clivly 0.8.0 → 0.9.1

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.
Files changed (112) hide show
  1. package/README.md +330 -134
  2. package/core-drizzle.cjs +3 -1
  3. package/core-drizzle.d.cts +3 -2
  4. package/core-drizzle.d.mts +3 -2
  5. package/core-drizzle.mjs +2 -2
  6. package/core-entity-config.cjs +2 -1
  7. package/core-entity-config.d.cts +2 -2
  8. package/core-entity-config.d.mts +2 -2
  9. package/core-entity-config.mjs +2 -2
  10. package/core-entity-heuristics.d.cts +3 -2
  11. package/core-entity-heuristics.d.mts +3 -2
  12. package/core-mapping-form.d.cts +1 -1
  13. package/core-mapping-form.d.mts +1 -1
  14. package/core-mappings-config.cjs +1 -1
  15. package/core-mappings-config.d.cts +1 -1
  16. package/core-mappings-config.d.mts +1 -1
  17. package/core-mappings-config.mjs +1 -1
  18. package/core-sync-engine.cjs +1 -1
  19. package/core-sync-engine.d.cts +1 -1
  20. package/core-sync-engine.d.mts +1 -1
  21. package/core-sync-engine.mjs +1 -1
  22. package/core-view-compiler.cjs +2 -1
  23. package/core-view-compiler.d.cts +2 -2
  24. package/core-view-compiler.d.mts +2 -2
  25. package/core-view-compiler.mjs +2 -2
  26. package/core.cjs +21 -5
  27. package/core.d.cts +7 -6
  28. package/core.d.mts +7 -6
  29. package/core.mjs +7 -6
  30. package/drizzle-3kvhkwO7.mjs +110 -0
  31. package/drizzle-DooDUGqO.d.mts +41 -0
  32. package/drizzle-bfjVzYR6.cjs +127 -0
  33. package/drizzle-jz0lrAgl.d.cts +41 -0
  34. package/drizzle.cjs +12 -40
  35. package/drizzle.d.cts +3 -11
  36. package/drizzle.d.mts +3 -11
  37. package/drizzle.mjs +12 -41
  38. package/entity-config-D08zAeFY.mjs +657 -0
  39. package/{entity-config-DoUCXtq5.d.cts → entity-config-fKQhimtD.d.cts} +263 -2
  40. package/{entity-config-DoUCXtq5.d.mts → entity-config-fKQhimtD.d.mts} +263 -2
  41. package/entity-config-nuXge6rY.cjs +778 -0
  42. package/index.cjs +768 -122
  43. package/index.d.cts +12 -0
  44. package/index.d.mts +12 -0
  45. package/index.mjs +769 -123
  46. package/internal-BGVUG312.mjs +68 -0
  47. package/internal-BLCK81kB.d.mts +72 -0
  48. package/internal-C-YBW2DB.d.cts +72 -0
  49. package/internal-D4HDi2GC.cjs +97 -0
  50. package/internal-drizzle.cjs +3 -1
  51. package/internal-drizzle.d.cts +2 -2
  52. package/internal-drizzle.d.mts +2 -2
  53. package/internal-drizzle.mjs +2 -2
  54. package/internal-entity-config.cjs +2 -1
  55. package/internal-entity-config.d.cts +2 -2
  56. package/internal-entity-config.d.mts +2 -2
  57. package/internal-entity-config.mjs +2 -2
  58. package/internal-entity-heuristics.cjs +28 -8
  59. package/internal-entity-heuristics.d.cts +2 -12
  60. package/internal-entity-heuristics.d.mts +2 -12
  61. package/internal-entity-heuristics.mjs +28 -8
  62. package/internal-mapping-form.d.cts +1 -1
  63. package/internal-mapping-form.d.mts +1 -1
  64. package/internal-mappings-config.cjs +1 -1
  65. package/internal-mappings-config.d.cts +1 -1
  66. package/internal-mappings-config.d.mts +1 -1
  67. package/internal-mappings-config.mjs +1 -1
  68. package/internal-sync-engine.cjs +1 -1
  69. package/internal-sync-engine.d.cts +1 -1
  70. package/internal-sync-engine.d.mts +1 -1
  71. package/internal-sync-engine.mjs +1 -1
  72. package/internal-view-compiler.cjs +2 -1
  73. package/internal-view-compiler.d.cts +2 -2
  74. package/internal-view-compiler.d.mts +2 -2
  75. package/internal-view-compiler.mjs +2 -2
  76. package/internal.cjs +21 -4
  77. package/internal.d.cts +7 -6
  78. package/internal.d.mts +7 -6
  79. package/internal.mjs +7 -5
  80. package/{mapping-form-D93lhHEc.d.mts → mapping-form-DhY0iFmN.d.cts} +1 -1
  81. package/{mapping-form-lfyEE7EJ.d.cts → mapping-form-Du36VZr1.d.mts} +1 -1
  82. package/{mappings-config-DsUa7-1w.d.mts → mappings-config-B1qNM8VL.d.cts} +7 -1
  83. package/{mappings-config-XmzB4dag.d.cts → mappings-config-BChZsany.d.mts} +7 -1
  84. package/{mappings-config-BsRADNRv.cjs → mappings-config-C8ynKmDK.cjs} +32 -6
  85. package/{mappings-config-Dbgxx3Nv.mjs → mappings-config-DNaYGyKN.mjs} +32 -6
  86. package/namespace-probe-BkUs8raG.d.mts +381 -0
  87. package/namespace-probe-aRFmpU85.d.cts +381 -0
  88. package/package.json +1 -1
  89. package/{entity-config-va2bkH5o.mjs → projection-C5244vGa.cjs} +130 -309
  90. package/{entity-config-D87rV-tE.cjs → projection-D7xmQP4S.mjs} +60 -381
  91. package/{sdk-iqOXeWIp.cjs → sdk-Bj-ZDf4u.cjs} +126 -28
  92. package/{sdk-C6WANW8M.mjs → sdk-BvRD2jAp.mjs} +126 -28
  93. package/sdk.cjs +2 -2
  94. package/sdk.d.cts +36 -1
  95. package/sdk.d.mts +36 -1
  96. package/sdk.mjs +2 -2
  97. package/{sync-engine-DGk_AeHJ.d.mts → sync-engine-DHQ7rXZe.d.mts} +2 -2
  98. package/{sync-engine-B7QIbXro.d.cts → sync-engine-DiPjH53O.d.cts} +2 -2
  99. package/{sync-engine-Do5ru3WF.mjs → sync-engine-SuxSzbZ4.mjs} +9 -2
  100. package/{sync-engine-CzEbpQGv.cjs → sync-engine-abE94g6X.cjs} +9 -2
  101. package/{view-compiler-BIwvm-o2.cjs → view-compiler-Blu4OXJ7.cjs} +103 -12
  102. package/{view-compiler-ChWr_Lml.mjs → view-compiler-CQx8TRrW.mjs} +98 -13
  103. package/{view-compiler-BWz0sGdm.d.cts → view-compiler-CcUwzZut.d.mts} +12 -2
  104. package/{view-compiler-Bn_EJJMO.d.mts → view-compiler-DqAdVxLq.d.cts} +12 -2
  105. package/vite.d.cts +1 -1
  106. package/vite.d.mts +1 -1
  107. package/drizzle-B89tLo4T.mjs +0 -31
  108. package/drizzle-CFOr9dU9.d.mts +0 -20
  109. package/drizzle-DiReZf6d.cjs +0 -36
  110. package/drizzle-DmeKSqeF.d.cts +0 -20
  111. package/types-CsL0rFYh.d.cts +0 -155
  112. package/types-CsL0rFYh.d.mts +0 -155
package/index.mjs CHANGED
@@ -1,16 +1,82 @@
1
1
  #!/usr/bin/env node
2
- import { t as CANONICAL_FIELDS } from "./entity-config-va2bkH5o.mjs";
2
+ import { t as CANONICAL_FIELDS } from "./entity-config-D08zAeFY.mjs";
3
3
  import "./mapping-score-C68uAqGB.mjs";
4
4
  import { matchesConcept, requiredFieldsFor, scoreFieldMap, scoreRelationships, suggestCursorField, toFieldMap } from "./internal-entity-heuristics.mjs";
5
- import { t as createClivlySDK } from "./sdk-C6WANW8M.mjs";
5
+ import { t as createClivlySDK } from "./sdk-BvRD2jAp.mjs";
6
6
  import { createRequire } from "node:module";
7
7
  import { spawn } from "node:child_process";
8
- import { accessSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
8
+ import { accessSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, watch, writeFileSync } from "node:fs";
9
9
  import { homedir, hostname } from "node:os";
10
10
  import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
11
11
  import { createInterface } from "node:readline/promises";
12
12
  import { createJiti } from "jiti";
13
13
  import { fileURLToPath, pathToFileURL } from "node:url";
14
+ //#region ../core/src/env-file.ts
15
+ const ENV_FILES = {
16
+ next: ".env.local",
17
+ "tanstack-start": ".env",
18
+ sveltekit: ".env",
19
+ remix: ".env",
20
+ nuxt: ".env",
21
+ "hono-workers": ".dev.vars"
22
+ };
23
+ /** The conventional env file for a framework. `.env` when we do not know. */
24
+ function envFileFor(framework) {
25
+ return ENV_FILES[normalizeFramework(framework)] ?? ".env";
26
+ }
27
+ const ALIASES = {
28
+ next: "next",
29
+ nextjs: "next",
30
+ "next.js": "next",
31
+ "tanstack-start": "tanstack-start",
32
+ tanstack: "tanstack-start",
33
+ sveltekit: "sveltekit",
34
+ remix: "remix",
35
+ nuxt: "nuxt",
36
+ "hono-workers": "hono-workers",
37
+ hono: "hono-workers",
38
+ workers: "hono-workers"
39
+ };
40
+ /** The canonical id for a framework name, or null when unrecognised. */
41
+ function normalizeFramework(framework) {
42
+ if (!framework) return null;
43
+ return ALIASES[framework.toLowerCase()] ?? null;
44
+ }
45
+ /**
46
+ * Which env file to actually write to.
47
+ *
48
+ * Prefers a file that already holds the key over the conventional one: a
49
+ * project that has been running with credentials in `.env` must not suddenly
50
+ * get a second copy in `.env.local`, leaving two files disagreeing and the
51
+ * stale one still loading. Convention applies to new setups, not to a working
52
+ * one someone already has.
53
+ */
54
+ function resolveEnvFile(input) {
55
+ const conventional = envFileFor(input.framework);
56
+ if (input.existing.includes(conventional)) return conventional;
57
+ return input.existing[0] ?? conventional;
58
+ }
59
+ //#endregion
60
+ //#region src/app-layout.ts
61
+ function resolveAppLayout(cwd, fileExists = existsSync) {
62
+ if (fileExists(join(cwd, "src", "app"))) return "src/app";
63
+ if (fileExists(join(cwd, "app"))) return "app";
64
+ return fileExists(join(cwd, "src")) ? "src/app" : "app";
65
+ }
66
+ function shadowedAppDirError(cwd, framework, plannedPaths, fileExists = existsSync) {
67
+ if (framework !== "nextjs") return null;
68
+ if (!plannedPaths.some((path) => path.startsWith("app/"))) return null;
69
+ if (!fileExists(join(cwd, "src", "app"))) return null;
70
+ return `This project has a src/app directory, so writing routes to app/ would shadow it.
71
+
72
+ Next.js reads either app/ or src/app/, never both. A root app/ directory wins,
73
+ and every route under src/app/ disappears from the build.
74
+
75
+ Nothing was written. Please report this at https://github.com/amani-joseph/clivly/issues
76
+ — Clivly should have detected the src/app layout automatically.
77
+ `;
78
+ }
79
+ //#endregion
14
80
  //#region src/load-config.ts
15
81
  const CONFIG_HINT = "Expected clivly.config.ts at your app root exporting `createClivlySDK({...})`.";
16
82
  function resolveConfigPath(cwd, configPath) {
@@ -108,6 +174,14 @@ async function loadCliConfig(cwd) {
108
174
  return envFiles ? { envFiles } : {};
109
175
  }
110
176
  //#endregion
177
+ //#region src/dashboard-locations.ts
178
+ /** Secret + publishable keys. The Connect dialog mints them and shows the secret once. */
179
+ const CREDENTIALS_LOCATION = "Clivly dashboard → Connect app (or Settings → API keys)";
180
+ /** The signing secret Clivly Cloud uses on sync-trigger callbacks. */
181
+ const SYNC_SECRET_LOCATION = "Clivly dashboard → Integrations → Remote sync";
182
+ /** Chat widget records and their allowed origins. */
183
+ const WIDGET_LOCATION = "Clivly dashboard → Settings → Chat widget";
184
+ //#endregion
111
185
  //#region src/detect.ts
112
186
  const FRAMEWORK_MARKERS = [
113
187
  {
@@ -253,8 +327,19 @@ const OVERRIDE_FAILURE_CAUSES = {
253
327
  not_enabled: "The target backend does not have the feature-flagged trigger-url endpoint enabled (likely the wrong backend, or the flag is off).",
254
328
  unreachable: "Could not reach Clivly at the selected API base URL."
255
329
  };
256
- function overrideFailureMessage(kind) {
257
- return `Warning: could not register the tunnel URL — ${OVERRIDE_FAILURE_CAUSES[kind]} Dashboard-triggered sync is unavailable this session. Your app is still running.`;
330
+ /**
331
+ * `status` is required in practice for `unexpected_response` and meaningless
332
+ * for the others.
333
+ *
334
+ * Every non-401/404 status used to collapse into `unreachable`, so the CLI told
335
+ * developers it "could not reach Clivly" while Clivly was answering. That is
336
+ * not a wording nit: when neon-http broke these endpoints in production the CLI
337
+ * discarded the one honest signal it had (HTTP 500) and sent people to check
338
+ * their network and API URL, where nothing was wrong. A response is proof of
339
+ * reachability — so say what came back.
340
+ */
341
+ function overrideFailureMessage(kind, status) {
342
+ return `Warning: could not register the tunnel URL — ${kind === "unexpected_response" ? `Clivly answered HTTP ${status ?? "an error"} — the request reached Clivly but was rejected, so this is a Clivly-side or request problem rather than a network one.` : OVERRIDE_FAILURE_CAUSES[kind]} Dashboard-triggered sync is unavailable this session. Your app is still running.`;
258
343
  }
259
344
  function cloudflaredMissingMessage() {
260
345
  return [
@@ -309,12 +394,16 @@ async function runDev(options) {
309
394
  deps.out(`Tunnel: ${triggerUrl}`);
310
395
  const result = await deps.register(triggerUrl).catch(() => UNREACHABLE_RESULT);
311
396
  if (result.ok) deps.out("Dashboard-triggered sync now points at this local session.");
312
- else deps.out(overrideFailureMessage(result.kind));
397
+ else deps.out(overrideFailureMessage(result.kind, result.status));
313
398
  });
399
+ const stopWatching = deps.watchConfig?.(options.cwd, () => {
400
+ deps.out("clivly.config.ts changed — restart your app for it to take effect.");
401
+ }) ?? null;
314
402
  return await new Promise((resolve) => {
315
403
  const shutdown = async (code) => {
316
404
  if (shuttingDown) return;
317
405
  shuttingDown = true;
406
+ stopWatching?.();
318
407
  tunnel.kill();
319
408
  app?.kill();
320
409
  if (!(await deps.clear().catch(() => UNREACHABLE_RESULT)).ok) deps.out("Warning: could not clear the tunnel override on exit.");
@@ -336,7 +425,18 @@ const HTTP_NOT_FOUND = 404;
336
425
  function kindForStatus(status) {
337
426
  if (status === HTTP_UNAUTHORIZED) return "unauthorized";
338
427
  if (status === HTTP_NOT_FOUND) return "not_enabled";
339
- return "unreachable";
428
+ return "unexpected_response";
429
+ }
430
+ function failureFor(status) {
431
+ const kind = kindForStatus(status);
432
+ return kind === "unexpected_response" ? {
433
+ ok: false,
434
+ kind,
435
+ status
436
+ } : {
437
+ ok: false,
438
+ kind
439
+ };
340
440
  }
341
441
  async function registerTunnelOverride(deps) {
342
442
  const res = await (deps.fetchImpl ?? globalThis.fetch)(`${deps.apiUrl}${TRIGGER_URL_PATH}`, {
@@ -348,10 +448,7 @@ async function registerTunnelOverride(deps) {
348
448
  body: JSON.stringify({ url: deps.triggerUrl })
349
449
  });
350
450
  if (res.ok) return { ok: true };
351
- return {
352
- ok: false,
353
- kind: kindForStatus(res.status)
354
- };
451
+ return failureFor(res.status);
355
452
  }
356
453
  async function clearTunnelOverride(deps) {
357
454
  const res = await (deps.fetchImpl ?? globalThis.fetch)(`${deps.apiUrl}${TRIGGER_URL_PATH}`, {
@@ -359,10 +456,7 @@ async function clearTunnelOverride(deps) {
359
456
  headers: { authorization: `Bearer ${deps.apiKey}` }
360
457
  });
361
458
  if (res.ok) return { ok: true };
362
- return {
363
- ok: false,
364
- kind: kindForStatus(res.status)
365
- };
459
+ return failureFor(res.status);
366
460
  }
367
461
  //#endregion
368
462
  //#region src/probe-module.ts
@@ -687,6 +781,30 @@ function buildFkByColumn(pgCore, drizzle, table) {
687
781
  } catch {}
688
782
  return fkByColumn;
689
783
  }
784
+ function buildUniqueConstraints(pgCore, table) {
785
+ if (!pgCore) return;
786
+ const keys = [];
787
+ try {
788
+ const config = pgCore.getTableConfig(table);
789
+ for (const column of config.columns ?? []) if (column.isUnique || column.primary) keys.push([column.name]);
790
+ for (const pk of config.primaryKeys ?? []) keys.push(pk.columns.map((c) => c.name));
791
+ for (const constraint of config.uniqueConstraints ?? []) keys.push(constraint.columns.map((c) => c.name));
792
+ for (const index of config.indexes ?? []) {
793
+ if (!index.config?.unique) continue;
794
+ const columns = (index.config.columns ?? []).map((c) => c?.name).filter((name) => Boolean(name));
795
+ if (columns.length > 0) keys.push(columns);
796
+ }
797
+ } catch {
798
+ return;
799
+ }
800
+ const seen = /* @__PURE__ */ new Set();
801
+ return keys.filter((key) => {
802
+ const id = [...key].sort().join("\0");
803
+ if (seen.has(id) || key.length === 0) return false;
804
+ seen.add(id);
805
+ return true;
806
+ });
807
+ }
690
808
  function toDiscoveredColumn(key, col, fkByColumn) {
691
809
  let type;
692
810
  try {
@@ -734,10 +852,12 @@ async function discoverSchema(opts) {
734
852
  if (drizzle.is(value, drizzle.Table)) {
735
853
  const fkByColumn = buildFkByColumn(pgCore, drizzle, value);
736
854
  const columns = Object.entries(drizzle.getTableColumns(value)).map(([key, col]) => toDiscoveredColumn(key, col, fkByColumn));
855
+ const uniqueConstraints = buildUniqueConstraints(pgCore, value);
737
856
  tables.push({
738
857
  moduleKey,
739
858
  tableName: drizzle.getTableName(value),
740
- columns
859
+ columns,
860
+ ...uniqueConstraints === void 0 ? {} : { uniqueConstraints }
741
861
  });
742
862
  continue;
743
863
  }
@@ -767,6 +887,29 @@ async function discoverSchema(opts) {
767
887
  };
768
888
  }
769
889
  //#endregion
890
+ //#region src/engine-check.ts
891
+ const MAJOR = /^v?(\d+)/;
892
+ const FIRST_NUMBER = /(\d+)/;
893
+ function currentMajor(version) {
894
+ const match = MAJOR.exec(version);
895
+ return match ? Number(match[1]) : null;
896
+ }
897
+ function requiredMajor(range) {
898
+ if (!range) return null;
899
+ const match = FIRST_NUMBER.exec(range);
900
+ return match ? Number(match[1]) : null;
901
+ }
902
+ function engineError(currentVersion, enginesRange) {
903
+ const have = currentMajor(currentVersion);
904
+ const need = requiredMajor(enginesRange);
905
+ if (have === null || need === null || have >= need) return null;
906
+ return `Clivly requires Node.js ${need} or newer. You are running ${currentVersion}.
907
+
908
+ Upgrade with \`nvm install ${need}\` (or your version manager of choice), then re-run.
909
+ Set CLIVLY_SKIP_ENGINE_CHECK=1 to run anyway — expect unrelated-looking failures.
910
+ `;
911
+ }
912
+ //#endregion
770
913
  //#region src/env-file.ts
771
914
  const REGEX_METACHARACTERS = /[.*+?^${}()|[\]\\]/g;
772
915
  function escapeForRegExp(key) {
@@ -851,6 +994,123 @@ function upsertEnvValues(existing, entries, options = {}) {
851
994
  };
852
995
  }
853
996
  //#endregion
997
+ //#region src/help.ts
998
+ const ROOT_HELP = `clivly — embed a CRM in your app
999
+
1000
+ Usage:
1001
+ clivly init [options] Scaffold Clivly (config, routes, env file) for your app
1002
+ clivly add widget [options] Scaffold the chat-session route
1003
+ clivly status [options] Check your setup, step by step
1004
+ clivly push-schema [options] Report your schema to Clivly without deploying
1005
+ clivly dev [options] Tunnel localhost so remote sync can reach you
1006
+ clivly login [options] Pair this machine and write credentials to your env file
1007
+ clivly --version Print the installed clivly version
1008
+
1009
+ Deprecated aliases — each runs part of \`clivly status\` and still works:
1010
+ clivly ping credential + reachability
1011
+ clivly connect the above, plus config and trigger URL
1012
+ clivly doctor config, setup checks and edge safety
1013
+
1014
+ Prefer \`clivly status\`: it runs the whole ladder and tells you which step you
1015
+ are on. The aliases are kept because output and docs have recommended them.
1016
+
1017
+ Run \`clivly <command> --help\` for details on a command.
1018
+ `;
1019
+ const COMMAND_HELP = {
1020
+ init: `clivly init — scaffold Clivly into this app
1021
+
1022
+ Usage:
1023
+ clivly init [--force] [--dry-run] [--diff] [--yes] [--no-login] [--no-browser]
1024
+ [--schema <path|package>] [--db-import <specifier>]
1025
+ [--auth-import <specifier>] [--contact-table <table>]
1026
+ [--company-table <table>]
1027
+
1028
+ Scaffolds clivly.config.ts, the Clivly route files, and .env placeholders.
1029
+ Detects your framework, ORM, auth adapter and app layout from the project.
1030
+
1031
+ Options:
1032
+ --dry-run Show what would be written; write nothing
1033
+ --diff Show a diff for files that already exist
1034
+ --force Overwrite files Clivly owns
1035
+ --yes Skip prompts
1036
+ --no-login Skip browser pairing
1037
+ --schema <p> Path or package holding your ORM schema
1038
+ --db-import <s> Module specifier exporting your database instance
1039
+ --auth-import <s> Module specifier exporting your auth instance
1040
+ --contact-table <t> Table to map as contacts
1041
+ --company-table <t> Table to map as companies
1042
+ `,
1043
+ add: `clivly add widget — scaffold the chat-session route
1044
+
1045
+ Usage:
1046
+ clivly add widget [--force] [--dry-run]
1047
+
1048
+ Writes the route that mints chat-widget sessions. Creates no widget record —
1049
+ that lives in the Clivly dashboard.
1050
+
1051
+ Options:
1052
+ --dry-run Show what would be written; write nothing
1053
+ --force Overwrite the route if it already exists
1054
+ `,
1055
+ status: `clivly status — check your setup, step by step
1056
+
1057
+ Usage:
1058
+ clivly status [--verbose] [--config <path>] [--env-file <path>]
1059
+
1060
+ Runs the setup ladder: credential, reachability, config load, edge-safety,
1061
+ setup checks, sync trigger. Read-only — writes nothing.
1062
+
1063
+ Options:
1064
+ --verbose Show every check, including those that passed
1065
+ --config <path> Path to clivly.config.ts
1066
+ --env-file <path> Extra env file to load
1067
+ `,
1068
+ "push-schema": `clivly push-schema — report your schema without deploying
1069
+
1070
+ Usage:
1071
+ clivly push-schema [--config <path>]
1072
+
1073
+ Sends your discovered tables to Clivly so the dashboard can offer them for
1074
+ mapping. Reads your config; writes nothing locally.
1075
+ `,
1076
+ dev: `clivly dev — tunnel localhost so remote sync can reach you
1077
+
1078
+ Usage:
1079
+ clivly dev [--port <n>] [--api-url <url>] [--route-path <path>] [-- <cmd>]
1080
+
1081
+ Opens a tunnel to your local app and points Clivly's sync trigger at it.
1082
+ Everything after \`--\` runs as your app's dev command.
1083
+
1084
+ Options:
1085
+ --port <n> Local port your app listens on
1086
+ --route-path <path> Path to your mounted tick route
1087
+ `,
1088
+ login: `clivly login — pair this machine with Clivly
1089
+
1090
+ Usage:
1091
+ clivly login [--force] [--rotate] [--api-url <url>] [--no-browser]
1092
+
1093
+ Opens a browser to approve this machine, then writes CLIVLY_SECRET_KEY and
1094
+ CLIVLY_SYNC_TRIGGER_SECRET to .env.
1095
+
1096
+ Options:
1097
+ --force Re-pair even if credentials already exist
1098
+ --rotate Replace the existing credentials
1099
+ --no-browser Print the URL instead of opening a browser
1100
+ `
1101
+ };
1102
+ function isHelpRequest(argv) {
1103
+ for (const arg of argv) {
1104
+ if (arg === "--") return false;
1105
+ if (arg === "--help" || arg === "-h") return true;
1106
+ }
1107
+ return false;
1108
+ }
1109
+ function helpFor(command) {
1110
+ if (!command) return ROOT_HELP;
1111
+ return COMMAND_HELP[command] ?? ROOT_HELP;
1112
+ }
1113
+ //#endregion
854
1114
  //#region src/install.ts
855
1115
  const defaultRun = (command, args, cwd) => new Promise((resolvePromise, reject) => {
856
1116
  const child = spawn(command, args, {
@@ -1090,11 +1350,11 @@ function credentialEntries(credentials) {
1090
1350
  return [{
1091
1351
  key: SECRET_KEY_ENV,
1092
1352
  value: credentials.apiKey,
1093
- comment: "Org secret key — Settings → API keys"
1353
+ comment: `Org secret key — ${CREDENTIALS_LOCATION}`
1094
1354
  }, {
1095
1355
  key: "CLIVLY_SYNC_TRIGGER_SECRET",
1096
1356
  value: credentials.triggerSecret,
1097
- comment: "Signing secret — Integrations → Remote sync"
1357
+ comment: `Signing secret — ${SYNC_SECRET_LOCATION}`
1098
1358
  }];
1099
1359
  }
1100
1360
  function writeCredentials(options, credentials) {
@@ -1181,12 +1441,19 @@ function chooseSyncFields(table, todos) {
1181
1441
  idField
1182
1442
  };
1183
1443
  }
1444
+ const SPLIT_NAME_COLUMNS = /* @__PURE__ */ new Set([
1445
+ "first_name",
1446
+ "firstname",
1447
+ "given_name"
1448
+ ]);
1184
1449
  function buildEntity(table, concept, todos) {
1185
1450
  const canonical = CANONICAL_FIELDS[concept];
1186
1451
  const suggested = toFieldMap(scoreFieldMap(concept, toScoredTable(table)));
1187
1452
  const fields = {};
1188
1453
  for (const [key, column] of Object.entries(suggested)) if (canonical.includes(key)) fields[key] = column;
1189
1454
  for (const required of requiredFieldsFor(concept)) if (!fields[required]) todos.push(`Could not map required ${concept} field "${required}" on "${table.tableName}" — add it manually.`);
1455
+ const nameColumn = fields.name?.toLowerCase();
1456
+ if (nameColumn && SPLIT_NAME_COLUMNS.has(nameColumn)) todos.push(`Mapped ${concept} "name" to "${fields.name}" on "${table.tableName}" — that is the given name only. Clivly mirrors one column per field, so add a full-name column if you want surnames.`);
1190
1457
  const { cursorField, idField } = chooseSyncFields(table, todos);
1191
1458
  return {
1192
1459
  moduleKey: table.moduleKey,
@@ -1235,8 +1502,10 @@ function planInit(schema, overrides = {}) {
1235
1502
  if (built && !contact) todos.push(`"${built.tableName}" was chosen as the contact entity but none of its columns could be mapped to name/email.`);
1236
1503
  let company = null;
1237
1504
  if (companyTable) {
1238
- const candidate = buildEntity(companyTable, "company", todos);
1505
+ const candidateTodos = [];
1506
+ const candidate = buildEntity(companyTable, "company", candidateTodos);
1239
1507
  if (hasMappedFields(candidate)) {
1508
+ todos.push(...candidateTodos);
1240
1509
  company = candidate;
1241
1510
  if (matchesConcept(companyTable.tableName, "contact")) todos.push(`"${companyTable.tableName}" matches both contact and company naming — assigned to company. Swap it to your contact entity if that's wrong.`);
1242
1511
  } else todos.push(`"${companyTable.tableName}" looked like a company but no fields could be mapped — left it out. Add it to \`entities\` once you know which columns map to name/domain.`);
@@ -1257,15 +1526,99 @@ function planInit(schema, overrides = {}) {
1257
1526
  contact,
1258
1527
  company,
1259
1528
  tableNames: schema.tables.map((table) => table.tableName),
1529
+ contactCandidates: rankContactCandidates(schema.tables),
1260
1530
  todos
1261
1531
  };
1262
1532
  }
1533
+ const CONTACT_COLUMN_EVIDENCE = [
1534
+ {
1535
+ label: "email",
1536
+ names: ["email", "email_address"],
1537
+ weight: 3
1538
+ },
1539
+ {
1540
+ label: "phone",
1541
+ names: [
1542
+ "phone",
1543
+ "phone_number",
1544
+ "mobile"
1545
+ ],
1546
+ weight: 2
1547
+ },
1548
+ {
1549
+ label: "first name",
1550
+ names: [
1551
+ "first_name",
1552
+ "firstname",
1553
+ "given_name"
1554
+ ],
1555
+ weight: 2
1556
+ },
1557
+ {
1558
+ label: "last name",
1559
+ names: [
1560
+ "last_name",
1561
+ "lastname",
1562
+ "surname",
1563
+ "family_name"
1564
+ ],
1565
+ weight: 2
1566
+ },
1567
+ {
1568
+ label: "name",
1569
+ names: [
1570
+ "name",
1571
+ "full_name",
1572
+ "display_name"
1573
+ ],
1574
+ weight: 1
1575
+ }
1576
+ ];
1577
+ const NAME_MATCH_WEIGHT = 4;
1578
+ /**
1579
+ * Rank the schema's tables by how much they look like people, best first.
1580
+ *
1581
+ * Used only on the failure path, where detection found nothing mappable and the
1582
+ * developer has to choose. `init` previously suggested `tableNames[0]` — the
1583
+ * first table in schema order — so a rental app was told to map its `lease`
1584
+ * table as contacts. Wrong guidance is worse than none: it is confident, and
1585
+ * the developer has no way to see it is wrong.
1586
+ *
1587
+ * Deliberately evidence-based rather than name-based alone, so a table whose
1588
+ * name Clivly has never heard of still surfaces on the strength of its columns.
1589
+ */
1590
+ function rankContactCandidates(tables) {
1591
+ return tables.map((table) => {
1592
+ const columns = new Set(table.columns.flatMap((c) => [c.dbName.toLowerCase(), c.key.toLowerCase()]));
1593
+ const reasons = [];
1594
+ let score = 0;
1595
+ if (matchesConcept(table.tableName, "contact")) {
1596
+ score += NAME_MATCH_WEIGHT;
1597
+ reasons.push("the name looks like a people table");
1598
+ }
1599
+ for (const evidence of CONTACT_COLUMN_EVIDENCE) {
1600
+ const hit = evidence.names.find((name) => columns.has(name));
1601
+ if (hit) {
1602
+ score += evidence.weight;
1603
+ reasons.push(hit);
1604
+ }
1605
+ }
1606
+ return {
1607
+ reasons,
1608
+ score,
1609
+ table: table.tableName
1610
+ };
1611
+ }).filter((candidate) => candidate.score > 0).sort((a, b) => b.score - a.score || a.table.localeCompare(b.table)).map(({ reasons, table }) => ({
1612
+ reasons,
1613
+ table
1614
+ }));
1615
+ }
1263
1616
  //#endregion
1264
1617
  //#region src/render-config.ts
1265
1618
  const CLIVLY_ENV_KEYS = [
1266
1619
  {
1267
1620
  key: "CLIVLY_SECRET_KEY",
1268
- comment: "Org secret key — Settings → API keys"
1621
+ comment: `Org secret key — ${CREDENTIALS_LOCATION}`
1269
1622
  },
1270
1623
  {
1271
1624
  key: "CLIVLY_SYNC_TRIGGER_URL",
@@ -1273,7 +1626,7 @@ const CLIVLY_ENV_KEYS = [
1273
1626
  },
1274
1627
  {
1275
1628
  key: "CLIVLY_SYNC_TRIGGER_SECRET",
1276
- comment: "Signing secret — Integrations → Remote sync"
1629
+ comment: `Signing secret — ${SYNC_SECRET_LOCATION}`
1277
1630
  }
1278
1631
  ];
1279
1632
  function renderFields(fields) {
@@ -1302,6 +1655,28 @@ function renderDbExpr(plan) {
1302
1655
  if (!plan.db) return "db";
1303
1656
  return dbIsInSchemaModule(plan) ? `schema.${plan.db.moduleKey}` : plan.db.moduleKey;
1304
1657
  }
1658
+ const TRAILING_SCHEMA_SEGMENT = /\/schema(\/index)?$/;
1659
+ /**
1660
+ * Where the `db` instance most likely lives, given where the schema was found.
1661
+ *
1662
+ * Only reached when discovery could not confirm one. Discovery *evaluates* a
1663
+ * candidate module and duck-types its exports for `.select`, which is the right
1664
+ * check but fails for the most common real-world db module: `drizzle(process.
1665
+ * env.DATABASE_URL!)` throws at import time when the env var is absent, and it
1666
+ * is absent precisely when a developer is running `clivly init` for the first
1667
+ * time. So the probe misses, through no fault of the project.
1668
+ *
1669
+ * The old fallback emitted `<YOUR_DB_MODULE>` — not a module specifier, not
1670
+ * valid TypeScript, and invisible until the config failed to load at runtime.
1671
+ * A best-guess relative path is strictly better: right for the conventional
1672
+ * layout (`lib/db/schema.ts` beside `lib/db/index.ts`), and when it is wrong it
1673
+ * is a module-not-found error at build time, naming a real path the developer
1674
+ * can correct.
1675
+ */
1676
+ function guessDbImportPath(schemaImportPath) {
1677
+ const trimmed = schemaImportPath.replace(TRAILING_SCHEMA_SEGMENT, "");
1678
+ return trimmed === schemaImportPath || trimmed === "." || trimmed === "" ? "./db" : trimmed;
1679
+ }
1305
1680
  function renderImports(plan) {
1306
1681
  const lines = [
1307
1682
  "import { createClivlySDK, defineClivlyConfig } from \"clivly/sdk\";",
@@ -1309,8 +1684,10 @@ function renderImports(plan) {
1309
1684
  "import { discoverFromDrizzle } from \"clivly/drizzle\";",
1310
1685
  `import * as schema from "${plan.importPath}";`
1311
1686
  ];
1312
- if (!plan.db) lines.push("import { db } from \"<YOUR_DB_MODULE>\"; // TODO(clivly): replace with the path to your Drizzle `db` instance — NOT your schema file");
1313
- else if (!dbIsInSchemaModule(plan)) lines.push(`import { ${plan.db.moduleKey} } from "${plan.db.importPath}";`);
1687
+ if (plan.db === null) {
1688
+ const guess = guessDbImportPath(plan.importPath);
1689
+ lines.push(`import { db } from "${guess}"; // TODO(clivly): verify this path points at your Drizzle \`db\` instance — NOT your schema file`);
1690
+ } else if (!dbIsInSchemaModule(plan)) lines.push(`import { ${plan.db.moduleKey} } from "${plan.db.importPath}";`);
1314
1691
  return lines.join("\n");
1315
1692
  }
1316
1693
  function renderTodoHeader(todos) {
@@ -1428,6 +1805,83 @@ function renderAuthWiring(auth, authInstance = null) {
1428
1805
  }
1429
1806
  }
1430
1807
  //#endregion
1808
+ //#region src/tsconfig-alias.ts
1809
+ const LEADING_DOT_SLASH$1 = /^\.\//;
1810
+ const TRAILING_WILDCARD = /\/?\*$/;
1811
+ const TRAILING_SLASH = /\/$/;
1812
+ const BLOCK_COMMENT = /\/\*[\s\S]*?\*\//g;
1813
+ const LINE_COMMENT = /(^|[^:])\/\/.*$/gm;
1814
+ const TRAILING_COMMA = /,(\s*[}\]])/g;
1815
+ /**
1816
+ * `compilerOptions` from a tsconfig's text, or null when it cannot be read.
1817
+ *
1818
+ * tsconfig is JSONC — comments and trailing commas are legal and common, and
1819
+ * `JSON.parse` rejects both. Stripping them is enough here: this only ever reads
1820
+ * two string-valued fields, so the cost of being wrong is falling back to a
1821
+ * relative import, which already works.
1822
+ */
1823
+ function parseTsconfig(source) {
1824
+ try {
1825
+ const stripped = source.replace(BLOCK_COMMENT, "").replace(LINE_COMMENT, "$1").replace(TRAILING_COMMA, "$1");
1826
+ return JSON.parse(stripped).compilerOptions ?? null;
1827
+ } catch {
1828
+ return null;
1829
+ }
1830
+ }
1831
+ /**
1832
+ * Strip a leading "./" and any trailing wildcard so two paths can be compared.
1833
+ *
1834
+ * `"./*"` — an alias rooted at the app itself — must reduce to the empty string,
1835
+ * not to a stray `"*"`; that is the mapping which makes a root-level
1836
+ * clivly.config alias-reachable.
1837
+ */
1838
+ function normalize(value) {
1839
+ return value.replace(LEADING_DOT_SLASH$1, "").replace(TRAILING_WILDCARD, "").replace(TRAILING_SLASH, "");
1840
+ }
1841
+ /**
1842
+ * The alias specifier that reaches `target`, or null when none does.
1843
+ *
1844
+ * `target` is app-root-relative and extensionless, e.g. `lib/auth` or
1845
+ * `clivly.config`. Only wildcard patterns are considered — an exact mapping
1846
+ * names one module and is not a prefix anything else can be rebased onto.
1847
+ *
1848
+ * Longest prefix wins, so `"@components/*": ["./src/components/*"]` beats
1849
+ * `"@/*": ["./src/*"]` for a module under `src/components`, matching how
1850
+ * TypeScript itself resolves.
1851
+ */
1852
+ function aliasFor(input) {
1853
+ if (!input.paths) return null;
1854
+ const target = normalize(input.target);
1855
+ let best = null;
1856
+ for (const [pattern, targets] of Object.entries(input.paths)) {
1857
+ if (!pattern.endsWith("/*")) continue;
1858
+ const prefix = pattern.slice(0, -2);
1859
+ for (const mapped of targets) {
1860
+ const base = baseReaching(mapped, target);
1861
+ if (base === null) continue;
1862
+ const rest = base === "" ? target : target.slice(base.length + 1);
1863
+ if (!best || base.length > best.length) best = {
1864
+ alias: `${prefix}/${rest}`,
1865
+ length: base.length
1866
+ };
1867
+ }
1868
+ }
1869
+ return best?.alias ?? null;
1870
+ }
1871
+ /**
1872
+ * The normalized base of `mapped` when it contains `target`, else null.
1873
+ *
1874
+ * A prefix must end at a segment boundary — `src` must not match `srcinternal`,
1875
+ * a different directory — and the target must be strictly inside the base, since
1876
+ * a wildcard needs a remainder to substitute.
1877
+ */
1878
+ function baseReaching(mapped, target) {
1879
+ if (!mapped.endsWith("/*")) return null;
1880
+ const base = normalize(mapped);
1881
+ if (base === "") return target === "" ? null : "";
1882
+ return target.startsWith(`${base}/`) ? base : null;
1883
+ }
1884
+ //#endregion
1431
1885
  //#region src/scaffold.ts
1432
1886
  const TANSTACK_ROUTE_PREFIX = /^src\/routes\//;
1433
1887
  const TS_EXTENSION = /\.ts$/;
@@ -1459,10 +1913,14 @@ const ROUTE_PATHS = {
1459
1913
  },
1460
1914
  unknown: null
1461
1915
  };
1462
- function routePath(framework, kind) {
1916
+ function applyLayout(framework, path, layout) {
1917
+ if (framework !== "nextjs" || layout === "app") return path;
1918
+ return `src/${path}`;
1919
+ }
1920
+ function routePath(framework, kind, layout = "app") {
1463
1921
  const paths = ROUTE_PATHS[framework];
1464
1922
  if (!paths) throw new Error(`No route path for framework: ${framework}`);
1465
- return paths[kind];
1923
+ return applyLayout(framework, paths[kind], layout);
1466
1924
  }
1467
1925
  const WINDOWS_SEP = /\\/g;
1468
1926
  const LEADING_DOT_SLASH = /^\.\//;
@@ -1470,22 +1928,27 @@ function configImport(routeFilePath) {
1470
1928
  const rel = relative(dirname(routeFilePath), "clivly.config").replace(WINDOWS_SEP, "/");
1471
1929
  return rel.startsWith(".") ? rel : `./${rel}`;
1472
1930
  }
1473
- function hostImportPath(routeFilePath, appRelative) {
1931
+ function hostImportPath(routeFilePath, appRelative, paths) {
1474
1932
  const fromRoot = appRelative.replace(LEADING_DOT_SLASH, "");
1933
+ const alias = aliasFor({
1934
+ paths,
1935
+ target: fromRoot
1936
+ });
1937
+ if (alias) return alias;
1475
1938
  const rel = relative(dirname(routeFilePath), fromRoot).replace(WINDOWS_SEP, "/");
1476
1939
  return rel.startsWith(".") ? rel : `./${rel}`;
1477
1940
  }
1478
- function renderAuthPreamble(routeFilePath, auth) {
1941
+ function renderAuthPreamble(routeFilePath, auth, paths) {
1479
1942
  const lines = [...auth.imports];
1480
1943
  if (auth.hostImport) {
1481
1944
  if (auth.hostImport.todo) lines.push(`// ${auth.hostImport.todo}`);
1482
- const path = hostImportPath(routeFilePath, auth.hostImport.path);
1945
+ const path = hostImportPath(routeFilePath, auth.hostImport.path, paths);
1483
1946
  lines.push(`import { ${auth.hostImport.binding} } from "${path}";`);
1484
1947
  }
1485
1948
  lines.push("", auth.body);
1486
1949
  return lines.join("\n");
1487
1950
  }
1488
- function renderPreamble(kind, importPath, auth, routeFilePath) {
1951
+ function renderPreamble(kind, importPath, auth, paths, routeFilePath) {
1489
1952
  if (kind === "session") return `import { createChatSessionHandler } from "clivly/sdk";
1490
1953
 
1491
1954
  const handler = createChatSessionHandler({
@@ -1496,7 +1959,7 @@ const handler = createChatSessionHandler({
1496
1959
  });
1497
1960
  `;
1498
1961
  if (kind === "verify") return `import clivly from "${importPath}";
1499
- ${renderAuthPreamble(routeFilePath, auth)}
1962
+ ${renderAuthPreamble(routeFilePath, auth, paths)}
1500
1963
  const handler = clivly.createAuthVerifyHandler(resolveUser);
1501
1964
  `;
1502
1965
  return `import clivly from "${importPath}";
@@ -1504,8 +1967,8 @@ const handler = clivly.createAuthVerifyHandler(resolveUser);
1504
1967
  const handler = clivly.createClivlyHandler();
1505
1968
  `;
1506
1969
  }
1507
- function renderBody(framework, importPath, kind, auth, routeFilePath) {
1508
- const preamble = renderPreamble(kind, importPath, auth, routeFilePath);
1970
+ function renderBody(framework, importPath, kind, auth, routeFilePath, paths) {
1971
+ const preamble = renderPreamble(kind, importPath, auth, paths, routeFilePath);
1509
1972
  switch (framework) {
1510
1973
  case "nextjs": return `${preamble}
1511
1974
  export const POST = (request: Request) => handler(request);
@@ -1535,9 +1998,9 @@ export default defineEventHandler((event) => handler(toWebRequest(event)));
1535
1998
  default: throw new Error(`No route template for framework: ${framework}`);
1536
1999
  }
1537
2000
  }
1538
- function renderRouteFile(framework, kind, auth = renderAuthWiring("unknown")) {
1539
- const path = routePath(framework, kind);
1540
- let body = renderBody(framework, configImport(path), kind, auth, path);
2001
+ function renderRouteFile(framework, kind, auth = renderAuthWiring("unknown"), layout = "app", paths) {
2002
+ const path = routePath(framework, kind, layout);
2003
+ let body = renderBody(framework, configImport(path), kind, auth, path, paths);
1541
2004
  if (framework === "tanstack-start") {
1542
2005
  const url = `/${path.replace(TANSTACK_ROUTE_PREFIX, "").replace(TS_EXTENSION, "")}`;
1543
2006
  body = body.replace("__ROUTE__", url);
@@ -1549,28 +2012,28 @@ const COMMON_FILES = [{
1549
2012
  description: "Your Clivly configuration",
1550
2013
  owned: false
1551
2014
  }];
1552
- function planScaffold(framework) {
2015
+ function planScaffold(framework, layout = "app") {
1553
2016
  const paths = ROUTE_PATHS[framework];
1554
2017
  if (!paths) return [];
1555
2018
  return [
1556
2019
  {
1557
- path: paths.tick,
2020
+ path: applyLayout(framework, paths.tick, layout),
1558
2021
  description: "Clivly sync-trigger route (createClivlyHandler)",
1559
2022
  owned: true
1560
2023
  },
1561
2024
  {
1562
- path: paths.verify,
2025
+ path: applyLayout(framework, paths.verify, layout),
1563
2026
  description: "Clivly auth-verify route (createAuthVerifyHandler) — finish resolveUser",
1564
2027
  owned: true
1565
2028
  },
1566
2029
  ...COMMON_FILES
1567
2030
  ];
1568
2031
  }
1569
- function planWidgetScaffold(framework) {
2032
+ function planWidgetScaffold(framework, layout = "app") {
1570
2033
  const paths = ROUTE_PATHS[framework];
1571
2034
  if (!paths) return [];
1572
2035
  return [{
1573
- path: paths.session,
2036
+ path: applyLayout(framework, paths.session, layout),
1574
2037
  description: "Clivly chat-session route (createChatSessionHandler) — add allowedOrigins",
1575
2038
  owned: true
1576
2039
  }];
@@ -1590,9 +2053,9 @@ function planFiles(args) {
1590
2053
  const render = (path) => {
1591
2054
  if (path === "clivly.config.ts") return renderInitConfig(args.plan);
1592
2055
  const kind = path.includes("verify") ? "verify" : "tick";
1593
- return renderRouteFile(args.framework, kind, args.authWiring);
2056
+ return renderRouteFile(args.framework, kind, args.authWiring, args.layout, parseTsconfig(args.readFile(join(args.cwd, "tsconfig.json")) ?? "")?.paths);
1594
2057
  };
1595
- for (const file of planScaffold(args.framework)) {
2058
+ for (const file of planScaffold(args.framework, args.layout)) {
1596
2059
  const contents = render(file.path);
1597
2060
  mutations.push({
1598
2061
  path: file.path,
@@ -1623,8 +2086,8 @@ function planMutations(args) {
1623
2086
  };
1624
2087
  }
1625
2088
  function planWidgetMutations(args) {
1626
- return planWidgetScaffold(args.framework).map((file) => {
1627
- const contents = renderRouteFile(args.framework, "session");
2089
+ return planWidgetScaffold(args.framework, args.layout).map((file) => {
2090
+ const contents = renderRouteFile(args.framework, "session", void 0, args.layout);
1628
2091
  return {
1629
2092
  path: file.path,
1630
2093
  contents,
@@ -1949,7 +2412,17 @@ const ALLOWED_CALLS = /* @__PURE__ */ new Set([
1949
2412
  "while"
1950
2413
  ]);
1951
2414
  const SIDE_EFFECT_CALLS = /^(dotenv\.config|config|configDotenv|fileURLToPath|readFileSync|require|process\.chdir)$/;
2415
+ const NATIVE_PACKAGES = /* @__PURE__ */ new Set([
2416
+ "bcrypt",
2417
+ "better-sqlite3",
2418
+ "canvas",
2419
+ "node-gyp",
2420
+ "pg-native",
2421
+ "sharp",
2422
+ "sqlite3"
2423
+ ]);
1952
2424
  const ENV_HINT = "Move it to clivly.cli.ts (`envFiles`) or pass --env-file — the CLI reads those in Node, so your config stays edge-safe.";
2425
+ const NATIVE_HINT = "A compiled binary cannot load on an edge runtime — this is the \"Could not locate the bindings file\" / ERR_DLOPEN_FAILED failure. Import your db lazily inside a function, or use a driver that speaks HTTP (e.g. @neondatabase/serverless) in the config.";
1953
2426
  function isNodeBuiltin(specifier) {
1954
2427
  if (specifier.startsWith("node:")) return true;
1955
2428
  return NODE_BUILTINS.has(specifier.split("/")[0] ?? "");
@@ -1966,6 +2439,12 @@ function specifierIssue(specifier, line) {
1966
2439
  hint: ENV_HINT,
1967
2440
  line
1968
2441
  };
2442
+ if (NATIVE_PACKAGES.has(specifier.split("/")[0] ?? "")) return {
2443
+ code: "native_module_in_edge_config",
2444
+ detail: `imports "${specifier}", which ships a compiled binary`,
2445
+ hint: NATIVE_HINT,
2446
+ line
2447
+ };
1969
2448
  if (isNodeBuiltin(specifier)) return {
1970
2449
  code: "node_builtin_in_edge_config",
1971
2450
  detail: `imports "${specifier}", which does not exist on an edge runtime`,
@@ -2005,8 +2484,56 @@ function analyzeEdgeSafety(source) {
2005
2484
  });
2006
2485
  return issues;
2007
2486
  }
2487
+ /** Every relative specifier `source` imports, in first-seen order, deduped. */
2488
+ function relativeImports(source) {
2489
+ const seen = /* @__PURE__ */ new Set();
2490
+ for (const rawLine of source.split(NEWLINE)) {
2491
+ const line = stripComment(rawLine);
2492
+ const specifier = BARE_IMPORT.exec(line)?.[1] ?? IMPORT_FROM.exec(line)?.[1] ?? REQUIRE_CALL.exec(line)?.[1];
2493
+ if (specifier?.startsWith(".")) seen.add(specifier);
2494
+ }
2495
+ return [...seen];
2496
+ }
2497
+ /**
2498
+ * Edge-safety for the config *and* the modules it imports directly.
2499
+ *
2500
+ * The dominant real-world shape is a clean-looking config with
2501
+ * `import { db } from "./lib/db"`, where the problem is one hop away and
2502
+ * invisible to a scan that reads only the config file.
2503
+ *
2504
+ * One hop, not more: each additional level multiplies the files read for a
2505
+ * shrinking return, and the config's own imports are where a host app puts its
2506
+ * database. Bare specifiers are never followed — resolving into node_modules is
2507
+ * a different job with different failure modes, and the denylist already covers
2508
+ * the packages that matter.
2509
+ *
2510
+ * `readModule` returns source text, or null when the module cannot be read;
2511
+ * unreadable is not a finding, since a scan is not a build.
2512
+ */
2513
+ function analyzeConfigTree(input) {
2514
+ const issues = analyzeEdgeSafety(input.source);
2515
+ for (const specifier of relativeImports(input.source)) {
2516
+ const source = input.readModule(specifier);
2517
+ if (source === null) continue;
2518
+ for (const issue of analyzeEdgeSafety(source)) issues.push({
2519
+ ...issue,
2520
+ module: specifier
2521
+ });
2522
+ }
2523
+ return issues;
2524
+ }
2008
2525
  //#endregion
2009
2526
  //#region src/status-stages.ts
2527
+ const MODULE_CANDIDATES = [
2528
+ ".ts",
2529
+ ".tsx",
2530
+ ".js",
2531
+ ".mjs",
2532
+ ".cjs",
2533
+ "/index.ts",
2534
+ "/index.js",
2535
+ ""
2536
+ ];
2010
2537
  function defaultReadConfigSource(path) {
2011
2538
  try {
2012
2539
  return readFileSync(path, "utf8");
@@ -2021,10 +2548,11 @@ const REASON_HINT = {
2021
2548
  rate_limited: "Rate limited (429). Wait a moment and retry.",
2022
2549
  server_error: "Clivly returned a server error (5xx). Retry shortly.",
2023
2550
  client_error: "The request was rejected (4xx). Check your configuration.",
2551
+ server_too_old: "This Clivly server is older than your CLI and has no /clivly/sdk/ping. Upgrade the server, or pin the CLI to match it.",
2024
2552
  network_error: "Could not reach Clivly. Check your network or --api-url."
2025
2553
  };
2026
2554
  const apiKeyStage = {
2027
- id: "api-key",
2555
+ id: "credential",
2028
2556
  label: "API key present",
2029
2557
  run(ctx) {
2030
2558
  if (ctx.apiKey) return Promise.resolve({
@@ -2034,20 +2562,29 @@ const apiKeyStage = {
2034
2562
  return Promise.resolve({
2035
2563
  state: "fail",
2036
2564
  detail: "Secret key is not set.",
2037
- hint: "Copy your secret key from Settings → API keys into CLIVLY_SECRET_KEY in .env."
2565
+ hint: `Copy your secret key from ${CREDENTIALS_LOCATION} into CLIVLY_SECRET_KEY in .env.`
2038
2566
  });
2039
2567
  }
2040
2568
  };
2041
- const heartbeatStage = {
2042
- id: "heartbeat",
2569
+ const reachabilityStage = {
2570
+ id: "application",
2043
2571
  label: "Clivly reachable",
2044
2572
  needs: [apiKeyStage.id],
2045
2573
  async run(ctx) {
2046
2574
  const result = await ctx.connect(ctx.apiKey, ctx.apiUrl);
2047
- if (result.ok) return {
2048
- state: "pass",
2049
- detail: `Reached org: ${result.org?.name ?? "(unnamed org)"}${result.org?.id ? ` (${result.org.id})` : ""}`
2050
- };
2575
+ if (result.ok) {
2576
+ const name = result.org?.name ?? "(unnamed org)";
2577
+ const id = result.org?.id ? ` (${result.org.id})` : "";
2578
+ if (result.appLastSeenAt === null) return {
2579
+ state: "warn",
2580
+ detail: `Reached org: ${name}${id} — your app has not connected yet`,
2581
+ hint: "Start your app with the Clivly SDK mounted; it will appear in the dashboard within a minute."
2582
+ };
2583
+ return {
2584
+ state: "pass",
2585
+ detail: `Reached org: ${name}${id}`
2586
+ };
2587
+ }
2051
2588
  const status = result.status === null ? "no response" : `HTTP ${result.status}`;
2052
2589
  return {
2053
2590
  state: "fail",
@@ -2075,22 +2612,76 @@ const configStage = {
2075
2612
  }
2076
2613
  }
2077
2614
  };
2078
- const LADDER_OWNED_CHECKS = /* @__PURE__ */ new Set(["API key present", "Heartbeat"]);
2615
+ const LADDER_OWNED_CHECKS = /* @__PURE__ */ new Set(["API key present", "Clivly reachable"]);
2616
+ const sdkChecksStage = {
2617
+ id: "setup-checks",
2618
+ label: "Setup checks",
2619
+ needs: [configStage.id],
2620
+ async run(ctx) {
2621
+ const owned = (await ctx.sdk.doctor()).checks.filter((check) => !LADDER_OWNED_CHECKS.has(check.name));
2622
+ const counted = owned.filter((check) => !check.skipped);
2623
+ const passed = counted.filter((check) => check.ok).length;
2624
+ const ok = counted.every((check) => check.ok);
2625
+ const warned = counted.filter((check) => check.warn).length;
2626
+ const summary = warned > 0 ? `${passed} of ${counted.length} passed, ${warned} with warnings` : `${passed} of ${counted.length} passed`;
2627
+ return {
2628
+ state: ok ? "pass" : "fail",
2629
+ detail: summary,
2630
+ sub: owned.map((check) => ({
2631
+ label: check.name,
2632
+ ok: check.ok,
2633
+ skipped: check.skipped,
2634
+ warn: check.warn,
2635
+ detail: check.detail
2636
+ }))
2637
+ };
2638
+ }
2639
+ };
2640
+ const triggerUrlStage = {
2641
+ id: "trigger-url",
2642
+ label: "Sync trigger URL",
2643
+ needs: [configStage.id],
2644
+ run(ctx) {
2645
+ const url = ctx.sdk?.syncTrigger?.url;
2646
+ if (url) return Promise.resolve({
2647
+ state: "pass",
2648
+ detail: url
2649
+ });
2650
+ return Promise.resolve({
2651
+ state: "warn",
2652
+ detail: "Not set — remote sync cannot call back into this app.",
2653
+ hint: "Set CLIVLY_SYNC_TRIGGER_URL once the app is deployed. For local development run `clivly dev` — it opens a tunnel and registers the URL for you. Push-only setups can ignore this."
2654
+ });
2655
+ }
2656
+ };
2079
2657
  /** The full ladder, in the order a developer completes setup. */
2080
2658
  const STATUS_STAGES = [
2081
2659
  apiKeyStage,
2082
- heartbeatStage,
2660
+ reachabilityStage,
2083
2661
  configStage,
2084
2662
  {
2085
2663
  id: "runtime",
2086
2664
  label: "clivly.config is edge-safe",
2665
+ needs: [configStage.id],
2087
2666
  run(ctx) {
2088
2667
  const source = (ctx.readConfigSource ?? defaultReadConfigSource)(ctx.configPath);
2089
2668
  if (source === null) return Promise.resolve({
2090
2669
  state: "skip",
2091
2670
  detail: "config not readable"
2092
2671
  });
2093
- const issues = analyzeEdgeSafety(source);
2672
+ const read = ctx.readConfigSource ?? defaultReadConfigSource;
2673
+ const base = dirname(ctx.configPath);
2674
+ const issues = analyzeConfigTree({
2675
+ source,
2676
+ readModule: (specifier) => {
2677
+ const target = resolve(base, specifier);
2678
+ for (const candidate of MODULE_CANDIDATES) {
2679
+ const found = read(`${target}${candidate}`);
2680
+ if (found !== null) return found;
2681
+ }
2682
+ return null;
2683
+ }
2684
+ });
2094
2685
  if (issues.length === 0) return Promise.resolve({
2095
2686
  state: "pass",
2096
2687
  detail: "no Node-only code at module scope"
@@ -2107,43 +2698,8 @@ const STATUS_STAGES = [
2107
2698
  });
2108
2699
  }
2109
2700
  },
2110
- {
2111
- id: "setup-checks",
2112
- label: "Setup checks",
2113
- needs: [configStage.id],
2114
- async run(ctx) {
2115
- const owned = (await ctx.sdk.doctor()).checks.filter((check) => !LADDER_OWNED_CHECKS.has(check.name));
2116
- const counted = owned.filter((check) => !check.skipped);
2117
- const passed = counted.filter((check) => check.ok).length;
2118
- return {
2119
- state: counted.every((check) => check.ok) ? "pass" : "fail",
2120
- detail: `${passed} of ${counted.length} passed`,
2121
- sub: owned.map((check) => ({
2122
- label: check.name,
2123
- ok: check.ok,
2124
- skipped: check.skipped,
2125
- detail: check.detail
2126
- }))
2127
- };
2128
- }
2129
- },
2130
- {
2131
- id: "trigger-url",
2132
- label: "Sync trigger URL",
2133
- needs: [configStage.id],
2134
- run(ctx) {
2135
- const url = ctx.sdk?.syncTrigger?.url;
2136
- if (url) return Promise.resolve({
2137
- state: "pass",
2138
- detail: url
2139
- });
2140
- return Promise.resolve({
2141
- state: "warn",
2142
- detail: "Not set — remote sync cannot call back into this app.",
2143
- hint: "Set CLIVLY_SYNC_TRIGGER_URL once the app is deployed. For local development run `clivly dev` — it opens a tunnel and registers the URL for you. Push-only setups can ignore this."
2144
- });
2145
- }
2146
- }
2701
+ sdkChecksStage,
2702
+ triggerUrlStage
2147
2703
  ];
2148
2704
  function defaultLoadSdk(path) {
2149
2705
  return loadConfigSdk(path, "doctor");
@@ -2153,15 +2709,15 @@ function defaultLoadSdk(path) {
2153
2709
  /** Stage ids each legacy command maps onto. */
2154
2710
  const ALIAS_STAGE_IDS = {
2155
2711
  status: STATUS_STAGES.map((stage) => stage.id),
2156
- ping: ["api-key", "heartbeat"],
2712
+ ping: ["credential", "application"],
2157
2713
  doctor: [
2158
2714
  "config",
2159
2715
  "runtime",
2160
2716
  "setup-checks"
2161
2717
  ],
2162
2718
  connect: [
2163
- "api-key",
2164
- "heartbeat",
2719
+ "credential",
2720
+ "application",
2165
2721
  "config",
2166
2722
  "trigger-url"
2167
2723
  ]
@@ -2177,9 +2733,9 @@ const defaultConnect = (apiKey, apiUrl) => createClivlySDK({
2177
2733
  apiKey,
2178
2734
  apiUrl,
2179
2735
  retry: { maxAttempts: 1 }
2180
- }).connect();
2736
+ }).ping();
2181
2737
  function subMark(sub) {
2182
- if (sub.skipped) return "⚠️ ";
2738
+ if (sub.skipped || sub.warn) return "⚠️ ";
2183
2739
  return sub.ok ? "✅" : "❌";
2184
2740
  }
2185
2741
  function renderStage(result, verbose, out) {
@@ -2187,7 +2743,7 @@ function renderStage(result, verbose, out) {
2187
2743
  const detail = result.detail ? ` — ${result.detail}` : "";
2188
2744
  out(` ${MARK[result.state]} ${step} · ${result.label}${detail}\n`);
2189
2745
  for (const sub of result.sub ?? []) {
2190
- if (!(verbose || sub.skipped || !sub.ok)) continue;
2746
+ if (!(verbose || sub.skipped || sub.warn || !sub.ok)) continue;
2191
2747
  const subDetail = sub.detail ? ` — ${sub.detail}` : "";
2192
2748
  out(` ${subMark(sub)} ${sub.label}${subDetail}\n`);
2193
2749
  }
@@ -2213,6 +2769,12 @@ async function runStatus(options) {
2213
2769
  }
2214
2770
  //#endregion
2215
2771
  //#region src/index.ts
2772
+ const ENV_FILE_CANDIDATES = [
2773
+ ".env",
2774
+ ".env.local",
2775
+ ".dev.vars"
2776
+ ];
2777
+ const CONFIG_WATCH_DEBOUNCE_MS = 150;
2216
2778
  function flagValue(argv, flag) {
2217
2779
  const index = argv.indexOf(flag);
2218
2780
  return index >= 0 ? argv[index + 1] : void 0;
@@ -2231,7 +2793,10 @@ const KNOWN_COMMANDS = [
2231
2793
  "push-schema",
2232
2794
  "dev",
2233
2795
  "login",
2234
- "help"
2796
+ "help",
2797
+ "ping",
2798
+ "doctor",
2799
+ "connect"
2235
2800
  ];
2236
2801
  function editDistance(a, b) {
2237
2802
  const m = a.length;
@@ -2274,6 +2839,7 @@ function readDeps(cwd) {
2274
2839
  }
2275
2840
  const ADD_USAGE = "Usage: clivly add widget [--force] [--dry-run]";
2276
2841
  const defaultAddDeps = {
2842
+ fileExists: existsSync,
2277
2843
  out: (line) => process.stdout.write(line),
2278
2844
  readFile: (path) => existsSync(path) ? readFileSync(path, "utf8") : null,
2279
2845
  writeFile: (path, contents) => {
@@ -2293,14 +2859,21 @@ function runAdd(argv, cwd, deps = defaultAddDeps) {
2293
2859
  deps.out("Couldn't detect your framework from package.json. `clivly add widget` supports Next.js, TanStack Start, SvelteKit, Remix, and Nuxt.\n");
2294
2860
  return 1;
2295
2861
  }
2862
+ const layout = resolveAppLayout(cwd, deps.fileExists);
2296
2863
  const force = argv.includes("--force");
2297
2864
  const dryRun = argv.includes("--dry-run");
2298
2865
  const files = planWidgetMutations({
2299
2866
  framework,
2300
2867
  cwd,
2301
2868
  force,
2869
+ layout,
2302
2870
  readFile: (path) => deps.readFile(path)
2303
2871
  });
2872
+ const shadowing = shadowedAppDirError(cwd, framework, files.map((file) => file.path), deps.fileExists);
2873
+ if (shadowing) {
2874
+ deps.out(shadowing);
2875
+ return 1;
2876
+ }
2304
2877
  deps.out(`\nClivly add widget — ${FRAMEWORK_LABELS[framework]}\n\n`);
2305
2878
  if (dryRun) deps.out("Dry run — nothing was written.\n\n");
2306
2879
  for (const file of files) {
@@ -2321,7 +2894,7 @@ function runAdd(argv, cwd, deps = defaultAddDeps) {
2321
2894
  }
2322
2895
  if (!dryRun) {
2323
2896
  const sessionPath = files[0]?.path ?? "";
2324
- deps.out(`\nNext steps:\n 1. Add your site origins to allowedOrigins in ${sessionPath}\n 2. Create the widget + set allowed origins in the Clivly dashboard (Settings → Chat widget)\n 3. Embed it: <ClivlyChatWidget widgetId="…" />\n`);
2897
+ deps.out(`\nNext steps:\n 1. Add your site origins to allowedOrigins in ${sessionPath}\n 2. Create the widget + set allowed origins at ${WIDGET_LOCATION}\n 3. Embed it: <ClivlyChatWidget widgetId="…" />\n`);
2325
2898
  }
2326
2899
  return 0;
2327
2900
  }
@@ -2332,6 +2905,13 @@ function readOwnVersion() {
2332
2905
  if (pkg.name === "clivly" && pkg.version) return pkg.version;
2333
2906
  } catch {}
2334
2907
  }
2908
+ function readOwnEngines() {
2909
+ const req = createRequire(import.meta.url);
2910
+ for (const rel of ["./package.json", "../package.json"]) try {
2911
+ const pkg = req(rel);
2912
+ if (pkg.name === "clivly") return pkg.engines?.node;
2913
+ } catch {}
2914
+ }
2335
2915
  /**
2336
2916
  * Parse a `--db-import` / `--auth-import` value into a discovery-shaped override.
2337
2917
  *
@@ -2361,12 +2941,24 @@ function authInstanceFromFlag(flag) {
2361
2941
  function defaultReadFile(path) {
2362
2942
  return existsSync(path) ? readFileSync(path, "utf8") : null;
2363
2943
  }
2944
+ function resolveEnvPath(cwd) {
2945
+ const framework = detectFramework(readDeps(cwd));
2946
+ return join(cwd, resolveEnvFile({
2947
+ existing: ENV_FILE_CANDIDATES.filter((name) => {
2948
+ const path = join(cwd, name);
2949
+ if (!existsSync(path)) return false;
2950
+ const contents = readFileSync(path, "utf8");
2951
+ return isKeySet(contents, "CLIVLY_SECRET_KEY") || isKeySet(contents, "CLIVLY_API_KEY");
2952
+ }),
2953
+ framework
2954
+ }));
2955
+ }
2364
2956
  function defaultReadEnv(cwd) {
2365
- const path = join(cwd, ".env");
2957
+ const path = resolveEnvPath(cwd);
2366
2958
  return existsSync(path) ? readFileSync(path, "utf8") : "";
2367
2959
  }
2368
2960
  function defaultWriteEnv(cwd, contents) {
2369
- writeFileSync(join(cwd, ".env"), contents, "utf8");
2961
+ writeFileSync(resolveEnvPath(cwd), contents, "utf8");
2370
2962
  }
2371
2963
  function defaultIsInteractive() {
2372
2964
  return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY) && !process.env.CI;
@@ -2393,6 +2985,8 @@ function resolveInitDeps(options) {
2393
2985
  graph: graphFor(cwd)
2394
2986
  })),
2395
2987
  readFile: options.readFile ?? defaultReadFile,
2988
+ fileExists: options.fileExists ?? existsSync,
2989
+ resolveAppLayout: options.resolveAppLayout ?? resolveAppLayout,
2396
2990
  writeConfig: options.writeConfig ?? ((path, contents) => {
2397
2991
  mkdirSync(dirname(path), { recursive: true });
2398
2992
  writeFileSync(path, contents, "utf8");
@@ -2413,6 +3007,7 @@ function resolveInitDeps(options) {
2413
3007
  rotate: false
2414
3008
  })),
2415
3009
  framework: "unknown",
3010
+ layout: "app",
2416
3011
  authWiring: renderAuthWiring("unknown")
2417
3012
  };
2418
3013
  }
@@ -2427,11 +3022,15 @@ async function acquireSchema(options, deps) {
2427
3022
  const orm = detectOrm(appDeps);
2428
3023
  const auth = detectAuth(appDeps);
2429
3024
  deps.framework = framework;
2430
- deps.authWiring = renderAuthWiring(auth, auth === "better-auth" ? authInstanceFromFlag(options.authImportFlag) ?? await deps.probeAuth(options.cwd) : null);
3025
+ const layout = deps.resolveAppLayout(options.cwd, deps.fileExists);
3026
+ deps.layout = layout;
2431
3027
  out("\nClivly init\n");
3028
+ deps.authWiring = renderAuthWiring(auth, auth === "better-auth" ? authInstanceFromFlag(options.authImportFlag) ?? await deps.probeAuth(options.cwd) : null);
2432
3029
  out(` Framework: ${FRAMEWORK_LABELS[framework]}\n`);
2433
3030
  out(` ORM: ${orm}\n`);
2434
- out(` Auth: ${auth}${deps.authWiring.complete ? " (wired)" : ""}\n\n`);
3031
+ out(` Auth: ${auth}${deps.authWiring.complete ? " (wired)" : ""}\n`);
3032
+ if (framework === "nextjs") out(` Layout: ${layout}\n`);
3033
+ out("\n");
2435
3034
  if (orm !== "drizzle") {
2436
3035
  out(`clivly init currently generates config for Drizzle only. Detected ORM: ${orm}\n`);
2437
3036
  return { code: 1 };
@@ -2475,6 +3074,7 @@ function mutationsFor(options, deps, plan) {
2475
3074
  cwd: options.cwd,
2476
3075
  force: options.force,
2477
3076
  framework: deps.framework,
3077
+ layout: deps.layout,
2478
3078
  authWiring: deps.authWiring,
2479
3079
  plan,
2480
3080
  readFile: deps.readFile,
@@ -2534,14 +3134,15 @@ function previewMutations(deps, mutations, options) {
2534
3134
  }
2535
3135
  out(options.diff ? "\nRe-run without --diff to apply; --force to overwrite the files marked conflict.\n" : "\nRe-run without --dry-run to apply.\n");
2536
3136
  }
2537
- function printNextSteps(out, authComplete, loginRan) {
3137
+ function printNextSteps(out, authComplete, loginRan, envFile) {
2538
3138
  out("\nNext steps:\n");
2539
- out(loginRan ? " 1. Credentials written by `clivly login`\n" : " 1. Add CLIVLY_SECRET_KEY to .env — create one in the Clivly dashboard\n under Integrations → Create API Key (or Settings → API keys)\n (the legacy CLIVLY_API_KEY is still accepted)\n");
3139
+ out(loginRan ? " 1. Credentials written by `clivly login`\n" : ` 1. Add CLIVLY_SECRET_KEY to ${envFile} — create one at\n ${CREDENTIALS_LOCATION}\n (the legacy CLIVLY_API_KEY is still accepted)\n`);
2540
3140
  out(" 2. Set CLIVLY_SYNC_TRIGGER_URL to your public tick route\n");
2541
3141
  out(" Local dev? Run `clivly dev` to open a tunnel, or skip this step until you deploy (remote sync is deploy-only locally).\n");
2542
3142
  const step = authComplete ? 3 : 4;
2543
3143
  if (!authComplete) out(" 3. Finish resolveUser in the auth/verify route\n");
2544
3144
  out(` ${step}. Run \`clivly status\` to see where you are\n`);
3145
+ out("\nAfter editing clivly.config.ts, restart your app — it loads the config\nat boot, so a running server keeps using the old one.\n");
2545
3146
  }
2546
3147
  function credentialsMissing(envContents) {
2547
3148
  return !(isKeySet(envContents, "CLIVLY_SECRET_KEY") || isKeySet(envContents, "CLIVLY_API_KEY"));
@@ -2562,6 +3163,11 @@ async function scaffold(options, deps, plan) {
2562
3163
  const pm = deps.detectPackageManager(options.cwd);
2563
3164
  const packages = [resolveRuntimeInstallSpec(deps), ...deps.authWiring.packages];
2564
3165
  const mutations = mutationsFor(options, deps, plan);
3166
+ const shadowing = shadowedAppDirError(options.cwd, deps.framework, mutations.files.map((file) => file.path), deps.fileExists);
3167
+ if (shadowing) {
3168
+ out(shadowing);
3169
+ return 1;
3170
+ }
2565
3171
  if (options.dryRun || options.diff) {
2566
3172
  out(`\nWould install (${pm}): ${packages.join(", ")}\n`);
2567
3173
  previewMutations(deps, mutations, {
@@ -2586,7 +3192,7 @@ async function scaffold(options, deps, plan) {
2586
3192
  out(" Run `clivly login` from a terminal to write CLIVLY_SECRET_KEY and CLIVLY_SYNC_TRIGGER_SECRET.\n");
2587
3193
  } else loginRan = await deps.login(options.cwd) === 0;
2588
3194
  }
2589
- printNextSteps(out, deps.authWiring.complete, loginRan);
3195
+ printNextSteps(out, deps.authWiring.complete, loginRan, envFileFor(detectFramework(deps.readDepsFn(options.cwd))));
2590
3196
  return 0;
2591
3197
  }
2592
3198
  function normalizeAppRoot(options) {
@@ -2653,8 +3259,18 @@ function printNoContactGuidance(out, plan, options) {
2653
3259
  out(`\n Tables in ${plan.importPath}:\n`);
2654
3260
  for (const name of plan.tableNames) out(` ${name}\n`);
2655
3261
  const schemaArg = options.schemaFlag ? ` --schema ${options.schemaFlag}` : "";
2656
- out("\n Pick the one that holds your people and re-run:\n");
2657
- out(` clivly init${schemaArg} --contact-table ${plan.tableNames[0]}\n\n`);
3262
+ const best = plan.contactCandidates[0];
3263
+ if (best) {
3264
+ out(`\n Best guess: ${best.table} — ${best.reasons.join(", ")}\n`);
3265
+ const runnerUp = plan.contactCandidates.slice(1, 3).map((candidate) => candidate.table);
3266
+ if (runnerUp.length > 0) out(` Also possible: ${runnerUp.join(", ")}\n`);
3267
+ out("\n Confirm it and re-run:\n");
3268
+ out(` clivly init${schemaArg} --contact-table ${best.table}\n\n`);
3269
+ return;
3270
+ }
3271
+ out("\n Nothing here looks like a people table — no email, phone or name\n");
3272
+ out(" columns turned up. Pick the one that holds your people and re-run:\n");
3273
+ out(` clivly init${schemaArg} --contact-table ${plan.tableNames[0] ?? "<table>"}\n\n`);
2658
3274
  out(" Nothing was written — Clivly needs one entity before a config can load.\n\n");
2659
3275
  }
2660
3276
  function hasCloudflaredOnPath() {
@@ -2750,6 +3366,22 @@ function defaultDevDeps(options) {
2750
3366
  },
2751
3367
  out: (line) => {
2752
3368
  process.stdout.write(`${line}\n`);
3369
+ },
3370
+ watchConfig: (cwd, onChange) => {
3371
+ const target = resolveConfigPath(cwd);
3372
+ try {
3373
+ let timer = null;
3374
+ const watcher = watch(target, () => {
3375
+ if (timer) clearTimeout(timer);
3376
+ timer = setTimeout(onChange, CONFIG_WATCH_DEBOUNCE_MS);
3377
+ });
3378
+ return () => {
3379
+ if (timer) clearTimeout(timer);
3380
+ watcher.close();
3381
+ };
3382
+ } catch {
3383
+ return null;
3384
+ }
2753
3385
  }
2754
3386
  };
2755
3387
  }
@@ -2828,6 +3460,21 @@ function runInitFromArgv(argv, cwd) {
2828
3460
  }
2829
3461
  async function main(argv) {
2830
3462
  const [command, ...rest] = argv;
3463
+ if (command === "-h" || command === "--help" || isHelpRequest(rest)) {
3464
+ process.stdout.write(helpFor(command));
3465
+ return 0;
3466
+ }
3467
+ if (command === "--version" || command === "-v") {
3468
+ process.stdout.write(`${readOwnVersion() ?? "unknown"}\n`);
3469
+ return 0;
3470
+ }
3471
+ if (!process.env.CLIVLY_SKIP_ENGINE_CHECK) {
3472
+ const engineProblem = engineError(process.version, readOwnEngines());
3473
+ if (engineProblem) {
3474
+ process.stderr.write(engineProblem);
3475
+ return 1;
3476
+ }
3477
+ }
2831
3478
  let cliEnvFiles = [];
2832
3479
  try {
2833
3480
  cliEnvFiles = (await loadCliConfig(process.cwd()))?.envFiles ?? [];
@@ -2846,14 +3493,16 @@ async function main(argv) {
2846
3493
  case "status":
2847
3494
  case "ping":
2848
3495
  case "doctor":
2849
- case "connect": return await runStatus({
2850
- cwd: process.cwd(),
2851
- apiKey: readSecretKeyEnv(process.env),
2852
- apiUrl: flagValue(rest, "--api-url") ?? process.env.CLIVLY_API_URL,
2853
- configPath: flagValue(rest, "--config"),
2854
- only: ALIAS_STAGE_IDS[command],
2855
- verbose: rest.includes("--verbose")
2856
- });
3496
+ case "connect":
3497
+ if (command !== "status") process.stderr.write(`\`clivly ${command}\` is a deprecated alias that runs part of \`clivly status\`. Prefer \`clivly status\`.\n`);
3498
+ return await runStatus({
3499
+ cwd: process.cwd(),
3500
+ apiKey: readSecretKeyEnv(process.env),
3501
+ apiUrl: flagValue(rest, "--api-url") ?? process.env.CLIVLY_API_URL,
3502
+ configPath: flagValue(rest, "--config"),
3503
+ only: ALIAS_STAGE_IDS[command],
3504
+ verbose: rest.includes("--verbose")
3505
+ });
2857
3506
  case "dev": {
2858
3507
  const apiKey = readSecretKeyEnv(process.env);
2859
3508
  const apiUrl = flagValue(rest, "--api-url") ?? process.env.CLIVLY_API_URL ?? "https://api.clivly.com";
@@ -2876,14 +3525,11 @@ async function main(argv) {
2876
3525
  openBrowser: !rest.includes("--no-browser"),
2877
3526
  deps: defaultLoginDeps()
2878
3527
  });
2879
- case "--version":
2880
- case "-v":
2881
- process.stdout.write(`${readOwnVersion() ?? "unknown"}\n`);
2882
- return 0;
2883
3528
  case void 0:
3529
+ process.stdout.write(ROOT_HELP);
3530
+ return 0;
2884
3531
  case "help":
2885
- case "--help":
2886
- process.stdout.write("clivly — embed a CRM in your app\n\nUsage:\n clivly init [--force] [--dry-run] [--diff] [--yes] [--no-login] [--no-browser]\n [--schema <path|package>] [--db-import <specifier>] [--auth-import <specifier>]\n Scaffold Clivly (config, routes, .env) for your app\n clivly add widget [--force] [--dry-run] Scaffold the chat-session route\n clivly status [--verbose] [--config <path>] [--env-file <path>] Check your setup, step by step\n clivly push-schema [--config <path>] Report your schema to Clivly without deploying\n clivly dev [--port <n>] [--api-url <url>] [--route-path <path>] [-- <cmd>] Tunnel localhost for remote sync\n clivly login [--force] [--rotate] [--api-url <url>] [--no-browser] Pair this machine and write CLIVLY_SECRET_KEY / CLIVLY_SYNC_TRIGGER_SECRET to .env\n clivly --version Print the installed clivly version\n");
3532
+ process.stdout.write(helpFor(rest[0]));
2887
3533
  return 0;
2888
3534
  default: {
2889
3535
  process.stdout.write(`Unknown command: ${command}\n`);