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.
- package/README.md +11 -0
- package/dist/index.mjs +450 -101
- 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/
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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(
|
|
151
|
-
const orm = detectOrm(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (
|
|
156
|
-
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
|
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(
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
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
|
}
|