clivly 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +330 -134
- package/core-drizzle.cjs +3 -1
- package/core-drizzle.d.cts +3 -2
- package/core-drizzle.d.mts +3 -2
- package/core-drizzle.mjs +2 -2
- package/core-entity-config.cjs +1 -1
- package/core-entity-config.d.cts +2 -2
- package/core-entity-config.d.mts +2 -2
- package/core-entity-config.mjs +1 -1
- package/core-entity-heuristics.d.cts +3 -2
- package/core-entity-heuristics.d.mts +3 -2
- package/core-mapping-form.d.cts +1 -1
- package/core-mapping-form.d.mts +1 -1
- package/core-mappings-config.cjs +1 -1
- package/core-mappings-config.d.cts +1 -1
- package/core-mappings-config.d.mts +1 -1
- package/core-mappings-config.mjs +1 -1
- package/core-sync-engine.cjs +1 -1
- package/core-sync-engine.d.cts +1 -1
- package/core-sync-engine.d.mts +1 -1
- package/core-sync-engine.mjs +1 -1
- package/core-view-compiler.cjs +1 -1
- package/core-view-compiler.d.cts +1 -1
- package/core-view-compiler.d.mts +1 -1
- package/core-view-compiler.mjs +1 -1
- package/core.cjs +21 -5
- package/core.d.cts +7 -6
- package/core.d.mts +7 -6
- package/core.mjs +7 -6
- package/drizzle-3kvhkwO7.mjs +110 -0
- package/drizzle-BhN5M9zt.d.cts +41 -0
- package/drizzle-bfjVzYR6.cjs +127 -0
- package/drizzle-kBULBAOa.d.mts +41 -0
- package/drizzle.cjs +12 -40
- package/drizzle.d.cts +3 -11
- package/drizzle.d.mts +3 -11
- package/drizzle.mjs +12 -41
- package/{entity-config-DoUCXtq5.d.cts → entity-config-C4Yf_fPj.d.cts} +120 -1
- package/{entity-config-DoUCXtq5.d.mts → entity-config-C4Yf_fPj.d.mts} +120 -1
- package/entity-config-CsLl-ftC.cjs +727 -0
- package/entity-config-D3LmzgLb.mjs +612 -0
- package/index.cjs +764 -120
- package/index.d.cts +12 -0
- package/index.d.mts +12 -0
- package/index.mjs +765 -121
- package/internal-B8ydhsKd.d.cts +73 -0
- package/internal-BARjyN4n.cjs +97 -0
- package/internal-DMySVbM0.mjs +68 -0
- package/internal-MBkNo3bw.d.mts +73 -0
- package/internal-drizzle.cjs +3 -1
- package/internal-drizzle.d.cts +2 -2
- package/internal-drizzle.d.mts +2 -2
- package/internal-drizzle.mjs +2 -2
- package/internal-entity-config.cjs +1 -1
- package/internal-entity-config.d.cts +2 -2
- package/internal-entity-config.d.mts +2 -2
- package/internal-entity-config.mjs +1 -1
- package/internal-entity-heuristics.cjs +28 -8
- package/internal-entity-heuristics.d.cts +2 -12
- package/internal-entity-heuristics.d.mts +2 -12
- package/internal-entity-heuristics.mjs +28 -8
- package/internal-mapping-form.d.cts +1 -1
- package/internal-mapping-form.d.mts +1 -1
- package/internal-mappings-config.cjs +1 -1
- package/internal-mappings-config.d.cts +1 -1
- package/internal-mappings-config.d.mts +1 -1
- package/internal-mappings-config.mjs +1 -1
- package/internal-sync-engine.cjs +1 -1
- package/internal-sync-engine.d.cts +1 -1
- package/internal-sync-engine.d.mts +1 -1
- package/internal-sync-engine.mjs +1 -1
- package/internal-view-compiler.cjs +1 -1
- package/internal-view-compiler.d.cts +1 -1
- package/internal-view-compiler.d.mts +1 -1
- package/internal-view-compiler.mjs +1 -1
- package/internal.cjs +21 -4
- package/internal.d.cts +7 -6
- package/internal.d.mts +7 -6
- package/internal.mjs +7 -5
- package/{mapping-form-D93lhHEc.d.mts → mapping-form-43bwOTat.d.cts} +1 -1
- package/{mapping-form-lfyEE7EJ.d.cts → mapping-form-DUvWUYCo.d.mts} +1 -1
- package/{mappings-config-BsRADNRv.cjs → mappings-config-BSSwdHiq.cjs} +32 -6
- package/mappings-config-BpcR8bBo.d.mts +167 -0
- package/{mappings-config-Dbgxx3Nv.mjs → mappings-config-bJQT_gSa.mjs} +32 -6
- package/mappings-config-xsLMn3I-.d.cts +167 -0
- package/namespace-probe-BSH_p9TK.d.cts +381 -0
- package/namespace-probe-Dsp5uNkS.d.mts +381 -0
- package/package.json +1 -1
- package/{entity-config-va2bkH5o.mjs → projection-C5244vGa.cjs} +130 -309
- package/{entity-config-D87rV-tE.cjs → projection-D7xmQP4S.mjs} +60 -381
- package/{sdk-iqOXeWIp.cjs → sdk-Bj-ZDf4u.cjs} +126 -28
- package/{sdk-C6WANW8M.mjs → sdk-BvRD2jAp.mjs} +126 -28
- package/sdk.cjs +2 -2
- package/sdk.d.cts +36 -1
- package/sdk.d.mts +36 -1
- package/sdk.mjs +2 -2
- package/{sync-engine-Do5ru3WF.mjs → sync-engine-Bg6-SznL.mjs} +9 -2
- package/{sync-engine-CzEbpQGv.cjs → sync-engine-DEJvKw6E.cjs} +9 -2
- package/{sync-engine-DGk_AeHJ.d.mts → sync-engine-GNqRB8eS.d.cts} +2 -2
- package/{sync-engine-B7QIbXro.d.cts → sync-engine-ZsusW-q_.d.mts} +2 -2
- package/{view-compiler-BWz0sGdm.d.cts → view-compiler-Cin0neDO.d.mts} +1 -1
- package/{view-compiler-ChWr_Lml.mjs → view-compiler-DlTpOfZi.mjs} +81 -12
- package/{view-compiler-BIwvm-o2.cjs → view-compiler-DplczSjF.cjs} +81 -12
- package/{view-compiler-Bn_EJJMO.d.mts → view-compiler-Ll9cbIJz.d.cts} +1 -1
- package/vite.d.cts +1 -1
- package/vite.d.mts +1 -1
- package/drizzle-B89tLo4T.mjs +0 -31
- package/drizzle-CFOr9dU9.d.mts +0 -20
- package/drizzle-DiReZf6d.cjs +0 -36
- package/drizzle-DmeKSqeF.d.cts +0 -20
- package/mappings-config-DsUa7-1w.d.mts +0 -41
- package/mappings-config-XmzB4dag.d.cts +0 -41
- package/types-CsL0rFYh.d.cts +0 -155
- 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-
|
|
2
|
+
import { t as CANONICAL_FIELDS } from "./entity-config-D3LmzgLb.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-
|
|
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
|
-
|
|
257
|
-
|
|
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 "
|
|
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:
|
|
1353
|
+
comment: `Org secret key — ${CREDENTIALS_LOCATION}`
|
|
1094
1354
|
}, {
|
|
1095
1355
|
key: "CLIVLY_SYNC_TRIGGER_SECRET",
|
|
1096
1356
|
value: credentials.triggerSecret,
|
|
1097
|
-
comment:
|
|
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,
|
|
@@ -1257,15 +1524,99 @@ function planInit(schema, overrides = {}) {
|
|
|
1257
1524
|
contact,
|
|
1258
1525
|
company,
|
|
1259
1526
|
tableNames: schema.tables.map((table) => table.tableName),
|
|
1527
|
+
contactCandidates: rankContactCandidates(schema.tables),
|
|
1260
1528
|
todos
|
|
1261
1529
|
};
|
|
1262
1530
|
}
|
|
1531
|
+
const CONTACT_COLUMN_EVIDENCE = [
|
|
1532
|
+
{
|
|
1533
|
+
label: "email",
|
|
1534
|
+
names: ["email", "email_address"],
|
|
1535
|
+
weight: 3
|
|
1536
|
+
},
|
|
1537
|
+
{
|
|
1538
|
+
label: "phone",
|
|
1539
|
+
names: [
|
|
1540
|
+
"phone",
|
|
1541
|
+
"phone_number",
|
|
1542
|
+
"mobile"
|
|
1543
|
+
],
|
|
1544
|
+
weight: 2
|
|
1545
|
+
},
|
|
1546
|
+
{
|
|
1547
|
+
label: "first name",
|
|
1548
|
+
names: [
|
|
1549
|
+
"first_name",
|
|
1550
|
+
"firstname",
|
|
1551
|
+
"given_name"
|
|
1552
|
+
],
|
|
1553
|
+
weight: 2
|
|
1554
|
+
},
|
|
1555
|
+
{
|
|
1556
|
+
label: "last name",
|
|
1557
|
+
names: [
|
|
1558
|
+
"last_name",
|
|
1559
|
+
"lastname",
|
|
1560
|
+
"surname",
|
|
1561
|
+
"family_name"
|
|
1562
|
+
],
|
|
1563
|
+
weight: 2
|
|
1564
|
+
},
|
|
1565
|
+
{
|
|
1566
|
+
label: "name",
|
|
1567
|
+
names: [
|
|
1568
|
+
"name",
|
|
1569
|
+
"full_name",
|
|
1570
|
+
"display_name"
|
|
1571
|
+
],
|
|
1572
|
+
weight: 1
|
|
1573
|
+
}
|
|
1574
|
+
];
|
|
1575
|
+
const NAME_MATCH_WEIGHT = 4;
|
|
1576
|
+
/**
|
|
1577
|
+
* Rank the schema's tables by how much they look like people, best first.
|
|
1578
|
+
*
|
|
1579
|
+
* Used only on the failure path, where detection found nothing mappable and the
|
|
1580
|
+
* developer has to choose. `init` previously suggested `tableNames[0]` — the
|
|
1581
|
+
* first table in schema order — so a rental app was told to map its `lease`
|
|
1582
|
+
* table as contacts. Wrong guidance is worse than none: it is confident, and
|
|
1583
|
+
* the developer has no way to see it is wrong.
|
|
1584
|
+
*
|
|
1585
|
+
* Deliberately evidence-based rather than name-based alone, so a table whose
|
|
1586
|
+
* name Clivly has never heard of still surfaces on the strength of its columns.
|
|
1587
|
+
*/
|
|
1588
|
+
function rankContactCandidates(tables) {
|
|
1589
|
+
return tables.map((table) => {
|
|
1590
|
+
const columns = new Set(table.columns.flatMap((c) => [c.dbName.toLowerCase(), c.key.toLowerCase()]));
|
|
1591
|
+
const reasons = [];
|
|
1592
|
+
let score = 0;
|
|
1593
|
+
if (matchesConcept(table.tableName, "contact")) {
|
|
1594
|
+
score += NAME_MATCH_WEIGHT;
|
|
1595
|
+
reasons.push("the name looks like a people table");
|
|
1596
|
+
}
|
|
1597
|
+
for (const evidence of CONTACT_COLUMN_EVIDENCE) {
|
|
1598
|
+
const hit = evidence.names.find((name) => columns.has(name));
|
|
1599
|
+
if (hit) {
|
|
1600
|
+
score += evidence.weight;
|
|
1601
|
+
reasons.push(hit);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
return {
|
|
1605
|
+
reasons,
|
|
1606
|
+
score,
|
|
1607
|
+
table: table.tableName
|
|
1608
|
+
};
|
|
1609
|
+
}).filter((candidate) => candidate.score > 0).sort((a, b) => b.score - a.score || a.table.localeCompare(b.table)).map(({ reasons, table }) => ({
|
|
1610
|
+
reasons,
|
|
1611
|
+
table
|
|
1612
|
+
}));
|
|
1613
|
+
}
|
|
1263
1614
|
//#endregion
|
|
1264
1615
|
//#region src/render-config.ts
|
|
1265
1616
|
const CLIVLY_ENV_KEYS = [
|
|
1266
1617
|
{
|
|
1267
1618
|
key: "CLIVLY_SECRET_KEY",
|
|
1268
|
-
comment:
|
|
1619
|
+
comment: `Org secret key — ${CREDENTIALS_LOCATION}`
|
|
1269
1620
|
},
|
|
1270
1621
|
{
|
|
1271
1622
|
key: "CLIVLY_SYNC_TRIGGER_URL",
|
|
@@ -1273,7 +1624,7 @@ const CLIVLY_ENV_KEYS = [
|
|
|
1273
1624
|
},
|
|
1274
1625
|
{
|
|
1275
1626
|
key: "CLIVLY_SYNC_TRIGGER_SECRET",
|
|
1276
|
-
comment:
|
|
1627
|
+
comment: `Signing secret — ${SYNC_SECRET_LOCATION}`
|
|
1277
1628
|
}
|
|
1278
1629
|
];
|
|
1279
1630
|
function renderFields(fields) {
|
|
@@ -1302,6 +1653,28 @@ function renderDbExpr(plan) {
|
|
|
1302
1653
|
if (!plan.db) return "db";
|
|
1303
1654
|
return dbIsInSchemaModule(plan) ? `schema.${plan.db.moduleKey}` : plan.db.moduleKey;
|
|
1304
1655
|
}
|
|
1656
|
+
const TRAILING_SCHEMA_SEGMENT = /\/schema(\/index)?$/;
|
|
1657
|
+
/**
|
|
1658
|
+
* Where the `db` instance most likely lives, given where the schema was found.
|
|
1659
|
+
*
|
|
1660
|
+
* Only reached when discovery could not confirm one. Discovery *evaluates* a
|
|
1661
|
+
* candidate module and duck-types its exports for `.select`, which is the right
|
|
1662
|
+
* check but fails for the most common real-world db module: `drizzle(process.
|
|
1663
|
+
* env.DATABASE_URL!)` throws at import time when the env var is absent, and it
|
|
1664
|
+
* is absent precisely when a developer is running `clivly init` for the first
|
|
1665
|
+
* time. So the probe misses, through no fault of the project.
|
|
1666
|
+
*
|
|
1667
|
+
* The old fallback emitted `<YOUR_DB_MODULE>` — not a module specifier, not
|
|
1668
|
+
* valid TypeScript, and invisible until the config failed to load at runtime.
|
|
1669
|
+
* A best-guess relative path is strictly better: right for the conventional
|
|
1670
|
+
* layout (`lib/db/schema.ts` beside `lib/db/index.ts`), and when it is wrong it
|
|
1671
|
+
* is a module-not-found error at build time, naming a real path the developer
|
|
1672
|
+
* can correct.
|
|
1673
|
+
*/
|
|
1674
|
+
function guessDbImportPath(schemaImportPath) {
|
|
1675
|
+
const trimmed = schemaImportPath.replace(TRAILING_SCHEMA_SEGMENT, "");
|
|
1676
|
+
return trimmed === schemaImportPath || trimmed === "." || trimmed === "" ? "./db" : trimmed;
|
|
1677
|
+
}
|
|
1305
1678
|
function renderImports(plan) {
|
|
1306
1679
|
const lines = [
|
|
1307
1680
|
"import { createClivlySDK, defineClivlyConfig } from \"clivly/sdk\";",
|
|
@@ -1309,8 +1682,10 @@ function renderImports(plan) {
|
|
|
1309
1682
|
"import { discoverFromDrizzle } from \"clivly/drizzle\";",
|
|
1310
1683
|
`import * as schema from "${plan.importPath}";`
|
|
1311
1684
|
];
|
|
1312
|
-
if (
|
|
1313
|
-
|
|
1685
|
+
if (plan.db === null) {
|
|
1686
|
+
const guess = guessDbImportPath(plan.importPath);
|
|
1687
|
+
lines.push(`import { db } from "${guess}"; // TODO(clivly): verify this path points at your Drizzle \`db\` instance — NOT your schema file`);
|
|
1688
|
+
} else if (!dbIsInSchemaModule(plan)) lines.push(`import { ${plan.db.moduleKey} } from "${plan.db.importPath}";`);
|
|
1314
1689
|
return lines.join("\n");
|
|
1315
1690
|
}
|
|
1316
1691
|
function renderTodoHeader(todos) {
|
|
@@ -1428,6 +1803,83 @@ function renderAuthWiring(auth, authInstance = null) {
|
|
|
1428
1803
|
}
|
|
1429
1804
|
}
|
|
1430
1805
|
//#endregion
|
|
1806
|
+
//#region src/tsconfig-alias.ts
|
|
1807
|
+
const LEADING_DOT_SLASH$1 = /^\.\//;
|
|
1808
|
+
const TRAILING_WILDCARD = /\/?\*$/;
|
|
1809
|
+
const TRAILING_SLASH = /\/$/;
|
|
1810
|
+
const BLOCK_COMMENT = /\/\*[\s\S]*?\*\//g;
|
|
1811
|
+
const LINE_COMMENT = /(^|[^:])\/\/.*$/gm;
|
|
1812
|
+
const TRAILING_COMMA = /,(\s*[}\]])/g;
|
|
1813
|
+
/**
|
|
1814
|
+
* `compilerOptions` from a tsconfig's text, or null when it cannot be read.
|
|
1815
|
+
*
|
|
1816
|
+
* tsconfig is JSONC — comments and trailing commas are legal and common, and
|
|
1817
|
+
* `JSON.parse` rejects both. Stripping them is enough here: this only ever reads
|
|
1818
|
+
* two string-valued fields, so the cost of being wrong is falling back to a
|
|
1819
|
+
* relative import, which already works.
|
|
1820
|
+
*/
|
|
1821
|
+
function parseTsconfig(source) {
|
|
1822
|
+
try {
|
|
1823
|
+
const stripped = source.replace(BLOCK_COMMENT, "").replace(LINE_COMMENT, "$1").replace(TRAILING_COMMA, "$1");
|
|
1824
|
+
return JSON.parse(stripped).compilerOptions ?? null;
|
|
1825
|
+
} catch {
|
|
1826
|
+
return null;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* Strip a leading "./" and any trailing wildcard so two paths can be compared.
|
|
1831
|
+
*
|
|
1832
|
+
* `"./*"` — an alias rooted at the app itself — must reduce to the empty string,
|
|
1833
|
+
* not to a stray `"*"`; that is the mapping which makes a root-level
|
|
1834
|
+
* clivly.config alias-reachable.
|
|
1835
|
+
*/
|
|
1836
|
+
function normalize(value) {
|
|
1837
|
+
return value.replace(LEADING_DOT_SLASH$1, "").replace(TRAILING_WILDCARD, "").replace(TRAILING_SLASH, "");
|
|
1838
|
+
}
|
|
1839
|
+
/**
|
|
1840
|
+
* The alias specifier that reaches `target`, or null when none does.
|
|
1841
|
+
*
|
|
1842
|
+
* `target` is app-root-relative and extensionless, e.g. `lib/auth` or
|
|
1843
|
+
* `clivly.config`. Only wildcard patterns are considered — an exact mapping
|
|
1844
|
+
* names one module and is not a prefix anything else can be rebased onto.
|
|
1845
|
+
*
|
|
1846
|
+
* Longest prefix wins, so `"@components/*": ["./src/components/*"]` beats
|
|
1847
|
+
* `"@/*": ["./src/*"]` for a module under `src/components`, matching how
|
|
1848
|
+
* TypeScript itself resolves.
|
|
1849
|
+
*/
|
|
1850
|
+
function aliasFor(input) {
|
|
1851
|
+
if (!input.paths) return null;
|
|
1852
|
+
const target = normalize(input.target);
|
|
1853
|
+
let best = null;
|
|
1854
|
+
for (const [pattern, targets] of Object.entries(input.paths)) {
|
|
1855
|
+
if (!pattern.endsWith("/*")) continue;
|
|
1856
|
+
const prefix = pattern.slice(0, -2);
|
|
1857
|
+
for (const mapped of targets) {
|
|
1858
|
+
const base = baseReaching(mapped, target);
|
|
1859
|
+
if (base === null) continue;
|
|
1860
|
+
const rest = base === "" ? target : target.slice(base.length + 1);
|
|
1861
|
+
if (!best || base.length > best.length) best = {
|
|
1862
|
+
alias: `${prefix}/${rest}`,
|
|
1863
|
+
length: base.length
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
return best?.alias ?? null;
|
|
1868
|
+
}
|
|
1869
|
+
/**
|
|
1870
|
+
* The normalized base of `mapped` when it contains `target`, else null.
|
|
1871
|
+
*
|
|
1872
|
+
* A prefix must end at a segment boundary — `src` must not match `srcinternal`,
|
|
1873
|
+
* a different directory — and the target must be strictly inside the base, since
|
|
1874
|
+
* a wildcard needs a remainder to substitute.
|
|
1875
|
+
*/
|
|
1876
|
+
function baseReaching(mapped, target) {
|
|
1877
|
+
if (!mapped.endsWith("/*")) return null;
|
|
1878
|
+
const base = normalize(mapped);
|
|
1879
|
+
if (base === "") return target === "" ? null : "";
|
|
1880
|
+
return target.startsWith(`${base}/`) ? base : null;
|
|
1881
|
+
}
|
|
1882
|
+
//#endregion
|
|
1431
1883
|
//#region src/scaffold.ts
|
|
1432
1884
|
const TANSTACK_ROUTE_PREFIX = /^src\/routes\//;
|
|
1433
1885
|
const TS_EXTENSION = /\.ts$/;
|
|
@@ -1459,10 +1911,14 @@ const ROUTE_PATHS = {
|
|
|
1459
1911
|
},
|
|
1460
1912
|
unknown: null
|
|
1461
1913
|
};
|
|
1462
|
-
function
|
|
1914
|
+
function applyLayout(framework, path, layout) {
|
|
1915
|
+
if (framework !== "nextjs" || layout === "app") return path;
|
|
1916
|
+
return `src/${path}`;
|
|
1917
|
+
}
|
|
1918
|
+
function routePath(framework, kind, layout = "app") {
|
|
1463
1919
|
const paths = ROUTE_PATHS[framework];
|
|
1464
1920
|
if (!paths) throw new Error(`No route path for framework: ${framework}`);
|
|
1465
|
-
return paths[kind];
|
|
1921
|
+
return applyLayout(framework, paths[kind], layout);
|
|
1466
1922
|
}
|
|
1467
1923
|
const WINDOWS_SEP = /\\/g;
|
|
1468
1924
|
const LEADING_DOT_SLASH = /^\.\//;
|
|
@@ -1470,22 +1926,27 @@ function configImport(routeFilePath) {
|
|
|
1470
1926
|
const rel = relative(dirname(routeFilePath), "clivly.config").replace(WINDOWS_SEP, "/");
|
|
1471
1927
|
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
1472
1928
|
}
|
|
1473
|
-
function hostImportPath(routeFilePath, appRelative) {
|
|
1929
|
+
function hostImportPath(routeFilePath, appRelative, paths) {
|
|
1474
1930
|
const fromRoot = appRelative.replace(LEADING_DOT_SLASH, "");
|
|
1931
|
+
const alias = aliasFor({
|
|
1932
|
+
paths,
|
|
1933
|
+
target: fromRoot
|
|
1934
|
+
});
|
|
1935
|
+
if (alias) return alias;
|
|
1475
1936
|
const rel = relative(dirname(routeFilePath), fromRoot).replace(WINDOWS_SEP, "/");
|
|
1476
1937
|
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
1477
1938
|
}
|
|
1478
|
-
function renderAuthPreamble(routeFilePath, auth) {
|
|
1939
|
+
function renderAuthPreamble(routeFilePath, auth, paths) {
|
|
1479
1940
|
const lines = [...auth.imports];
|
|
1480
1941
|
if (auth.hostImport) {
|
|
1481
1942
|
if (auth.hostImport.todo) lines.push(`// ${auth.hostImport.todo}`);
|
|
1482
|
-
const path = hostImportPath(routeFilePath, auth.hostImport.path);
|
|
1943
|
+
const path = hostImportPath(routeFilePath, auth.hostImport.path, paths);
|
|
1483
1944
|
lines.push(`import { ${auth.hostImport.binding} } from "${path}";`);
|
|
1484
1945
|
}
|
|
1485
1946
|
lines.push("", auth.body);
|
|
1486
1947
|
return lines.join("\n");
|
|
1487
1948
|
}
|
|
1488
|
-
function renderPreamble(kind, importPath, auth, routeFilePath) {
|
|
1949
|
+
function renderPreamble(kind, importPath, auth, paths, routeFilePath) {
|
|
1489
1950
|
if (kind === "session") return `import { createChatSessionHandler } from "clivly/sdk";
|
|
1490
1951
|
|
|
1491
1952
|
const handler = createChatSessionHandler({
|
|
@@ -1496,7 +1957,7 @@ const handler = createChatSessionHandler({
|
|
|
1496
1957
|
});
|
|
1497
1958
|
`;
|
|
1498
1959
|
if (kind === "verify") return `import clivly from "${importPath}";
|
|
1499
|
-
${renderAuthPreamble(routeFilePath, auth)}
|
|
1960
|
+
${renderAuthPreamble(routeFilePath, auth, paths)}
|
|
1500
1961
|
const handler = clivly.createAuthVerifyHandler(resolveUser);
|
|
1501
1962
|
`;
|
|
1502
1963
|
return `import clivly from "${importPath}";
|
|
@@ -1504,8 +1965,8 @@ const handler = clivly.createAuthVerifyHandler(resolveUser);
|
|
|
1504
1965
|
const handler = clivly.createClivlyHandler();
|
|
1505
1966
|
`;
|
|
1506
1967
|
}
|
|
1507
|
-
function renderBody(framework, importPath, kind, auth, routeFilePath) {
|
|
1508
|
-
const preamble = renderPreamble(kind, importPath, auth, routeFilePath);
|
|
1968
|
+
function renderBody(framework, importPath, kind, auth, routeFilePath, paths) {
|
|
1969
|
+
const preamble = renderPreamble(kind, importPath, auth, paths, routeFilePath);
|
|
1509
1970
|
switch (framework) {
|
|
1510
1971
|
case "nextjs": return `${preamble}
|
|
1511
1972
|
export const POST = (request: Request) => handler(request);
|
|
@@ -1535,9 +1996,9 @@ export default defineEventHandler((event) => handler(toWebRequest(event)));
|
|
|
1535
1996
|
default: throw new Error(`No route template for framework: ${framework}`);
|
|
1536
1997
|
}
|
|
1537
1998
|
}
|
|
1538
|
-
function renderRouteFile(framework, kind, auth = renderAuthWiring("unknown")) {
|
|
1539
|
-
const path = routePath(framework, kind);
|
|
1540
|
-
let body = renderBody(framework, configImport(path), kind, auth, path);
|
|
1999
|
+
function renderRouteFile(framework, kind, auth = renderAuthWiring("unknown"), layout = "app", paths) {
|
|
2000
|
+
const path = routePath(framework, kind, layout);
|
|
2001
|
+
let body = renderBody(framework, configImport(path), kind, auth, path, paths);
|
|
1541
2002
|
if (framework === "tanstack-start") {
|
|
1542
2003
|
const url = `/${path.replace(TANSTACK_ROUTE_PREFIX, "").replace(TS_EXTENSION, "")}`;
|
|
1543
2004
|
body = body.replace("__ROUTE__", url);
|
|
@@ -1549,28 +2010,28 @@ const COMMON_FILES = [{
|
|
|
1549
2010
|
description: "Your Clivly configuration",
|
|
1550
2011
|
owned: false
|
|
1551
2012
|
}];
|
|
1552
|
-
function planScaffold(framework) {
|
|
2013
|
+
function planScaffold(framework, layout = "app") {
|
|
1553
2014
|
const paths = ROUTE_PATHS[framework];
|
|
1554
2015
|
if (!paths) return [];
|
|
1555
2016
|
return [
|
|
1556
2017
|
{
|
|
1557
|
-
path: paths.tick,
|
|
2018
|
+
path: applyLayout(framework, paths.tick, layout),
|
|
1558
2019
|
description: "Clivly sync-trigger route (createClivlyHandler)",
|
|
1559
2020
|
owned: true
|
|
1560
2021
|
},
|
|
1561
2022
|
{
|
|
1562
|
-
path: paths.verify,
|
|
2023
|
+
path: applyLayout(framework, paths.verify, layout),
|
|
1563
2024
|
description: "Clivly auth-verify route (createAuthVerifyHandler) — finish resolveUser",
|
|
1564
2025
|
owned: true
|
|
1565
2026
|
},
|
|
1566
2027
|
...COMMON_FILES
|
|
1567
2028
|
];
|
|
1568
2029
|
}
|
|
1569
|
-
function planWidgetScaffold(framework) {
|
|
2030
|
+
function planWidgetScaffold(framework, layout = "app") {
|
|
1570
2031
|
const paths = ROUTE_PATHS[framework];
|
|
1571
2032
|
if (!paths) return [];
|
|
1572
2033
|
return [{
|
|
1573
|
-
path: paths.session,
|
|
2034
|
+
path: applyLayout(framework, paths.session, layout),
|
|
1574
2035
|
description: "Clivly chat-session route (createChatSessionHandler) — add allowedOrigins",
|
|
1575
2036
|
owned: true
|
|
1576
2037
|
}];
|
|
@@ -1590,9 +2051,9 @@ function planFiles(args) {
|
|
|
1590
2051
|
const render = (path) => {
|
|
1591
2052
|
if (path === "clivly.config.ts") return renderInitConfig(args.plan);
|
|
1592
2053
|
const kind = path.includes("verify") ? "verify" : "tick";
|
|
1593
|
-
return renderRouteFile(args.framework, kind, args.authWiring);
|
|
2054
|
+
return renderRouteFile(args.framework, kind, args.authWiring, args.layout, parseTsconfig(args.readFile(join(args.cwd, "tsconfig.json")) ?? "")?.paths);
|
|
1594
2055
|
};
|
|
1595
|
-
for (const file of planScaffold(args.framework)) {
|
|
2056
|
+
for (const file of planScaffold(args.framework, args.layout)) {
|
|
1596
2057
|
const contents = render(file.path);
|
|
1597
2058
|
mutations.push({
|
|
1598
2059
|
path: file.path,
|
|
@@ -1623,8 +2084,8 @@ function planMutations(args) {
|
|
|
1623
2084
|
};
|
|
1624
2085
|
}
|
|
1625
2086
|
function planWidgetMutations(args) {
|
|
1626
|
-
return planWidgetScaffold(args.framework).map((file) => {
|
|
1627
|
-
const contents = renderRouteFile(args.framework, "session");
|
|
2087
|
+
return planWidgetScaffold(args.framework, args.layout).map((file) => {
|
|
2088
|
+
const contents = renderRouteFile(args.framework, "session", void 0, args.layout);
|
|
1628
2089
|
return {
|
|
1629
2090
|
path: file.path,
|
|
1630
2091
|
contents,
|
|
@@ -1949,7 +2410,17 @@ const ALLOWED_CALLS = /* @__PURE__ */ new Set([
|
|
|
1949
2410
|
"while"
|
|
1950
2411
|
]);
|
|
1951
2412
|
const SIDE_EFFECT_CALLS = /^(dotenv\.config|config|configDotenv|fileURLToPath|readFileSync|require|process\.chdir)$/;
|
|
2413
|
+
const NATIVE_PACKAGES = /* @__PURE__ */ new Set([
|
|
2414
|
+
"bcrypt",
|
|
2415
|
+
"better-sqlite3",
|
|
2416
|
+
"canvas",
|
|
2417
|
+
"node-gyp",
|
|
2418
|
+
"pg-native",
|
|
2419
|
+
"sharp",
|
|
2420
|
+
"sqlite3"
|
|
2421
|
+
]);
|
|
1952
2422
|
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.";
|
|
2423
|
+
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
2424
|
function isNodeBuiltin(specifier) {
|
|
1954
2425
|
if (specifier.startsWith("node:")) return true;
|
|
1955
2426
|
return NODE_BUILTINS.has(specifier.split("/")[0] ?? "");
|
|
@@ -1966,6 +2437,12 @@ function specifierIssue(specifier, line) {
|
|
|
1966
2437
|
hint: ENV_HINT,
|
|
1967
2438
|
line
|
|
1968
2439
|
};
|
|
2440
|
+
if (NATIVE_PACKAGES.has(specifier.split("/")[0] ?? "")) return {
|
|
2441
|
+
code: "native_module_in_edge_config",
|
|
2442
|
+
detail: `imports "${specifier}", which ships a compiled binary`,
|
|
2443
|
+
hint: NATIVE_HINT,
|
|
2444
|
+
line
|
|
2445
|
+
};
|
|
1969
2446
|
if (isNodeBuiltin(specifier)) return {
|
|
1970
2447
|
code: "node_builtin_in_edge_config",
|
|
1971
2448
|
detail: `imports "${specifier}", which does not exist on an edge runtime`,
|
|
@@ -2005,8 +2482,56 @@ function analyzeEdgeSafety(source) {
|
|
|
2005
2482
|
});
|
|
2006
2483
|
return issues;
|
|
2007
2484
|
}
|
|
2485
|
+
/** Every relative specifier `source` imports, in first-seen order, deduped. */
|
|
2486
|
+
function relativeImports(source) {
|
|
2487
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2488
|
+
for (const rawLine of source.split(NEWLINE)) {
|
|
2489
|
+
const line = stripComment(rawLine);
|
|
2490
|
+
const specifier = BARE_IMPORT.exec(line)?.[1] ?? IMPORT_FROM.exec(line)?.[1] ?? REQUIRE_CALL.exec(line)?.[1];
|
|
2491
|
+
if (specifier?.startsWith(".")) seen.add(specifier);
|
|
2492
|
+
}
|
|
2493
|
+
return [...seen];
|
|
2494
|
+
}
|
|
2495
|
+
/**
|
|
2496
|
+
* Edge-safety for the config *and* the modules it imports directly.
|
|
2497
|
+
*
|
|
2498
|
+
* The dominant real-world shape is a clean-looking config with
|
|
2499
|
+
* `import { db } from "./lib/db"`, where the problem is one hop away and
|
|
2500
|
+
* invisible to a scan that reads only the config file.
|
|
2501
|
+
*
|
|
2502
|
+
* One hop, not more: each additional level multiplies the files read for a
|
|
2503
|
+
* shrinking return, and the config's own imports are where a host app puts its
|
|
2504
|
+
* database. Bare specifiers are never followed — resolving into node_modules is
|
|
2505
|
+
* a different job with different failure modes, and the denylist already covers
|
|
2506
|
+
* the packages that matter.
|
|
2507
|
+
*
|
|
2508
|
+
* `readModule` returns source text, or null when the module cannot be read;
|
|
2509
|
+
* unreadable is not a finding, since a scan is not a build.
|
|
2510
|
+
*/
|
|
2511
|
+
function analyzeConfigTree(input) {
|
|
2512
|
+
const issues = analyzeEdgeSafety(input.source);
|
|
2513
|
+
for (const specifier of relativeImports(input.source)) {
|
|
2514
|
+
const source = input.readModule(specifier);
|
|
2515
|
+
if (source === null) continue;
|
|
2516
|
+
for (const issue of analyzeEdgeSafety(source)) issues.push({
|
|
2517
|
+
...issue,
|
|
2518
|
+
module: specifier
|
|
2519
|
+
});
|
|
2520
|
+
}
|
|
2521
|
+
return issues;
|
|
2522
|
+
}
|
|
2008
2523
|
//#endregion
|
|
2009
2524
|
//#region src/status-stages.ts
|
|
2525
|
+
const MODULE_CANDIDATES = [
|
|
2526
|
+
".ts",
|
|
2527
|
+
".tsx",
|
|
2528
|
+
".js",
|
|
2529
|
+
".mjs",
|
|
2530
|
+
".cjs",
|
|
2531
|
+
"/index.ts",
|
|
2532
|
+
"/index.js",
|
|
2533
|
+
""
|
|
2534
|
+
];
|
|
2010
2535
|
function defaultReadConfigSource(path) {
|
|
2011
2536
|
try {
|
|
2012
2537
|
return readFileSync(path, "utf8");
|
|
@@ -2021,10 +2546,11 @@ const REASON_HINT = {
|
|
|
2021
2546
|
rate_limited: "Rate limited (429). Wait a moment and retry.",
|
|
2022
2547
|
server_error: "Clivly returned a server error (5xx). Retry shortly.",
|
|
2023
2548
|
client_error: "The request was rejected (4xx). Check your configuration.",
|
|
2549
|
+
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
2550
|
network_error: "Could not reach Clivly. Check your network or --api-url."
|
|
2025
2551
|
};
|
|
2026
2552
|
const apiKeyStage = {
|
|
2027
|
-
id: "
|
|
2553
|
+
id: "credential",
|
|
2028
2554
|
label: "API key present",
|
|
2029
2555
|
run(ctx) {
|
|
2030
2556
|
if (ctx.apiKey) return Promise.resolve({
|
|
@@ -2034,20 +2560,29 @@ const apiKeyStage = {
|
|
|
2034
2560
|
return Promise.resolve({
|
|
2035
2561
|
state: "fail",
|
|
2036
2562
|
detail: "Secret key is not set.",
|
|
2037
|
-
hint:
|
|
2563
|
+
hint: `Copy your secret key from ${CREDENTIALS_LOCATION} into CLIVLY_SECRET_KEY in .env.`
|
|
2038
2564
|
});
|
|
2039
2565
|
}
|
|
2040
2566
|
};
|
|
2041
|
-
const
|
|
2042
|
-
id: "
|
|
2567
|
+
const reachabilityStage = {
|
|
2568
|
+
id: "application",
|
|
2043
2569
|
label: "Clivly reachable",
|
|
2044
2570
|
needs: [apiKeyStage.id],
|
|
2045
2571
|
async run(ctx) {
|
|
2046
2572
|
const result = await ctx.connect(ctx.apiKey, ctx.apiUrl);
|
|
2047
|
-
if (result.ok)
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2573
|
+
if (result.ok) {
|
|
2574
|
+
const name = result.org?.name ?? "(unnamed org)";
|
|
2575
|
+
const id = result.org?.id ? ` (${result.org.id})` : "";
|
|
2576
|
+
if (result.appLastSeenAt === null) return {
|
|
2577
|
+
state: "warn",
|
|
2578
|
+
detail: `Reached org: ${name}${id} — your app has not connected yet`,
|
|
2579
|
+
hint: "Start your app with the Clivly SDK mounted; it will appear in the dashboard within a minute."
|
|
2580
|
+
};
|
|
2581
|
+
return {
|
|
2582
|
+
state: "pass",
|
|
2583
|
+
detail: `Reached org: ${name}${id}`
|
|
2584
|
+
};
|
|
2585
|
+
}
|
|
2051
2586
|
const status = result.status === null ? "no response" : `HTTP ${result.status}`;
|
|
2052
2587
|
return {
|
|
2053
2588
|
state: "fail",
|
|
@@ -2075,22 +2610,76 @@ const configStage = {
|
|
|
2075
2610
|
}
|
|
2076
2611
|
}
|
|
2077
2612
|
};
|
|
2078
|
-
const LADDER_OWNED_CHECKS = /* @__PURE__ */ new Set(["API key present", "
|
|
2613
|
+
const LADDER_OWNED_CHECKS = /* @__PURE__ */ new Set(["API key present", "Clivly reachable"]);
|
|
2614
|
+
const sdkChecksStage = {
|
|
2615
|
+
id: "setup-checks",
|
|
2616
|
+
label: "Setup checks",
|
|
2617
|
+
needs: [configStage.id],
|
|
2618
|
+
async run(ctx) {
|
|
2619
|
+
const owned = (await ctx.sdk.doctor()).checks.filter((check) => !LADDER_OWNED_CHECKS.has(check.name));
|
|
2620
|
+
const counted = owned.filter((check) => !check.skipped);
|
|
2621
|
+
const passed = counted.filter((check) => check.ok).length;
|
|
2622
|
+
const ok = counted.every((check) => check.ok);
|
|
2623
|
+
const warned = counted.filter((check) => check.warn).length;
|
|
2624
|
+
const summary = warned > 0 ? `${passed} of ${counted.length} passed, ${warned} with warnings` : `${passed} of ${counted.length} passed`;
|
|
2625
|
+
return {
|
|
2626
|
+
state: ok ? "pass" : "fail",
|
|
2627
|
+
detail: summary,
|
|
2628
|
+
sub: owned.map((check) => ({
|
|
2629
|
+
label: check.name,
|
|
2630
|
+
ok: check.ok,
|
|
2631
|
+
skipped: check.skipped,
|
|
2632
|
+
warn: check.warn,
|
|
2633
|
+
detail: check.detail
|
|
2634
|
+
}))
|
|
2635
|
+
};
|
|
2636
|
+
}
|
|
2637
|
+
};
|
|
2638
|
+
const triggerUrlStage = {
|
|
2639
|
+
id: "trigger-url",
|
|
2640
|
+
label: "Sync trigger URL",
|
|
2641
|
+
needs: [configStage.id],
|
|
2642
|
+
run(ctx) {
|
|
2643
|
+
const url = ctx.sdk?.syncTrigger?.url;
|
|
2644
|
+
if (url) return Promise.resolve({
|
|
2645
|
+
state: "pass",
|
|
2646
|
+
detail: url
|
|
2647
|
+
});
|
|
2648
|
+
return Promise.resolve({
|
|
2649
|
+
state: "warn",
|
|
2650
|
+
detail: "Not set — remote sync cannot call back into this app.",
|
|
2651
|
+
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."
|
|
2652
|
+
});
|
|
2653
|
+
}
|
|
2654
|
+
};
|
|
2079
2655
|
/** The full ladder, in the order a developer completes setup. */
|
|
2080
2656
|
const STATUS_STAGES = [
|
|
2081
2657
|
apiKeyStage,
|
|
2082
|
-
|
|
2658
|
+
reachabilityStage,
|
|
2083
2659
|
configStage,
|
|
2084
2660
|
{
|
|
2085
2661
|
id: "runtime",
|
|
2086
2662
|
label: "clivly.config is edge-safe",
|
|
2663
|
+
needs: [configStage.id],
|
|
2087
2664
|
run(ctx) {
|
|
2088
2665
|
const source = (ctx.readConfigSource ?? defaultReadConfigSource)(ctx.configPath);
|
|
2089
2666
|
if (source === null) return Promise.resolve({
|
|
2090
2667
|
state: "skip",
|
|
2091
2668
|
detail: "config not readable"
|
|
2092
2669
|
});
|
|
2093
|
-
const
|
|
2670
|
+
const read = ctx.readConfigSource ?? defaultReadConfigSource;
|
|
2671
|
+
const base = dirname(ctx.configPath);
|
|
2672
|
+
const issues = analyzeConfigTree({
|
|
2673
|
+
source,
|
|
2674
|
+
readModule: (specifier) => {
|
|
2675
|
+
const target = resolve(base, specifier);
|
|
2676
|
+
for (const candidate of MODULE_CANDIDATES) {
|
|
2677
|
+
const found = read(`${target}${candidate}`);
|
|
2678
|
+
if (found !== null) return found;
|
|
2679
|
+
}
|
|
2680
|
+
return null;
|
|
2681
|
+
}
|
|
2682
|
+
});
|
|
2094
2683
|
if (issues.length === 0) return Promise.resolve({
|
|
2095
2684
|
state: "pass",
|
|
2096
2685
|
detail: "no Node-only code at module scope"
|
|
@@ -2107,43 +2696,8 @@ const STATUS_STAGES = [
|
|
|
2107
2696
|
});
|
|
2108
2697
|
}
|
|
2109
2698
|
},
|
|
2110
|
-
|
|
2111
|
-
|
|
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
|
-
}
|
|
2699
|
+
sdkChecksStage,
|
|
2700
|
+
triggerUrlStage
|
|
2147
2701
|
];
|
|
2148
2702
|
function defaultLoadSdk(path) {
|
|
2149
2703
|
return loadConfigSdk(path, "doctor");
|
|
@@ -2153,15 +2707,15 @@ function defaultLoadSdk(path) {
|
|
|
2153
2707
|
/** Stage ids each legacy command maps onto. */
|
|
2154
2708
|
const ALIAS_STAGE_IDS = {
|
|
2155
2709
|
status: STATUS_STAGES.map((stage) => stage.id),
|
|
2156
|
-
ping: ["
|
|
2710
|
+
ping: ["credential", "application"],
|
|
2157
2711
|
doctor: [
|
|
2158
2712
|
"config",
|
|
2159
2713
|
"runtime",
|
|
2160
2714
|
"setup-checks"
|
|
2161
2715
|
],
|
|
2162
2716
|
connect: [
|
|
2163
|
-
"
|
|
2164
|
-
"
|
|
2717
|
+
"credential",
|
|
2718
|
+
"application",
|
|
2165
2719
|
"config",
|
|
2166
2720
|
"trigger-url"
|
|
2167
2721
|
]
|
|
@@ -2177,9 +2731,9 @@ const defaultConnect = (apiKey, apiUrl) => createClivlySDK({
|
|
|
2177
2731
|
apiKey,
|
|
2178
2732
|
apiUrl,
|
|
2179
2733
|
retry: { maxAttempts: 1 }
|
|
2180
|
-
}).
|
|
2734
|
+
}).ping();
|
|
2181
2735
|
function subMark(sub) {
|
|
2182
|
-
if (sub.skipped) return "⚠️ ";
|
|
2736
|
+
if (sub.skipped || sub.warn) return "⚠️ ";
|
|
2183
2737
|
return sub.ok ? "✅" : "❌";
|
|
2184
2738
|
}
|
|
2185
2739
|
function renderStage(result, verbose, out) {
|
|
@@ -2187,7 +2741,7 @@ function renderStage(result, verbose, out) {
|
|
|
2187
2741
|
const detail = result.detail ? ` — ${result.detail}` : "";
|
|
2188
2742
|
out(` ${MARK[result.state]} ${step} · ${result.label}${detail}\n`);
|
|
2189
2743
|
for (const sub of result.sub ?? []) {
|
|
2190
|
-
if (!(verbose || sub.skipped || !sub.ok)) continue;
|
|
2744
|
+
if (!(verbose || sub.skipped || sub.warn || !sub.ok)) continue;
|
|
2191
2745
|
const subDetail = sub.detail ? ` — ${sub.detail}` : "";
|
|
2192
2746
|
out(` ${subMark(sub)} ${sub.label}${subDetail}\n`);
|
|
2193
2747
|
}
|
|
@@ -2213,6 +2767,12 @@ async function runStatus(options) {
|
|
|
2213
2767
|
}
|
|
2214
2768
|
//#endregion
|
|
2215
2769
|
//#region src/index.ts
|
|
2770
|
+
const ENV_FILE_CANDIDATES = [
|
|
2771
|
+
".env",
|
|
2772
|
+
".env.local",
|
|
2773
|
+
".dev.vars"
|
|
2774
|
+
];
|
|
2775
|
+
const CONFIG_WATCH_DEBOUNCE_MS = 150;
|
|
2216
2776
|
function flagValue(argv, flag) {
|
|
2217
2777
|
const index = argv.indexOf(flag);
|
|
2218
2778
|
return index >= 0 ? argv[index + 1] : void 0;
|
|
@@ -2231,7 +2791,10 @@ const KNOWN_COMMANDS = [
|
|
|
2231
2791
|
"push-schema",
|
|
2232
2792
|
"dev",
|
|
2233
2793
|
"login",
|
|
2234
|
-
"help"
|
|
2794
|
+
"help",
|
|
2795
|
+
"ping",
|
|
2796
|
+
"doctor",
|
|
2797
|
+
"connect"
|
|
2235
2798
|
];
|
|
2236
2799
|
function editDistance(a, b) {
|
|
2237
2800
|
const m = a.length;
|
|
@@ -2274,6 +2837,7 @@ function readDeps(cwd) {
|
|
|
2274
2837
|
}
|
|
2275
2838
|
const ADD_USAGE = "Usage: clivly add widget [--force] [--dry-run]";
|
|
2276
2839
|
const defaultAddDeps = {
|
|
2840
|
+
fileExists: existsSync,
|
|
2277
2841
|
out: (line) => process.stdout.write(line),
|
|
2278
2842
|
readFile: (path) => existsSync(path) ? readFileSync(path, "utf8") : null,
|
|
2279
2843
|
writeFile: (path, contents) => {
|
|
@@ -2293,14 +2857,21 @@ function runAdd(argv, cwd, deps = defaultAddDeps) {
|
|
|
2293
2857
|
deps.out("Couldn't detect your framework from package.json. `clivly add widget` supports Next.js, TanStack Start, SvelteKit, Remix, and Nuxt.\n");
|
|
2294
2858
|
return 1;
|
|
2295
2859
|
}
|
|
2860
|
+
const layout = resolveAppLayout(cwd, deps.fileExists);
|
|
2296
2861
|
const force = argv.includes("--force");
|
|
2297
2862
|
const dryRun = argv.includes("--dry-run");
|
|
2298
2863
|
const files = planWidgetMutations({
|
|
2299
2864
|
framework,
|
|
2300
2865
|
cwd,
|
|
2301
2866
|
force,
|
|
2867
|
+
layout,
|
|
2302
2868
|
readFile: (path) => deps.readFile(path)
|
|
2303
2869
|
});
|
|
2870
|
+
const shadowing = shadowedAppDirError(cwd, framework, files.map((file) => file.path), deps.fileExists);
|
|
2871
|
+
if (shadowing) {
|
|
2872
|
+
deps.out(shadowing);
|
|
2873
|
+
return 1;
|
|
2874
|
+
}
|
|
2304
2875
|
deps.out(`\nClivly add widget — ${FRAMEWORK_LABELS[framework]}\n\n`);
|
|
2305
2876
|
if (dryRun) deps.out("Dry run — nothing was written.\n\n");
|
|
2306
2877
|
for (const file of files) {
|
|
@@ -2321,7 +2892,7 @@ function runAdd(argv, cwd, deps = defaultAddDeps) {
|
|
|
2321
2892
|
}
|
|
2322
2893
|
if (!dryRun) {
|
|
2323
2894
|
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
|
|
2895
|
+
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
2896
|
}
|
|
2326
2897
|
return 0;
|
|
2327
2898
|
}
|
|
@@ -2332,6 +2903,13 @@ function readOwnVersion() {
|
|
|
2332
2903
|
if (pkg.name === "clivly" && pkg.version) return pkg.version;
|
|
2333
2904
|
} catch {}
|
|
2334
2905
|
}
|
|
2906
|
+
function readOwnEngines() {
|
|
2907
|
+
const req = createRequire(import.meta.url);
|
|
2908
|
+
for (const rel of ["./package.json", "../package.json"]) try {
|
|
2909
|
+
const pkg = req(rel);
|
|
2910
|
+
if (pkg.name === "clivly") return pkg.engines?.node;
|
|
2911
|
+
} catch {}
|
|
2912
|
+
}
|
|
2335
2913
|
/**
|
|
2336
2914
|
* Parse a `--db-import` / `--auth-import` value into a discovery-shaped override.
|
|
2337
2915
|
*
|
|
@@ -2361,12 +2939,24 @@ function authInstanceFromFlag(flag) {
|
|
|
2361
2939
|
function defaultReadFile(path) {
|
|
2362
2940
|
return existsSync(path) ? readFileSync(path, "utf8") : null;
|
|
2363
2941
|
}
|
|
2942
|
+
function resolveEnvPath(cwd) {
|
|
2943
|
+
const framework = detectFramework(readDeps(cwd));
|
|
2944
|
+
return join(cwd, resolveEnvFile({
|
|
2945
|
+
existing: ENV_FILE_CANDIDATES.filter((name) => {
|
|
2946
|
+
const path = join(cwd, name);
|
|
2947
|
+
if (!existsSync(path)) return false;
|
|
2948
|
+
const contents = readFileSync(path, "utf8");
|
|
2949
|
+
return isKeySet(contents, "CLIVLY_SECRET_KEY") || isKeySet(contents, "CLIVLY_API_KEY");
|
|
2950
|
+
}),
|
|
2951
|
+
framework
|
|
2952
|
+
}));
|
|
2953
|
+
}
|
|
2364
2954
|
function defaultReadEnv(cwd) {
|
|
2365
|
-
const path =
|
|
2955
|
+
const path = resolveEnvPath(cwd);
|
|
2366
2956
|
return existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
2367
2957
|
}
|
|
2368
2958
|
function defaultWriteEnv(cwd, contents) {
|
|
2369
|
-
writeFileSync(
|
|
2959
|
+
writeFileSync(resolveEnvPath(cwd), contents, "utf8");
|
|
2370
2960
|
}
|
|
2371
2961
|
function defaultIsInteractive() {
|
|
2372
2962
|
return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY) && !process.env.CI;
|
|
@@ -2393,6 +2983,8 @@ function resolveInitDeps(options) {
|
|
|
2393
2983
|
graph: graphFor(cwd)
|
|
2394
2984
|
})),
|
|
2395
2985
|
readFile: options.readFile ?? defaultReadFile,
|
|
2986
|
+
fileExists: options.fileExists ?? existsSync,
|
|
2987
|
+
resolveAppLayout: options.resolveAppLayout ?? resolveAppLayout,
|
|
2396
2988
|
writeConfig: options.writeConfig ?? ((path, contents) => {
|
|
2397
2989
|
mkdirSync(dirname(path), { recursive: true });
|
|
2398
2990
|
writeFileSync(path, contents, "utf8");
|
|
@@ -2413,6 +3005,7 @@ function resolveInitDeps(options) {
|
|
|
2413
3005
|
rotate: false
|
|
2414
3006
|
})),
|
|
2415
3007
|
framework: "unknown",
|
|
3008
|
+
layout: "app",
|
|
2416
3009
|
authWiring: renderAuthWiring("unknown")
|
|
2417
3010
|
};
|
|
2418
3011
|
}
|
|
@@ -2427,11 +3020,15 @@ async function acquireSchema(options, deps) {
|
|
|
2427
3020
|
const orm = detectOrm(appDeps);
|
|
2428
3021
|
const auth = detectAuth(appDeps);
|
|
2429
3022
|
deps.framework = framework;
|
|
3023
|
+
const layout = deps.resolveAppLayout(options.cwd, deps.fileExists);
|
|
3024
|
+
deps.layout = layout;
|
|
2430
3025
|
deps.authWiring = renderAuthWiring(auth, auth === "better-auth" ? authInstanceFromFlag(options.authImportFlag) ?? await deps.probeAuth(options.cwd) : null);
|
|
2431
3026
|
out("\nClivly init\n");
|
|
2432
3027
|
out(` Framework: ${FRAMEWORK_LABELS[framework]}\n`);
|
|
2433
3028
|
out(` ORM: ${orm}\n`);
|
|
2434
|
-
out(` Auth: ${auth}${deps.authWiring.complete ? " (wired)" : ""}\n
|
|
3029
|
+
out(` Auth: ${auth}${deps.authWiring.complete ? " (wired)" : ""}\n`);
|
|
3030
|
+
if (framework === "nextjs") out(` Layout: ${layout}\n`);
|
|
3031
|
+
out("\n");
|
|
2435
3032
|
if (orm !== "drizzle") {
|
|
2436
3033
|
out(`clivly init currently generates config for Drizzle only. Detected ORM: ${orm}\n`);
|
|
2437
3034
|
return { code: 1 };
|
|
@@ -2475,6 +3072,7 @@ function mutationsFor(options, deps, plan) {
|
|
|
2475
3072
|
cwd: options.cwd,
|
|
2476
3073
|
force: options.force,
|
|
2477
3074
|
framework: deps.framework,
|
|
3075
|
+
layout: deps.layout,
|
|
2478
3076
|
authWiring: deps.authWiring,
|
|
2479
3077
|
plan,
|
|
2480
3078
|
readFile: deps.readFile,
|
|
@@ -2534,14 +3132,15 @@ function previewMutations(deps, mutations, options) {
|
|
|
2534
3132
|
}
|
|
2535
3133
|
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
3134
|
}
|
|
2537
|
-
function printNextSteps(out, authComplete, loginRan) {
|
|
3135
|
+
function printNextSteps(out, authComplete, loginRan, envFile) {
|
|
2538
3136
|
out("\nNext steps:\n");
|
|
2539
|
-
out(loginRan ? " 1. Credentials written by `clivly login`\n" :
|
|
3137
|
+
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
3138
|
out(" 2. Set CLIVLY_SYNC_TRIGGER_URL to your public tick route\n");
|
|
2541
3139
|
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
3140
|
const step = authComplete ? 3 : 4;
|
|
2543
3141
|
if (!authComplete) out(" 3. Finish resolveUser in the auth/verify route\n");
|
|
2544
3142
|
out(` ${step}. Run \`clivly status\` to see where you are\n`);
|
|
3143
|
+
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
3144
|
}
|
|
2546
3145
|
function credentialsMissing(envContents) {
|
|
2547
3146
|
return !(isKeySet(envContents, "CLIVLY_SECRET_KEY") || isKeySet(envContents, "CLIVLY_API_KEY"));
|
|
@@ -2562,6 +3161,11 @@ async function scaffold(options, deps, plan) {
|
|
|
2562
3161
|
const pm = deps.detectPackageManager(options.cwd);
|
|
2563
3162
|
const packages = [resolveRuntimeInstallSpec(deps), ...deps.authWiring.packages];
|
|
2564
3163
|
const mutations = mutationsFor(options, deps, plan);
|
|
3164
|
+
const shadowing = shadowedAppDirError(options.cwd, deps.framework, mutations.files.map((file) => file.path), deps.fileExists);
|
|
3165
|
+
if (shadowing) {
|
|
3166
|
+
out(shadowing);
|
|
3167
|
+
return 1;
|
|
3168
|
+
}
|
|
2565
3169
|
if (options.dryRun || options.diff) {
|
|
2566
3170
|
out(`\nWould install (${pm}): ${packages.join(", ")}\n`);
|
|
2567
3171
|
previewMutations(deps, mutations, {
|
|
@@ -2586,7 +3190,7 @@ async function scaffold(options, deps, plan) {
|
|
|
2586
3190
|
out(" Run `clivly login` from a terminal to write CLIVLY_SECRET_KEY and CLIVLY_SYNC_TRIGGER_SECRET.\n");
|
|
2587
3191
|
} else loginRan = await deps.login(options.cwd) === 0;
|
|
2588
3192
|
}
|
|
2589
|
-
printNextSteps(out, deps.authWiring.complete, loginRan);
|
|
3193
|
+
printNextSteps(out, deps.authWiring.complete, loginRan, envFileFor(detectFramework(deps.readDepsFn(options.cwd))));
|
|
2590
3194
|
return 0;
|
|
2591
3195
|
}
|
|
2592
3196
|
function normalizeAppRoot(options) {
|
|
@@ -2653,8 +3257,18 @@ function printNoContactGuidance(out, plan, options) {
|
|
|
2653
3257
|
out(`\n Tables in ${plan.importPath}:\n`);
|
|
2654
3258
|
for (const name of plan.tableNames) out(` ${name}\n`);
|
|
2655
3259
|
const schemaArg = options.schemaFlag ? ` --schema ${options.schemaFlag}` : "";
|
|
2656
|
-
|
|
2657
|
-
|
|
3260
|
+
const best = plan.contactCandidates[0];
|
|
3261
|
+
if (best) {
|
|
3262
|
+
out(`\n Best guess: ${best.table} — ${best.reasons.join(", ")}\n`);
|
|
3263
|
+
const runnerUp = plan.contactCandidates.slice(1, 3).map((candidate) => candidate.table);
|
|
3264
|
+
if (runnerUp.length > 0) out(` Also possible: ${runnerUp.join(", ")}\n`);
|
|
3265
|
+
out("\n Confirm it and re-run:\n");
|
|
3266
|
+
out(` clivly init${schemaArg} --contact-table ${best.table}\n\n`);
|
|
3267
|
+
return;
|
|
3268
|
+
}
|
|
3269
|
+
out("\n Nothing here looks like a people table — no email, phone or name\n");
|
|
3270
|
+
out(" columns turned up. Pick the one that holds your people and re-run:\n");
|
|
3271
|
+
out(` clivly init${schemaArg} --contact-table ${plan.tableNames[0] ?? "<table>"}\n\n`);
|
|
2658
3272
|
out(" Nothing was written — Clivly needs one entity before a config can load.\n\n");
|
|
2659
3273
|
}
|
|
2660
3274
|
function hasCloudflaredOnPath() {
|
|
@@ -2750,6 +3364,22 @@ function defaultDevDeps(options) {
|
|
|
2750
3364
|
},
|
|
2751
3365
|
out: (line) => {
|
|
2752
3366
|
process.stdout.write(`${line}\n`);
|
|
3367
|
+
},
|
|
3368
|
+
watchConfig: (cwd, onChange) => {
|
|
3369
|
+
const target = resolveConfigPath(cwd);
|
|
3370
|
+
try {
|
|
3371
|
+
let timer = null;
|
|
3372
|
+
const watcher = watch(target, () => {
|
|
3373
|
+
if (timer) clearTimeout(timer);
|
|
3374
|
+
timer = setTimeout(onChange, CONFIG_WATCH_DEBOUNCE_MS);
|
|
3375
|
+
});
|
|
3376
|
+
return () => {
|
|
3377
|
+
if (timer) clearTimeout(timer);
|
|
3378
|
+
watcher.close();
|
|
3379
|
+
};
|
|
3380
|
+
} catch {
|
|
3381
|
+
return null;
|
|
3382
|
+
}
|
|
2753
3383
|
}
|
|
2754
3384
|
};
|
|
2755
3385
|
}
|
|
@@ -2828,6 +3458,21 @@ function runInitFromArgv(argv, cwd) {
|
|
|
2828
3458
|
}
|
|
2829
3459
|
async function main(argv) {
|
|
2830
3460
|
const [command, ...rest] = argv;
|
|
3461
|
+
if (command === "-h" || command === "--help" || isHelpRequest(rest)) {
|
|
3462
|
+
process.stdout.write(helpFor(command));
|
|
3463
|
+
return 0;
|
|
3464
|
+
}
|
|
3465
|
+
if (command === "--version" || command === "-v") {
|
|
3466
|
+
process.stdout.write(`${readOwnVersion() ?? "unknown"}\n`);
|
|
3467
|
+
return 0;
|
|
3468
|
+
}
|
|
3469
|
+
if (!process.env.CLIVLY_SKIP_ENGINE_CHECK) {
|
|
3470
|
+
const engineProblem = engineError(process.version, readOwnEngines());
|
|
3471
|
+
if (engineProblem) {
|
|
3472
|
+
process.stderr.write(engineProblem);
|
|
3473
|
+
return 1;
|
|
3474
|
+
}
|
|
3475
|
+
}
|
|
2831
3476
|
let cliEnvFiles = [];
|
|
2832
3477
|
try {
|
|
2833
3478
|
cliEnvFiles = (await loadCliConfig(process.cwd()))?.envFiles ?? [];
|
|
@@ -2846,14 +3491,16 @@ async function main(argv) {
|
|
|
2846
3491
|
case "status":
|
|
2847
3492
|
case "ping":
|
|
2848
3493
|
case "doctor":
|
|
2849
|
-
case "connect":
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
3494
|
+
case "connect":
|
|
3495
|
+
if (command !== "status") process.stderr.write(`\`clivly ${command}\` is a deprecated alias that runs part of \`clivly status\`. Prefer \`clivly status\`.\n`);
|
|
3496
|
+
return await runStatus({
|
|
3497
|
+
cwd: process.cwd(),
|
|
3498
|
+
apiKey: readSecretKeyEnv(process.env),
|
|
3499
|
+
apiUrl: flagValue(rest, "--api-url") ?? process.env.CLIVLY_API_URL,
|
|
3500
|
+
configPath: flagValue(rest, "--config"),
|
|
3501
|
+
only: ALIAS_STAGE_IDS[command],
|
|
3502
|
+
verbose: rest.includes("--verbose")
|
|
3503
|
+
});
|
|
2857
3504
|
case "dev": {
|
|
2858
3505
|
const apiKey = readSecretKeyEnv(process.env);
|
|
2859
3506
|
const apiUrl = flagValue(rest, "--api-url") ?? process.env.CLIVLY_API_URL ?? "https://api.clivly.com";
|
|
@@ -2876,14 +3523,11 @@ async function main(argv) {
|
|
|
2876
3523
|
openBrowser: !rest.includes("--no-browser"),
|
|
2877
3524
|
deps: defaultLoginDeps()
|
|
2878
3525
|
});
|
|
2879
|
-
case "--version":
|
|
2880
|
-
case "-v":
|
|
2881
|
-
process.stdout.write(`${readOwnVersion() ?? "unknown"}\n`);
|
|
2882
|
-
return 0;
|
|
2883
3526
|
case void 0:
|
|
3527
|
+
process.stdout.write(ROOT_HELP);
|
|
3528
|
+
return 0;
|
|
2884
3529
|
case "help":
|
|
2885
|
-
|
|
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");
|
|
3530
|
+
process.stdout.write(helpFor(rest[0]));
|
|
2887
3531
|
return 0;
|
|
2888
3532
|
default: {
|
|
2889
3533
|
process.stdout.write(`Unknown command: ${command}\n`);
|