clivly 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +11 -0
  2. package/dist/index.mjs +450 -101
  3. package/package.json +9 -3
package/README.md CHANGED
@@ -7,10 +7,21 @@ Scaffold [Clivly](https://clivly.com) — the CRM that lives inside your app.
7
7
  ```bash
8
8
  npx clivly init # detect your stack and print the scaffold plan
9
9
  npx clivly init --write # write clivly.config.ts
10
+ npx clivly ping # verify CLIVLY_API_KEY + reachability
10
11
  ```
11
12
 
12
13
  `clivly init` detects your framework (Next.js, TanStack Start, SvelteKit, Remix, Nuxt) and ORM (Drizzle, Prisma, Kysely) from `package.json`, then reports the routes and schema fragment it will scaffold.
13
14
 
15
+ `clivly ping` reads `CLIVLY_API_KEY` from the environment, sends one heartbeat, and prints a clear ✅/❌ with the resolved org or the failure reason — so you can confirm a connection from the terminal without writing any code. Point it at a non-default backend with `--api-url <url>` (or `CLIVLY_API_URL`).
16
+
17
+ ```bash
18
+ CLIVLY_API_KEY=sk_live_… npx clivly ping
19
+ #
20
+ # Clivly ping
21
+ # ✅ Connected to Clivly
22
+ # Org: Acme (org_1)
23
+ ```
24
+
14
25
  ## License
15
26
 
16
27
  MIT
package/dist/index.mjs CHANGED
@@ -1,6 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
2
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import { createJiti } from "jiti";
6
+ import { createClivlySDK } from "@clivly/sdk";
7
+ import { CANONICAL_FIELDS } from "@clivly/core/entity-config";
8
+ import { matchesConcept, requiredFieldsFor, suggestCursorField, suggestFieldMap, suggestRelationships } from "@clivly/core/entity-heuristics";
4
9
  //#region src/detect.ts
5
10
  const FRAMEWORK_MARKERS = [
6
11
  {
@@ -55,79 +60,354 @@ function detectOrm(deps) {
55
60
  return "unknown";
56
61
  }
57
62
  //#endregion
58
- //#region src/scaffold.ts
59
- const FRAMEWORK_ROUTES = {
60
- nextjs: [{
61
- path: "app/crm/[[...slug]]/page.tsx",
62
- description: "Mounts the Clivly SPA at /crm",
63
- owned: true
64
- }, {
65
- path: "app/api/clivly/[...path]/route.ts",
66
- description: "Clivly API handler",
67
- owned: true
68
- }],
69
- "tanstack-start": [{
70
- path: "src/routes/crm/$.tsx",
71
- description: "Mounts the Clivly SPA at /crm",
72
- owned: true
73
- }, {
74
- path: "src/routes/api/clivly/$.ts",
75
- description: "Clivly API handler",
76
- owned: true
77
- }],
78
- sveltekit: [{
79
- path: "src/routes/crm/[...slug]/+page.svelte",
80
- description: "Mounts the Clivly SPA at /crm",
81
- owned: true
82
- }, {
83
- path: "src/routes/api/clivly/[...path]/+server.ts",
84
- description: "Clivly API handler",
85
- owned: true
86
- }],
87
- remix: [{
88
- path: "app/routes/crm.$.tsx",
89
- description: "Mounts the Clivly SPA at /crm",
90
- owned: true
91
- }, {
92
- path: "app/routes/api.clivly.$.ts",
93
- description: "Clivly API handler",
94
- owned: true
95
- }],
96
- nuxt: [{
97
- path: "pages/crm/[...slug].vue",
98
- description: "Mounts the Clivly SPA at /crm",
99
- owned: true
100
- }, {
101
- path: "server/api/clivly/[...path].ts",
102
- description: "Clivly API handler",
103
- owned: true
104
- }],
105
- unknown: []
63
+ //#region src/discover-schema.ts
64
+ const WINDOWS_SEP = /\\/g;
65
+ const MODULE_EXT = /\.(ts|js|mjs|cts|mts)$/;
66
+ function toImportPath(schemaPath, cwd) {
67
+ const rel = relative(cwd, schemaPath).replace(WINDOWS_SEP, "/").replace(MODULE_EXT, "");
68
+ return rel.startsWith(".") ? rel : `./${rel}`;
69
+ }
70
+ function isDirectory(path) {
71
+ try {
72
+ return statSync(path).isDirectory();
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+ function loadSchemaModule(schemaPath, load) {
78
+ return load(isDirectory(schemaPath) ? join(schemaPath, "index.ts") : schemaPath);
79
+ }
80
+ function defaultLoader(cwd) {
81
+ const jiti = createJiti(pathToFileURL(join(cwd, "clivly.config.ts")).href);
82
+ return (specifier) => jiti.import(specifier);
83
+ }
84
+ async function discoverSchema(opts) {
85
+ const load = opts.load ?? defaultLoader(opts.cwd);
86
+ const mod = await loadSchemaModule(opts.schemaPath, load);
87
+ const drizzle = await load("drizzle-orm");
88
+ const importPath = toImportPath(opts.schemaPath, opts.cwd);
89
+ const tables = [];
90
+ let db = null;
91
+ for (const [moduleKey, value] of Object.entries(mod)) {
92
+ if (drizzle.is(value, drizzle.Table)) {
93
+ const columns = Object.entries(drizzle.getTableColumns(value)).map(([key, col]) => ({
94
+ key,
95
+ dbName: col.name,
96
+ isPrimary: Boolean(col.primary)
97
+ }));
98
+ tables.push({
99
+ moduleKey,
100
+ tableName: drizzle.getTableName(value),
101
+ columns
102
+ });
103
+ continue;
104
+ }
105
+ if (db === null && value && typeof value.select === "function") db = {
106
+ moduleKey,
107
+ importPath
108
+ };
109
+ }
110
+ return {
111
+ tables,
112
+ importPath,
113
+ db
114
+ };
115
+ }
116
+ //#endregion
117
+ //#region src/render.ts
118
+ function checkLine(ok, label, detail) {
119
+ return ` ${ok ? "✅" : "❌"} ${label}${detail ? ` — ${detail}` : ""}\n`;
120
+ }
121
+ //#endregion
122
+ //#region src/doctor.ts
123
+ async function loadSdkFromConfig(path) {
124
+ const mod = await createJiti(import.meta.url).import(path);
125
+ const sdk = mod.default ?? mod;
126
+ if (typeof sdk.doctor !== "function") throw new Error("default export is not a Clivly SDK instance");
127
+ return sdk;
128
+ }
129
+ /**
130
+ * `clivly doctor` — load the app's clivly.config, run the SDK's read-only setup
131
+ * checks, and print a ✅/❌ checklist. Exit 0 when every check passes. `loadSdk`
132
+ * and `out` are injectable for tests.
133
+ */
134
+ async function runDoctor(options) {
135
+ const out = options.out ?? ((line) => process.stdout.write(line));
136
+ const path = resolve(options.cwd, options.configPath ?? "clivly.config.ts");
137
+ const loadSdk = options.loadSdk ?? loadSdkFromConfig;
138
+ out("\nClivly doctor\n");
139
+ let sdk;
140
+ try {
141
+ sdk = await loadSdk(path);
142
+ } catch (error) {
143
+ out(checkLine(false, "Load clivly.config", error.message));
144
+ out(" Expected clivly.config.ts at your app root with `export default createClivlySDK({...})`.\n");
145
+ return 1;
146
+ }
147
+ const report = await sdk.doctor();
148
+ for (const check of report.checks) {
149
+ if (check.skipped) {
150
+ out(` ⚠️ ${check.name} — ${check.detail}\n`);
151
+ continue;
152
+ }
153
+ out(checkLine(check.ok, check.name, check.detail));
154
+ }
155
+ return report.ok ? 0 : 1;
156
+ }
157
+ //#endregion
158
+ //#region src/ping.ts
159
+ const REASON_HINT = {
160
+ ok: "",
161
+ invalid_api_key: "The key was rejected (401). Check CLIVLY_API_KEY.",
162
+ forbidden: "The key is valid but lacks access to this org (403).",
163
+ rate_limited: "Rate limited (429). Wait a moment and retry.",
164
+ server_error: "Clivly returned a server error (5xx). Retry shortly.",
165
+ client_error: "The request was rejected (4xx). Check your configuration.",
166
+ network_error: "Could not reach Clivly. Check your network or --api-url."
106
167
  };
107
- const COMMON_FILES = [{
108
- path: "drizzle/schema/clivly.ts",
109
- description: "crm_* schema fragment — merge into your schema",
110
- owned: false
111
- }, {
112
- path: "clivly.config.ts",
113
- description: "Your Clivly configuration",
114
- owned: false
115
- }];
116
- function planScaffold(framework) {
117
- return [...FRAMEWORK_ROUTES[framework] ?? [], ...COMMON_FILES];
118
- }
119
- function renderConfigTemplate(contactEntity) {
120
- return `import { defineClivlyConfig } from "clivly";
168
+ const defaultConnect = (apiKey, apiUrl) => createClivlySDK({
169
+ apiKey,
170
+ apiUrl,
171
+ retry: { maxAttempts: 1 }
172
+ }).connect();
173
+ function resultLines(result) {
174
+ if (result.ok) {
175
+ const lines = [checkLine(true, "Connected to Clivly")];
176
+ if (result.org) {
177
+ const name = result.org.name ?? "(unnamed org)";
178
+ const id = result.org.id ? ` (${result.org.id})` : "";
179
+ lines.push(` Org: ${name}${id}\n`);
180
+ }
181
+ return lines;
182
+ }
183
+ const status = result.status === null ? "no response" : `HTTP ${result.status}`;
184
+ return [checkLine(false, `Could not connect (${result.reason}, ${status})`), ` ${REASON_HINT[result.reason]}\n`];
185
+ }
186
+ /**
187
+ * `clivly ping` — send one heartbeat with the configured key and print a clear
188
+ * ✅/❌ with the resolved org or the failure reason. Verifies key + reachability
189
+ * from the terminal with zero code. `connect`/`out` are injectable for tests.
190
+ */
191
+ async function runPing(options) {
192
+ const out = options.out ?? ((line) => process.stdout.write(line));
193
+ out("\nClivly ping\n");
194
+ if (!options.apiKey) {
195
+ out(checkLine(false, "CLIVLY_API_KEY is not set."));
196
+ out(" Set it to your sk_live_… key from Settings → API keys.\n");
197
+ return 1;
198
+ }
199
+ const result = await (options.connect ?? defaultConnect)(options.apiKey, options.apiUrl);
200
+ for (const line of resultLines(result)) out(line);
201
+ return result.ok ? 0 : 1;
202
+ }
203
+ //#endregion
204
+ //#region src/plan-init.ts
205
+ function rankCandidates(tables, concept) {
206
+ return tables.map((t) => ({
207
+ t,
208
+ m: matchesConcept(t.tableName, concept)
209
+ })).filter((c) => Boolean(c.m)).sort((a, b) => Number(b.m.exact) - Number(a.m.exact)).map((c) => c.t);
210
+ }
211
+ function selectEntities(tables) {
212
+ const contactCands = rankCandidates(tables, "contact");
213
+ const companyCands = rankCandidates(tables, "company");
214
+ const contactNames = new Set(contactCands.map((t) => t.tableName));
215
+ const companyNames = new Set(companyCands.map((t) => t.tableName));
216
+ const contactExclusive = contactCands.filter((t) => !companyNames.has(t.tableName));
217
+ const companyExclusive = companyCands.filter((t) => !contactNames.has(t.tableName));
218
+ const contact = contactExclusive[0] ?? contactCands[0] ?? null;
219
+ const companyPreferred = companyExclusive[0] ?? companyCands[0] ?? null;
220
+ return {
221
+ contact,
222
+ company: companyPreferred && companyPreferred.tableName !== contact?.tableName ? companyPreferred : companyCands.find((t) => t.tableName !== contact?.tableName) ?? null
223
+ };
224
+ }
225
+ function chooseSyncFields(table, todos) {
226
+ const columnKeys = table.columns.map((c) => c.key);
227
+ let cursorField = suggestCursorField(columnKeys);
228
+ if (!cursorField) {
229
+ cursorField = table.columns.find((c) => c.isPrimary)?.key ?? columnKeys[0] ?? "id";
230
+ todos.push(`No updated_at-like column on "${table.tableName}" — using "${cursorField}" as the sync cursor. Confirm it is monotonic.`);
231
+ }
232
+ const pkCols = table.columns.filter((c) => c.isPrimary);
233
+ if (pkCols.length === 1) return {
234
+ cursorField,
235
+ idField: null
236
+ };
237
+ const idField = pkCols[0]?.key ?? columnKeys[0] ?? "id";
238
+ todos.push(`Table "${table.tableName}" has ${pkCols.length === 0 ? "no" : "a composite"} primary key — set idField (guessed "${idField}").`);
239
+ return {
240
+ cursorField,
241
+ idField
242
+ };
243
+ }
244
+ function buildEntity(table, concept, todos) {
245
+ const canonical = CANONICAL_FIELDS[concept];
246
+ const suggested = suggestFieldMap(concept, table.columns.map((c) => c.dbName));
247
+ const fields = {};
248
+ for (const [key, column] of Object.entries(suggested)) if (canonical.includes(key)) fields[key] = column;
249
+ for (const required of requiredFieldsFor(concept)) if (!fields[required]) todos.push(`Could not map required ${concept} field "${required}" on "${table.tableName}" — add it manually.`);
250
+ const { cursorField, idField } = chooseSyncFields(table, todos);
251
+ return {
252
+ moduleKey: table.moduleKey,
253
+ tableName: table.tableName,
254
+ concept,
255
+ fields,
256
+ cursorField,
257
+ idField
258
+ };
259
+ }
260
+ function placeholderContact(todos) {
261
+ todos.push("No contact table detected. Filled in a placeholder — set `source`, `fields`, and the fromDrizzle table below, then run `clivly doctor`.");
262
+ return {
263
+ moduleKey: "contacts",
264
+ tableName: "contacts",
265
+ concept: "contact",
266
+ fields: {
267
+ name: "name",
268
+ email: "email"
269
+ },
270
+ cursorField: "updatedAt",
271
+ idField: null
272
+ };
273
+ }
274
+ function planInit(schema) {
275
+ const todos = [];
276
+ const { contact: contactTable, company: companyTable } = selectEntities(schema.tables);
277
+ const contactDetected = contactTable !== null;
278
+ const contact = contactTable ? buildEntity(contactTable, "contact", todos) : placeholderContact(todos);
279
+ let company = null;
280
+ if (companyTable) {
281
+ company = buildEntity(companyTable, "company", todos);
282
+ 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.`);
283
+ }
284
+ if (contactTable) {
285
+ const suggestions = suggestRelationships({
286
+ name: contactTable.tableName,
287
+ columns: contactTable.columns.map((c) => c.dbName)
288
+ }, schema.tables.map((t) => ({
289
+ name: t.tableName,
290
+ columns: t.columns.map((c) => c.dbName)
291
+ })));
292
+ const only = suggestions[0];
293
+ if (suggestions.length === 1 && only) contact.relationship = {
294
+ name: only.name,
295
+ via: only.via
296
+ };
297
+ }
298
+ return {
299
+ importPath: schema.importPath,
300
+ db: schema.db,
301
+ contact,
302
+ company,
303
+ todos,
304
+ contactDetected
305
+ };
306
+ }
307
+ //#endregion
308
+ //#region src/render-config.ts
309
+ function renderFields(fields) {
310
+ return `{ ${Object.entries(fields).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(", ")} }`;
311
+ }
312
+ function renderRelationships(entity) {
313
+ if (!entity.relationship) return "";
314
+ const { name, via } = entity.relationship;
315
+ return `,\n relationships: { ${name}: { entity: "${name}", via: ${JSON.stringify(via)} } }`;
316
+ }
317
+ function renderEntity(key, entity) {
318
+ return ` ${key}: {
319
+ concept: "${entity.concept}",
320
+ source: "${entity.tableName}",
321
+ fields: ${renderFields(entity.fields)}${renderRelationships(entity)},
322
+ }`;
323
+ }
324
+ function renderSource(role, entity) {
325
+ const opts = entity.idField === null ? `{ entity: "${entity.tableName}", cursorField: "${entity.cursorField}" }` : `{ entity: "${entity.tableName}", cursorField: "${entity.cursorField}", idField: "${entity.idField}" }`;
326
+ return ` ${role}: fromDrizzle(db, ${entity.moduleKey}, ${opts})`;
327
+ }
328
+ function renderTableImport(plan) {
329
+ const tableKeys = [plan.contact.moduleKey, plan.company?.moduleKey].filter(Boolean);
330
+ if (plan.db) return `import { db, ${tableKeys.join(", ")} } from "${plan.importPath}";`;
331
+ return `import { ${tableKeys.join(", ")} } from "${plan.importPath}";
332
+ import { db } from "./db"; // TODO: point at your Drizzle instance`;
333
+ }
334
+ function renderTodoHeader(todos) {
335
+ if (todos.length === 0) return "";
336
+ return `// TODO(clivly): review before running \`clivly doctor\`:\n${todos.map((t) => `// - ${t}`).join("\n")}\n\n`;
337
+ }
338
+ function renderInitConfig(plan) {
339
+ const entities = [renderEntity(plan.contact.moduleKey, plan.contact)];
340
+ if (plan.company) entities.push(renderEntity(plan.company.moduleKey, plan.company));
341
+ const sources = [renderSource("contacts", plan.contact)];
342
+ if (plan.company) sources.push(renderSource("companies", plan.company));
343
+ return `${renderTodoHeader(plan.todos)}import { createClivlySDK } from "@clivly/sdk";
344
+ import { fromDrizzle } from "@clivly/sdk/drizzle";
345
+ import { defineClivlyConfig } from "@clivly/core";
346
+ ${renderTableImport(plan)}
347
+
348
+ const entities = defineClivlyConfig({
349
+ entities: {
350
+ ${entities.join(",\n")},
351
+ },
352
+ });
121
353
 
122
- export default defineClivlyConfig({
123
- // The host table Clivly treats as the contact entity.
124
- contactEntity: "${contactEntity}",
125
- contactFields: ["id", "email", "name", "created_at"],
354
+ export default createClivlySDK({
355
+ apiKey: process.env.CLIVLY_API_KEY!,
356
+ entities,
357
+ source: {
358
+ ${sources.join(",\n")},
359
+ },
126
360
  });
127
361
  `;
128
362
  }
129
363
  //#endregion
364
+ //#region src/resolve-schema-path.ts
365
+ const COMMON_PATHS = [
366
+ "src/db/schema.ts",
367
+ "src/db/schema",
368
+ "db/schema.ts",
369
+ "db/schema",
370
+ "src/schema.ts",
371
+ "src/schema",
372
+ "drizzle/schema.ts",
373
+ "drizzle/schema"
374
+ ];
375
+ const DRIZZLE_CONFIG_NAMES = [
376
+ "drizzle.config.ts",
377
+ "drizzle.config.js",
378
+ "drizzle.config.mjs"
379
+ ];
380
+ function defaultReadDrizzleConfigSchema(cwd) {
381
+ for (const name of DRIZZLE_CONFIG_NAMES) {
382
+ const path = resolve(cwd, name);
383
+ if (!existsSync(path)) continue;
384
+ try {
385
+ const schema = createJiti(pathToFileURL(path).href)(path).default?.schema;
386
+ const first = Array.isArray(schema) ? schema[0] : schema;
387
+ if (typeof first === "string") return first;
388
+ } catch {}
389
+ }
390
+ return null;
391
+ }
392
+ function resolveSchemaPath(opts) {
393
+ const exists = opts.deps?.exists ?? existsSync;
394
+ const readSchema = opts.deps?.readDrizzleConfigSchema ?? defaultReadDrizzleConfigSchema;
395
+ const toAbs = (p) => isAbsolute(p) ? p : resolve(opts.cwd, p);
396
+ if (opts.flag) return toAbs(opts.flag);
397
+ const fromConfig = readSchema(opts.cwd);
398
+ if (fromConfig) return toAbs(fromConfig);
399
+ for (const candidate of COMMON_PATHS) {
400
+ const abs = toAbs(candidate);
401
+ if (exists(abs)) return abs;
402
+ }
403
+ return null;
404
+ }
405
+ //#endregion
130
406
  //#region src/index.ts
407
+ function flagValue(argv, flag) {
408
+ const index = argv.indexOf(flag);
409
+ return index >= 0 ? argv[index + 1] : void 0;
410
+ }
131
411
  function readDeps(cwd) {
132
412
  const pkgPath = join(cwd, "package.json");
133
413
  if (!existsSync(pkgPath)) return {};
@@ -141,53 +421,122 @@ function readDeps(cwd) {
141
421
  return {};
142
422
  }
143
423
  }
144
- function runInit(cwd, write) {
145
- const deps = readDeps(cwd);
146
- if (Object.keys(deps).length === 0) {
147
- process.stdout.write("No package.json found here. Run `clivly init` from your app root.\n");
148
- return 1;
424
+ function resolveInitDeps(options) {
425
+ return {
426
+ out: options.out ?? ((line) => process.stdout.write(line)),
427
+ readDepsFn: options.readDeps ?? readDeps,
428
+ resolvePath: options.resolveSchemaPath ?? ((cwd) => resolveSchemaPath({
429
+ cwd,
430
+ flag: options.schemaFlag
431
+ })),
432
+ discover: options.discoverSchema ?? ((schemaPath, cwd) => discoverSchema({
433
+ schemaPath,
434
+ cwd
435
+ })),
436
+ configExists: options.configExists ?? existsSync,
437
+ writeConfig: options.writeConfig ?? ((path, contents) => writeFileSync(path, contents, "utf8"))
438
+ };
439
+ }
440
+ async function acquireSchema(options, deps) {
441
+ const { out } = deps;
442
+ const appDeps = deps.readDepsFn(options.cwd);
443
+ if (Object.keys(appDeps).length === 0) {
444
+ out("No package.json found here. Run `clivly init` from your app root.\n");
445
+ return { code: 1 };
149
446
  }
150
- const framework = detectFramework(deps);
151
- const orm = detectOrm(deps);
152
- process.stdout.write("\nClivly init\n");
153
- process.stdout.write(` Framework: ${FRAMEWORK_LABELS[framework]}\n`);
154
- process.stdout.write(` ORM: ${orm}\n\n`);
155
- if (framework === "unknown") {
156
- process.stdout.write("Could not detect a supported framework. Supported: Next.js, TanStack Start, SvelteKit, Remix, Nuxt.\n");
157
- return 1;
447
+ const framework = detectFramework(appDeps);
448
+ const orm = detectOrm(appDeps);
449
+ out("\nClivly init\n");
450
+ out(` Framework: ${FRAMEWORK_LABELS[framework]}\n`);
451
+ out(` ORM: ${orm}\n\n`);
452
+ if (orm !== "drizzle") {
453
+ out(`clivly init currently generates config for Drizzle only. Detected ORM: ${orm}\n`);
454
+ return { code: 1 };
158
455
  }
159
- const files = planScaffold(framework);
160
- process.stdout.write("Will scaffold:\n");
161
- for (const file of files) {
162
- const tag = file.owned ? "generated" : "yours";
163
- process.stdout.write(` ${file.path} (${tag}) — ${file.description}\n`);
164
- }
165
- process.stdout.write("\n");
166
- if (write) {
167
- const configPath = join(cwd, "clivly.config.ts");
168
- if (existsSync(configPath)) process.stdout.write("clivly.config.ts already exists skipped.\n");
169
- else {
170
- writeFileSync(configPath, renderConfigTemplate("users"), "utf8");
171
- process.stdout.write("Created clivly.config.ts\n");
456
+ const schemaPath = deps.resolvePath(options.cwd);
457
+ if (!schemaPath) {
458
+ out("Could not locate your Drizzle schema. Pass --schema <path>, or add a `schema` field to drizzle.config.ts.\n");
459
+ return { code: 1 };
460
+ }
461
+ out(` Schema: ${schemaPath}\n\n`);
462
+ try {
463
+ const schema = await deps.discover(schemaPath, options.cwd);
464
+ if (schema.tables.length === 0) {
465
+ out(`No Drizzle tables found in ${schemaPath}. Is this the right path?\n`);
466
+ return { code: 1 };
172
467
  }
173
- } else process.stdout.write("Dry run. Re-run with --write to scaffold.\n");
174
- return 0;
468
+ return schema;
469
+ } catch (error) {
470
+ out(`Failed to load the schema module: ${error.message}\n`);
471
+ out("The schema must import cleanly without a live DB connection.\n");
472
+ return { code: 1 };
473
+ }
474
+ }
475
+ function printPlan(out, plan) {
476
+ out("Detected:\n");
477
+ out(` contact ← ${plan.contact.tableName} fields ${JSON.stringify(plan.contact.fields)}\n`);
478
+ if (plan.company) out(` company ← ${plan.company.tableName} fields ${JSON.stringify(plan.company.fields)}\n`);
479
+ out("\n");
480
+ if (plan.todos.length > 0) {
481
+ out("Review after generation:\n");
482
+ for (const todo of plan.todos) out(` • ${todo}\n`);
483
+ out("\n");
484
+ }
485
+ }
486
+ function writePlan(options, deps, plan) {
487
+ const { out } = deps;
488
+ const configPath = join(options.cwd, "clivly.config.ts");
489
+ if (!options.write) {
490
+ out("Dry run. Re-run with --write to scaffold clivly.config.ts.\n");
491
+ return plan.contactDetected ? 0 : 1;
492
+ }
493
+ if (deps.configExists(configPath) && !options.force) {
494
+ out("clivly.config.ts already exists — pass --force to overwrite.\n");
495
+ return 0;
496
+ }
497
+ deps.writeConfig(configPath, renderInitConfig(plan));
498
+ out("Created clivly.config.ts\n");
499
+ out("Next: set CLIVLY_API_KEY, address any TODOs above, then run `clivly doctor`.\n");
500
+ return plan.contactDetected ? 0 : 1;
175
501
  }
176
- function main(argv) {
502
+ async function runInit(options) {
503
+ const deps = resolveInitDeps(options);
504
+ const acquired = await acquireSchema(options, deps);
505
+ if ("code" in acquired) return acquired.code;
506
+ const plan = planInit(acquired);
507
+ printPlan(deps.out, plan);
508
+ return writePlan(options, deps, plan);
509
+ }
510
+ async function main(argv) {
177
511
  const [command, ...rest] = argv;
178
512
  const write = rest.includes("--write");
179
513
  switch (command) {
180
- case "init": return runInit(process.cwd(), write);
514
+ case "init": return await runInit({
515
+ cwd: process.cwd(),
516
+ write,
517
+ force: rest.includes("--force"),
518
+ schemaFlag: flagValue(rest, "--schema")
519
+ });
520
+ case "ping": return await runPing({
521
+ apiKey: process.env.CLIVLY_API_KEY,
522
+ apiUrl: flagValue(rest, "--api-url") ?? process.env.CLIVLY_API_URL
523
+ });
524
+ case "doctor": return await runDoctor({
525
+ cwd: process.cwd(),
526
+ configPath: flagValue(rest, "--config")
527
+ });
181
528
  case void 0:
182
529
  case "help":
183
530
  case "--help":
184
- process.stdout.write("clivly — embed a CRM in your app\n\nUsage:\n clivly init [--write] Detect your stack and scaffold Clivly\n");
531
+ process.stdout.write("clivly — embed a CRM in your app\n\nUsage:\n clivly init [--write] [--force] [--schema <path>] Detect your Drizzle schema and generate clivly.config.ts\n clivly ping [--api-url <url>] Verify CLIVLY_API_KEY + reachability\n clivly doctor [--config <path>] Verify your clivly.config setup end-to-end\n");
185
532
  return 0;
186
533
  default:
187
534
  process.stdout.write(`Unknown command: ${command}\n`);
188
535
  return 1;
189
536
  }
190
537
  }
191
- process.exitCode = main(process.argv.slice(2));
538
+ if (process.argv[1] === fileURLToPath(import.meta.url)) main(process.argv.slice(2)).then((code) => {
539
+ process.exitCode = code;
540
+ });
192
541
  //#endregion
193
- export {};
542
+ export { runInit };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clivly",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Clivly CLI — scaffold a CRM into your app (clivly init).",
@@ -16,6 +16,11 @@
16
16
  "files": [
17
17
  "dist"
18
18
  ],
19
+ "dependencies": {
20
+ "jiti": "^2.7.0",
21
+ "@clivly/core": "0.2.0",
22
+ "@clivly/sdk": "0.2.0"
23
+ },
19
24
  "publishConfig": {
20
25
  "access": "public"
21
26
  },
@@ -23,11 +28,12 @@
23
28
  "@types/node": "^25.9.1",
24
29
  "tsdown": "^0.21.9",
25
30
  "typescript": "^6",
26
- "@clivly.com/config": "0.0.0"
31
+ "vitest": "^3.2.4"
27
32
  },
28
33
  "scripts": {
29
34
  "build": "tsdown",
30
- "check-types": "tsc --noEmit"
35
+ "check-types": "tsc --noEmit",
36
+ "test": "vitest run"
31
37
  },
32
38
  "main": "./dist/index.mjs"
33
39
  }