clivly 0.3.0-next.1 → 0.3.0-next.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -4
- package/drizzle.cjs +1 -1
- package/drizzle.d.cts +1 -1
- package/drizzle.d.mts +1 -1
- package/drizzle.mjs +1 -1
- package/index.cjs +604 -31
- package/index.d.cts +6 -1
- package/index.d.mts +6 -1
- package/index.mjs +606 -34
- package/{namespace-probe-BtS0nXcr.d.cts → namespace-probe-CcYne5Je.d.cts} +17 -0
- package/{namespace-probe-CYMie-BI.d.mts → namespace-probe-DjWzU9gG.d.mts} +17 -0
- package/package.json +1 -1
- package/{sdk-vu_uej2q.mjs → sdk-DtFmeuwl.mjs} +129 -2
- package/{sdk-BNdK3KgW.cjs → sdk-r4khXzoH.cjs} +129 -2
- package/sdk.cjs +1 -1
- package/sdk.d.cts +1 -1
- package/sdk.d.mts +1 -1
- package/sdk.mjs +1 -1
- package/vite.d.cts +1 -1
- package/vite.d.mts +1 -1
- /package/{drizzle-meta-CM5M73Z4.cjs → drizzle-meta-BVR1qiyu.cjs} +0 -0
- /package/{drizzle-meta-1z6Apnt-.mjs → drizzle-meta-DpMKataU.mjs} +0 -0
package/index.mjs
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
import { t as CANONICAL_FIELDS } from "./entity-config-D3YpQB0z.mjs";
|
|
3
3
|
import "./mapping-score-CPPvwoZC.mjs";
|
|
4
4
|
import { matchesConcept, requiredFieldsFor, scoreFieldMap, scoreRelationships, suggestCursorField, toFieldMap } from "./core-entity-heuristics.mjs";
|
|
5
|
-
import { t as createClivlySDK } from "./sdk-
|
|
5
|
+
import { t as createClivlySDK } from "./sdk-DtFmeuwl.mjs";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { hostname } from "node:os";
|
|
10
|
+
import { delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
11
|
+
import { createInterface } from "node:readline/promises";
|
|
9
12
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
10
13
|
import { createJiti } from "jiti";
|
|
11
|
-
import { spawn } from "node:child_process";
|
|
12
14
|
//#region src/detect.ts
|
|
13
15
|
const FRAMEWORK_MARKERS = [
|
|
14
16
|
{
|
|
@@ -113,6 +115,154 @@ function addCommand(pm, packages) {
|
|
|
113
115
|
};
|
|
114
116
|
}
|
|
115
117
|
//#endregion
|
|
118
|
+
//#region src/dev-tunnel.ts
|
|
119
|
+
const CLOUDFLARE_QUICK_TUNNEL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
120
|
+
const DEFAULT_PORT = "3000";
|
|
121
|
+
const DEFAULT_ROUTE_PATH = "/api/clivly/tick";
|
|
122
|
+
function parseTunnelUrl(chunk) {
|
|
123
|
+
const match = chunk.match(CLOUDFLARE_QUICK_TUNNEL_REGEX);
|
|
124
|
+
return match ? match[0] : null;
|
|
125
|
+
}
|
|
126
|
+
function buildTriggerUrl(origin, path) {
|
|
127
|
+
return `${origin.endsWith("/") ? origin.slice(0, -1) : origin}${path.startsWith("/") ? path : `/${path}`}`;
|
|
128
|
+
}
|
|
129
|
+
function resolveDevTarget(flags) {
|
|
130
|
+
return {
|
|
131
|
+
target: flags.url ?? `http://localhost:${flags.port ?? DEFAULT_PORT}`,
|
|
132
|
+
triggerPath: flags.routePath ?? DEFAULT_ROUTE_PATH
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function detectDevCommand(scripts, explicit) {
|
|
136
|
+
if (explicit && explicit.length > 0) return explicit;
|
|
137
|
+
if (scripts.dev) return [
|
|
138
|
+
"npm",
|
|
139
|
+
"run",
|
|
140
|
+
"dev"
|
|
141
|
+
];
|
|
142
|
+
if (scripts.start) return [
|
|
143
|
+
"npm",
|
|
144
|
+
"run",
|
|
145
|
+
"start"
|
|
146
|
+
];
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const OVERRIDE_FAILURE_CAUSES = {
|
|
150
|
+
unauthorized: "CLIVLY_API_KEY is invalid for this workspace/backend.",
|
|
151
|
+
not_enabled: "The target backend does not have the feature-flagged trigger-url endpoint enabled (likely the wrong backend, or the flag is off).",
|
|
152
|
+
unreachable: "Could not reach Clivly at the selected API base URL."
|
|
153
|
+
};
|
|
154
|
+
function overrideFailureMessage(kind) {
|
|
155
|
+
return `Warning: could not register the tunnel URL — ${OVERRIDE_FAILURE_CAUSES[kind]} Dashboard-triggered sync is unavailable this session. Your app is still running.`;
|
|
156
|
+
}
|
|
157
|
+
function cloudflaredMissingMessage() {
|
|
158
|
+
return [
|
|
159
|
+
"cloudflared is not installed, so clivly dev cannot open a tunnel.",
|
|
160
|
+
"Remote sync is deploy-only until you install it:",
|
|
161
|
+
" brew install cloudflared (or see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)",
|
|
162
|
+
"Your app will still run locally; only dashboard-triggered sync is unavailable."
|
|
163
|
+
].join("\n");
|
|
164
|
+
}
|
|
165
|
+
//#endregion
|
|
166
|
+
//#region src/dev.ts
|
|
167
|
+
const UNREACHABLE_RESULT = {
|
|
168
|
+
ok: false,
|
|
169
|
+
kind: "unreachable"
|
|
170
|
+
};
|
|
171
|
+
function flagValue$1(argv, flag) {
|
|
172
|
+
const i = argv.indexOf(flag);
|
|
173
|
+
return i >= 0 ? argv[i + 1] : void 0;
|
|
174
|
+
}
|
|
175
|
+
function explicitCommand(argv) {
|
|
176
|
+
const sep = argv.indexOf("--");
|
|
177
|
+
return sep >= 0 ? argv.slice(sep + 1) : null;
|
|
178
|
+
}
|
|
179
|
+
async function runDev(options) {
|
|
180
|
+
const { deps } = options;
|
|
181
|
+
const { target, triggerPath } = resolveDevTarget({
|
|
182
|
+
url: flagValue$1(options.argv, "--url"),
|
|
183
|
+
port: flagValue$1(options.argv, "--port"),
|
|
184
|
+
routePath: flagValue$1(options.argv, "--route-path")
|
|
185
|
+
});
|
|
186
|
+
const command = detectDevCommand(deps.readScripts(options.cwd), explicitCommand(options.argv));
|
|
187
|
+
if (!deps.hasCloudflared()) {
|
|
188
|
+
deps.out(cloudflaredMissingMessage());
|
|
189
|
+
if (command) {
|
|
190
|
+
const app = deps.spawnApp(command);
|
|
191
|
+
deps.onSignal(() => {
|
|
192
|
+
app?.kill();
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
if (!options.apiKey) {
|
|
198
|
+
deps.out("CLIVLY_API_KEY is not set — cannot register the tunnel URL. Set it and re-run.");
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
const app = command ? deps.spawnApp(command) : null;
|
|
202
|
+
const tunnel = deps.spawnTunnel(target);
|
|
203
|
+
let shuttingDown = false;
|
|
204
|
+
tunnel.onUrl(async (origin) => {
|
|
205
|
+
if (shuttingDown) return;
|
|
206
|
+
const triggerUrl = buildTriggerUrl(origin, triggerPath);
|
|
207
|
+
deps.out(`Tunnel: ${triggerUrl}`);
|
|
208
|
+
const result = await deps.register(triggerUrl).catch(() => UNREACHABLE_RESULT);
|
|
209
|
+
if (result.ok) deps.out("Dashboard-triggered sync now points at this local session.");
|
|
210
|
+
else deps.out(overrideFailureMessage(result.kind));
|
|
211
|
+
});
|
|
212
|
+
return await new Promise((resolve) => {
|
|
213
|
+
const shutdown = async (code) => {
|
|
214
|
+
if (shuttingDown) return;
|
|
215
|
+
shuttingDown = true;
|
|
216
|
+
tunnel.kill();
|
|
217
|
+
app?.kill();
|
|
218
|
+
if (!(await deps.clear().catch(() => UNREACHABLE_RESULT)).ok) deps.out("Warning: could not clear the tunnel override on exit.");
|
|
219
|
+
resolve(code);
|
|
220
|
+
};
|
|
221
|
+
deps.onSignal(() => {
|
|
222
|
+
shutdown(0);
|
|
223
|
+
});
|
|
224
|
+
tunnel.onExit((code) => {
|
|
225
|
+
shutdown(code);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
//#endregion
|
|
230
|
+
//#region src/dev-override.ts
|
|
231
|
+
const TRIGGER_URL_PATH = "/clivly/sdk/trigger-url";
|
|
232
|
+
const HTTP_UNAUTHORIZED = 401;
|
|
233
|
+
const HTTP_NOT_FOUND = 404;
|
|
234
|
+
function kindForStatus(status) {
|
|
235
|
+
if (status === HTTP_UNAUTHORIZED) return "unauthorized";
|
|
236
|
+
if (status === HTTP_NOT_FOUND) return "not_enabled";
|
|
237
|
+
return "unreachable";
|
|
238
|
+
}
|
|
239
|
+
async function registerTunnelOverride(deps) {
|
|
240
|
+
const res = await (deps.fetchImpl ?? globalThis.fetch)(`${deps.apiUrl}${TRIGGER_URL_PATH}`, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: {
|
|
243
|
+
authorization: `Bearer ${deps.apiKey}`,
|
|
244
|
+
"content-type": "application/json"
|
|
245
|
+
},
|
|
246
|
+
body: JSON.stringify({ url: deps.triggerUrl })
|
|
247
|
+
});
|
|
248
|
+
if (res.ok) return { ok: true };
|
|
249
|
+
return {
|
|
250
|
+
ok: false,
|
|
251
|
+
kind: kindForStatus(res.status)
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
async function clearTunnelOverride(deps) {
|
|
255
|
+
const res = await (deps.fetchImpl ?? globalThis.fetch)(`${deps.apiUrl}${TRIGGER_URL_PATH}`, {
|
|
256
|
+
method: "DELETE",
|
|
257
|
+
headers: { authorization: `Bearer ${deps.apiKey}` }
|
|
258
|
+
});
|
|
259
|
+
if (res.ok) return { ok: true };
|
|
260
|
+
return {
|
|
261
|
+
ok: false,
|
|
262
|
+
kind: kindForStatus(res.status)
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
//#endregion
|
|
116
266
|
//#region src/probe-module.ts
|
|
117
267
|
async function probeModule(opts) {
|
|
118
268
|
for (const candidate of opts.candidates) {
|
|
@@ -242,6 +392,90 @@ async function discoverSchema(opts) {
|
|
|
242
392
|
};
|
|
243
393
|
}
|
|
244
394
|
//#endregion
|
|
395
|
+
//#region src/env-file.ts
|
|
396
|
+
const REGEX_METACHARACTERS = /[.*+?^${}()|[\]\\]/g;
|
|
397
|
+
function escapeForRegExp(key) {
|
|
398
|
+
return key.replace(REGEX_METACHARACTERS, "\\$&");
|
|
399
|
+
}
|
|
400
|
+
function keyLinePattern(key, suffix) {
|
|
401
|
+
return new RegExp(`^[ \\t]*${escapeForRegExp(key)}=${suffix}`, "m");
|
|
402
|
+
}
|
|
403
|
+
function hasKey$1(content, key) {
|
|
404
|
+
return keyLinePattern(key, "").test(content);
|
|
405
|
+
}
|
|
406
|
+
function findKeyLine(content, key) {
|
|
407
|
+
const match = keyLinePattern(key, "(.*)$").exec(content);
|
|
408
|
+
if (!match) return null;
|
|
409
|
+
return {
|
|
410
|
+
start: match.index,
|
|
411
|
+
end: match.index + match[0].length,
|
|
412
|
+
trimmedValue: (match[1] ?? "").trim()
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Whether `key` carries a real value in `contents`. The `KEY=` placeholder
|
|
417
|
+
* `appendEnvKeys` scaffolds on `clivly init` counts as NOT set — the same rule
|
|
418
|
+
* `upsertEnvValues` applies, shared so there is only one notion of it.
|
|
419
|
+
*/
|
|
420
|
+
function isKeySet(contents, key) {
|
|
421
|
+
const found = findKeyLine(contents, key);
|
|
422
|
+
return found !== null && found.trimmedValue !== "";
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Append only the `entries` whose key is absent, each preceded by its comment.
|
|
426
|
+
* Existing keys/values are left exactly as-is. Returns the full new content.
|
|
427
|
+
*/
|
|
428
|
+
function appendEnvKeys(existing, entries) {
|
|
429
|
+
const missing = entries.filter((e) => !hasKey$1(existing, e.key));
|
|
430
|
+
if (missing.length === 0) return existing;
|
|
431
|
+
const block = missing.map((e) => `${e.comment ? `# ${e.comment}\n` : ""}${e.key}=`).join("\n");
|
|
432
|
+
return `${existing}${existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""}${existing.length > 0 ? "\n" : ""}# Clivly\n${block}\n`;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Write each entry's value into the file: in place when the key already has
|
|
436
|
+
* a line, appended (with its comment) when it doesn't.
|
|
437
|
+
*
|
|
438
|
+
* A key whose existing line already has a real value is left untouched
|
|
439
|
+
* unless `force` is set — that's a deliberate choice the developer made.
|
|
440
|
+
* A key whose existing line is empty (e.g. `KEY=`, the placeholder
|
|
441
|
+
* `appendEnvKeys` scaffolds on `clivly init`) counts as ABSENT and is always
|
|
442
|
+
* filled in, `force` or not: `clivly login` run right after `clivly init` —
|
|
443
|
+
* the single most common sequence — must not be a silent no-op just because
|
|
444
|
+
* init already declared the key with nothing in it.
|
|
445
|
+
*/
|
|
446
|
+
function upsertEnvValues(existing, entries, options = {}) {
|
|
447
|
+
const force = options.force ?? false;
|
|
448
|
+
let contents = existing;
|
|
449
|
+
const written = [];
|
|
450
|
+
const skipped = [];
|
|
451
|
+
const toAppend = [];
|
|
452
|
+
for (const entry of entries) {
|
|
453
|
+
const found = findKeyLine(contents, entry.key);
|
|
454
|
+
if (!found) {
|
|
455
|
+
toAppend.push(entry);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (found.trimmedValue !== "" && !force) {
|
|
459
|
+
skipped.push(entry.key);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
contents = contents.slice(0, found.start) + `${entry.key}=${entry.value}` + contents.slice(found.end);
|
|
463
|
+
written.push(entry.key);
|
|
464
|
+
}
|
|
465
|
+
if (toAppend.length > 0) {
|
|
466
|
+
const block = toAppend.map((e) => `${e.comment ? `# ${e.comment}\n` : ""}${e.key}=${e.value}`).join("\n");
|
|
467
|
+
const prefix = contents.length > 0 && !contents.endsWith("\n") ? "\n" : "";
|
|
468
|
+
const lead = contents.length > 0 ? "\n" : "";
|
|
469
|
+
contents = `${contents}${prefix}${lead}# Clivly\n${block}\n`;
|
|
470
|
+
written.push(...toAppend.map((e) => e.key));
|
|
471
|
+
}
|
|
472
|
+
return {
|
|
473
|
+
contents,
|
|
474
|
+
skipped,
|
|
475
|
+
written
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
//#endregion
|
|
245
479
|
//#region src/install.ts
|
|
246
480
|
const defaultRun = (command, args, cwd) => new Promise((resolvePromise, reject) => {
|
|
247
481
|
const child = spawn(command, args, {
|
|
@@ -298,6 +532,141 @@ function isEntrypoint(argv1, moduleUrl, realpath = realpathSync) {
|
|
|
298
532
|
return resolve(argv1) === resolve(modulePath);
|
|
299
533
|
}
|
|
300
534
|
//#endregion
|
|
535
|
+
//#region src/login.ts
|
|
536
|
+
const POLL_INTERVAL_MS = 2e3;
|
|
537
|
+
const POLL_BACKOFF_MULTIPLIER = 1.5;
|
|
538
|
+
const POLL_INTERVAL_MAX_MS = 1e4;
|
|
539
|
+
const POLL_SERVER_ERROR_MIN = 500;
|
|
540
|
+
const MAX_POLL_ATTEMPTS = 1e3;
|
|
541
|
+
const PAIRING_CODE_HEADER = "x-clivly-pairing-code";
|
|
542
|
+
const MASK_VISIBLE_CHARS = 4;
|
|
543
|
+
const MASK_PREFIX = "••••";
|
|
544
|
+
function maskSecret(value) {
|
|
545
|
+
if (value.length <= MASK_VISIBLE_CHARS) return MASK_PREFIX;
|
|
546
|
+
return `${MASK_PREFIX}${value.slice(-MASK_VISIBLE_CHARS)}`;
|
|
547
|
+
}
|
|
548
|
+
async function createPairingSession(apiUrl, deps, rotate) {
|
|
549
|
+
const res = await deps.fetchImpl(`${apiUrl}/clivly/cli/pair`, {
|
|
550
|
+
method: "POST",
|
|
551
|
+
headers: { "content-type": "application/json" },
|
|
552
|
+
body: JSON.stringify({
|
|
553
|
+
machineName: deps.machineName(),
|
|
554
|
+
rotate
|
|
555
|
+
})
|
|
556
|
+
});
|
|
557
|
+
if (res.status === 404) {
|
|
558
|
+
deps.out("clivly login isn't available yet on this server — CLI pairing is not enabled. Ask whoever runs your Clivly backend to enable it, or set CLIVLY_API_KEY / CLIVLY_SYNC_TRIGGER_SECRET manually.");
|
|
559
|
+
return { exitCode: 1 };
|
|
560
|
+
}
|
|
561
|
+
if (!res.ok) {
|
|
562
|
+
deps.out(`Could not start login: server returned ${res.status}.`);
|
|
563
|
+
return { exitCode: 1 };
|
|
564
|
+
}
|
|
565
|
+
return await res.json();
|
|
566
|
+
}
|
|
567
|
+
function pollErrorMessage(status, message) {
|
|
568
|
+
if (status === 404) return "Pairing session not found. Run `clivly login` again.";
|
|
569
|
+
if (status === 401) return "Pairing code was rejected. Run `clivly login` again.";
|
|
570
|
+
if (status === 400) return message ? `Pairing session can't be used: ${message}` : "Pairing session can't be used. Run `clivly login` again.";
|
|
571
|
+
return `Could not check login status: server returned ${status}.`;
|
|
572
|
+
}
|
|
573
|
+
async function pollForApproval(apiUrl, deps, session) {
|
|
574
|
+
const deadline = Date.parse(session.expiresAt);
|
|
575
|
+
let interval = POLL_INTERVAL_MS;
|
|
576
|
+
let warnedTransient = false;
|
|
577
|
+
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) {
|
|
578
|
+
if (Number.isFinite(deadline) && Date.now() >= deadline) {
|
|
579
|
+
deps.out("Pairing session expired before it was approved. Run `clivly login` again.");
|
|
580
|
+
return { exitCode: 1 };
|
|
581
|
+
}
|
|
582
|
+
const res = await deps.fetchImpl(`${apiUrl}/clivly/cli/pair/${session.sessionId}`, { headers: { [PAIRING_CODE_HEADER]: session.code } });
|
|
583
|
+
if (!res.ok) {
|
|
584
|
+
if (res.status >= POLL_SERVER_ERROR_MIN) {
|
|
585
|
+
if (!warnedTransient) {
|
|
586
|
+
deps.out("Server hit an error finishing login — retrying (your approval is still valid)…");
|
|
587
|
+
warnedTransient = true;
|
|
588
|
+
}
|
|
589
|
+
await deps.sleep(interval);
|
|
590
|
+
interval = Math.min(interval * POLL_BACKOFF_MULTIPLIER, POLL_INTERVAL_MAX_MS);
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
const body = await res.json().catch(() => ({}));
|
|
594
|
+
deps.out(pollErrorMessage(res.status, body.message));
|
|
595
|
+
return { exitCode: 1 };
|
|
596
|
+
}
|
|
597
|
+
const body = await res.json();
|
|
598
|
+
if (body.status === "approved") return {
|
|
599
|
+
apiKey: body.apiKey,
|
|
600
|
+
organizationName: body.organizationName,
|
|
601
|
+
organizationSlug: body.organizationSlug,
|
|
602
|
+
triggerSecret: body.triggerSecret
|
|
603
|
+
};
|
|
604
|
+
await deps.sleep(interval);
|
|
605
|
+
interval = Math.min(interval * POLL_BACKOFF_MULTIPLIER, POLL_INTERVAL_MAX_MS);
|
|
606
|
+
}
|
|
607
|
+
deps.out("Timed out waiting for approval. Run `clivly login` again.");
|
|
608
|
+
return { exitCode: 1 };
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Say which workspace approved this pairing, and get the developer's
|
|
612
|
+
* agreement before writing anything.
|
|
613
|
+
*
|
|
614
|
+
* Approving is not bound to the requester: whoever holds a pending session id
|
|
615
|
+
* and integration-manager in *some* workspace can approve it into that
|
|
616
|
+
* workspace, and the polling CLI only verifies the pairing code — which it
|
|
617
|
+
* holds — so it would happily write a stranger's API key and trigger secret
|
|
618
|
+
* into this project's `.env`, and the host app would then sync its customer
|
|
619
|
+
* data there. Naming the workspace is what makes that visible.
|
|
620
|
+
*
|
|
621
|
+
* A non-interactive run (CI, piped stdin) still gets the name printed but is
|
|
622
|
+
* not prompted: a scripted `clivly login` must not hang waiting for a key.
|
|
623
|
+
*/
|
|
624
|
+
async function confirmWorkspace(deps, credentials) {
|
|
625
|
+
deps.out(`\nPaired with workspace ${credentials.organizationName} (${credentials.organizationSlug}).`);
|
|
626
|
+
if (!deps.isInteractive()) return true;
|
|
627
|
+
return await deps.confirm("Write this workspace's credentials to .env? [y/N] ");
|
|
628
|
+
}
|
|
629
|
+
function credentialEntries(credentials) {
|
|
630
|
+
return [{
|
|
631
|
+
key: "CLIVLY_API_KEY",
|
|
632
|
+
value: credentials.apiKey,
|
|
633
|
+
comment: "Org API key — Settings → API keys"
|
|
634
|
+
}, {
|
|
635
|
+
key: "CLIVLY_SYNC_TRIGGER_SECRET",
|
|
636
|
+
value: credentials.triggerSecret,
|
|
637
|
+
comment: "Signing secret — Integrations → Remote sync"
|
|
638
|
+
}];
|
|
639
|
+
}
|
|
640
|
+
function writeCredentials(options, credentials) {
|
|
641
|
+
const { deps } = options;
|
|
642
|
+
const entries = credentialEntries(credentials);
|
|
643
|
+
const result = upsertEnvValues(deps.readEnv(options.cwd), entries, { force: options.force });
|
|
644
|
+
deps.writeEnv(options.cwd, result.contents);
|
|
645
|
+
deps.out("\nLogin successful.\n");
|
|
646
|
+
const valuesByKey = {
|
|
647
|
+
CLIVLY_API_KEY: credentials.apiKey,
|
|
648
|
+
CLIVLY_SYNC_TRIGGER_SECRET: credentials.triggerSecret
|
|
649
|
+
};
|
|
650
|
+
for (const entry of entries) if (result.skipped.includes(entry.key)) deps.out(` • ${entry.key} already set — skipped (pass --force to overwrite)`);
|
|
651
|
+
else deps.out(` ✓ ${entry.key}=${maskSecret(valuesByKey[entry.key] ?? "")} written to .env`);
|
|
652
|
+
}
|
|
653
|
+
async function runLogin(options) {
|
|
654
|
+
const { deps } = options;
|
|
655
|
+
const session = await createPairingSession(options.apiUrl, deps, options.rotate);
|
|
656
|
+
if ("exitCode" in session) return session.exitCode;
|
|
657
|
+
deps.out(`\nApprove this login in your browser:\n ${session.approveUrl}\n`);
|
|
658
|
+
if (options.openBrowser) deps.open(session.approveUrl);
|
|
659
|
+
deps.out("Waiting for approval…");
|
|
660
|
+
const outcome = await pollForApproval(options.apiUrl, deps, session);
|
|
661
|
+
if ("exitCode" in outcome) return outcome.exitCode;
|
|
662
|
+
if (!await confirmWorkspace(deps, outcome)) {
|
|
663
|
+
deps.out("Login cancelled — nothing was written to .env.");
|
|
664
|
+
return 1;
|
|
665
|
+
}
|
|
666
|
+
writeCredentials(options, outcome);
|
|
667
|
+
return 0;
|
|
668
|
+
}
|
|
669
|
+
//#endregion
|
|
301
670
|
//#region src/plan-init.ts
|
|
302
671
|
function toScoredTable(table) {
|
|
303
672
|
return {
|
|
@@ -412,21 +781,6 @@ function planInit(schema) {
|
|
|
412
781
|
};
|
|
413
782
|
}
|
|
414
783
|
//#endregion
|
|
415
|
-
//#region src/env-file.ts
|
|
416
|
-
function hasKey$1(content, key) {
|
|
417
|
-
return new RegExp(`^\\s*${key}=`, "m").test(content);
|
|
418
|
-
}
|
|
419
|
-
/**
|
|
420
|
-
* Append only the `entries` whose key is absent, each preceded by its comment.
|
|
421
|
-
* Existing keys/values are left exactly as-is. Returns the full new content.
|
|
422
|
-
*/
|
|
423
|
-
function appendEnvKeys(existing, entries) {
|
|
424
|
-
const missing = entries.filter((e) => !hasKey$1(existing, e.key));
|
|
425
|
-
if (missing.length === 0) return existing;
|
|
426
|
-
const block = missing.map((e) => `${e.comment ? `# ${e.comment}\n` : ""}${e.key}=`).join("\n");
|
|
427
|
-
return `${existing}${existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""}${existing.length > 0 ? "\n" : ""}# Clivly\n${block}\n`;
|
|
428
|
-
}
|
|
429
|
-
//#endregion
|
|
430
784
|
//#region src/render-config.ts
|
|
431
785
|
const CLIVLY_ENV_KEYS = [
|
|
432
786
|
{
|
|
@@ -861,6 +1215,14 @@ const COMMON_PATHS = [
|
|
|
861
1215
|
"src/db/schema",
|
|
862
1216
|
"db/schema.ts",
|
|
863
1217
|
"db/schema",
|
|
1218
|
+
"src/lib/db/schema.ts",
|
|
1219
|
+
"src/lib/db/schema",
|
|
1220
|
+
"lib/db/schema.ts",
|
|
1221
|
+
"lib/db/schema",
|
|
1222
|
+
"src/lib/schema.ts",
|
|
1223
|
+
"src/lib/schema",
|
|
1224
|
+
"lib/schema.ts",
|
|
1225
|
+
"lib/schema",
|
|
864
1226
|
"src/schema.ts",
|
|
865
1227
|
"src/schema",
|
|
866
1228
|
"drizzle/schema.ts",
|
|
@@ -1010,6 +1372,7 @@ const configStage = {
|
|
|
1010
1372
|
}
|
|
1011
1373
|
}
|
|
1012
1374
|
};
|
|
1375
|
+
const LADDER_OWNED_CHECKS = new Set(["API key present", "Heartbeat"]);
|
|
1013
1376
|
/** The full ladder, in the order a developer completes setup. */
|
|
1014
1377
|
const STATUS_STAGES = [
|
|
1015
1378
|
apiKeyStage,
|
|
@@ -1020,13 +1383,13 @@ const STATUS_STAGES = [
|
|
|
1020
1383
|
label: "Setup checks",
|
|
1021
1384
|
needs: [configStage.id],
|
|
1022
1385
|
async run(ctx) {
|
|
1023
|
-
const
|
|
1024
|
-
const counted =
|
|
1386
|
+
const owned = (await ctx.sdk.doctor()).checks.filter((check) => !LADDER_OWNED_CHECKS.has(check.name));
|
|
1387
|
+
const counted = owned.filter((check) => !check.skipped);
|
|
1025
1388
|
const passed = counted.filter((check) => check.ok).length;
|
|
1026
1389
|
return {
|
|
1027
|
-
state:
|
|
1390
|
+
state: counted.every((check) => check.ok) ? "pass" : "fail",
|
|
1028
1391
|
detail: `${passed} of ${counted.length} passed`,
|
|
1029
|
-
sub:
|
|
1392
|
+
sub: owned.map((check) => ({
|
|
1030
1393
|
label: check.name,
|
|
1031
1394
|
ok: check.ok,
|
|
1032
1395
|
skipped: check.skipped,
|
|
@@ -1148,6 +1511,9 @@ function defaultReadEnv(cwd) {
|
|
|
1148
1511
|
function defaultWriteEnv(cwd, contents) {
|
|
1149
1512
|
writeFileSync(join(cwd, ".env"), contents, "utf8");
|
|
1150
1513
|
}
|
|
1514
|
+
function defaultIsInteractive() {
|
|
1515
|
+
return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY) && !process.env.CI;
|
|
1516
|
+
}
|
|
1151
1517
|
function resolveInitDeps(options) {
|
|
1152
1518
|
return {
|
|
1153
1519
|
out: options.out ?? ((line) => process.stdout.write(line)),
|
|
@@ -1171,6 +1537,15 @@ function resolveInitDeps(options) {
|
|
|
1171
1537
|
readEnv: options.readEnv ?? defaultReadEnv,
|
|
1172
1538
|
readOwnVersion: options.readOwnVersion ?? readOwnVersion,
|
|
1173
1539
|
writeEnv: options.writeEnv ?? defaultWriteEnv,
|
|
1540
|
+
isInteractive: options.isInteractive ?? defaultIsInteractive,
|
|
1541
|
+
login: options.login ?? ((cwd) => runLogin({
|
|
1542
|
+
apiUrl: process.env.CLIVLY_API_URL ?? "https://api.clivly.com",
|
|
1543
|
+
cwd,
|
|
1544
|
+
deps: defaultLoginDeps(),
|
|
1545
|
+
force: false,
|
|
1546
|
+
openBrowser: options.openBrowser ?? true,
|
|
1547
|
+
rotate: false
|
|
1548
|
+
})),
|
|
1174
1549
|
framework: "unknown",
|
|
1175
1550
|
authWiring: renderAuthWiring("unknown")
|
|
1176
1551
|
};
|
|
@@ -1276,14 +1651,23 @@ function previewMutations(deps, mutations) {
|
|
|
1276
1651
|
}
|
|
1277
1652
|
out("\nRe-run without --dry-run to apply.\n");
|
|
1278
1653
|
}
|
|
1279
|
-
function printNextSteps(out, authComplete) {
|
|
1654
|
+
function printNextSteps(out, authComplete, loginRan) {
|
|
1280
1655
|
out("\nNext steps:\n");
|
|
1281
|
-
out(" 1. Set the CLIVLY_* values in .env\n");
|
|
1656
|
+
out(loginRan ? " 1. Credentials written by `clivly login`\n" : " 1. Set the CLIVLY_* values in .env\n");
|
|
1282
1657
|
out(" 2. Set CLIVLY_SYNC_TRIGGER_URL to your public tick route\n");
|
|
1658
|
+
out(" Local dev? Run `clivly dev` to open a tunnel, or skip this step until you deploy (remote sync is deploy-only locally).\n");
|
|
1283
1659
|
const step = authComplete ? 3 : 4;
|
|
1284
1660
|
if (!authComplete) out(" 3. Finish resolveUser in the auth/verify route\n");
|
|
1285
1661
|
out(` ${step}. Run \`clivly status\` to see where you are\n`);
|
|
1286
1662
|
}
|
|
1663
|
+
function credentialsMissing(envContents) {
|
|
1664
|
+
return !isKeySet(envContents, "CLIVLY_API_KEY");
|
|
1665
|
+
}
|
|
1666
|
+
function pairingSkipReason(options, deps) {
|
|
1667
|
+
if (options.noLogin) return "--no-login";
|
|
1668
|
+
if (!deps.isInteractive()) return "non-interactive shell";
|
|
1669
|
+
return null;
|
|
1670
|
+
}
|
|
1287
1671
|
async function scaffold(options, deps, plan) {
|
|
1288
1672
|
const { out } = deps;
|
|
1289
1673
|
const pm = deps.detectPackageManager(options.cwd);
|
|
@@ -1303,7 +1687,15 @@ async function scaffold(options, deps, plan) {
|
|
|
1303
1687
|
if (result.ok) out(`Installed clivly (${pm})\n`);
|
|
1304
1688
|
else out(`Could not install automatically — run:\n ${result.command}\n`);
|
|
1305
1689
|
applyMutations(options, deps, mutations);
|
|
1306
|
-
|
|
1690
|
+
let loginRan = false;
|
|
1691
|
+
if (credentialsMissing(mutations.env.contents)) {
|
|
1692
|
+
const skipReason = pairingSkipReason(options, deps);
|
|
1693
|
+
if (skipReason) {
|
|
1694
|
+
out(`\nSkipped browser pairing (${skipReason}).\n`);
|
|
1695
|
+
out(" Run `clivly login` from a terminal to write CLIVLY_API_KEY and CLIVLY_SYNC_TRIGGER_SECRET.\n");
|
|
1696
|
+
} else loginRan = await deps.login(options.cwd) === 0;
|
|
1697
|
+
}
|
|
1698
|
+
printNextSteps(out, deps.authWiring.complete, loginRan);
|
|
1307
1699
|
return plan.contactDetected ? 0 : 1;
|
|
1308
1700
|
}
|
|
1309
1701
|
async function runInit(options) {
|
|
@@ -1314,15 +1706,173 @@ async function runInit(options) {
|
|
|
1314
1706
|
printPlan(deps.out, plan);
|
|
1315
1707
|
return scaffold(options, deps, plan);
|
|
1316
1708
|
}
|
|
1709
|
+
function hasCloudflaredOnPath() {
|
|
1710
|
+
const pathEnv = process.env.PATH ?? "";
|
|
1711
|
+
const exeName = process.platform === "win32" ? "cloudflared.exe" : "cloudflared";
|
|
1712
|
+
return pathEnv.split(delimiter).some((dir) => {
|
|
1713
|
+
if (!dir) return false;
|
|
1714
|
+
try {
|
|
1715
|
+
accessSync(join(dir, exeName), constants.X_OK);
|
|
1716
|
+
return true;
|
|
1717
|
+
} catch {
|
|
1718
|
+
return false;
|
|
1719
|
+
}
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
function readPackageScripts(cwd) {
|
|
1723
|
+
const pkgPath = join(cwd, "package.json");
|
|
1724
|
+
if (!existsSync(pkgPath)) return {};
|
|
1725
|
+
try {
|
|
1726
|
+
return JSON.parse(readFileSync(pkgPath, "utf8")).scripts ?? {};
|
|
1727
|
+
} catch {
|
|
1728
|
+
return {};
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
function spawnAppProcess(command) {
|
|
1732
|
+
const [cmd, ...args] = command;
|
|
1733
|
+
if (!cmd) return null;
|
|
1734
|
+
const child = spawn(cmd, args, { stdio: "inherit" });
|
|
1735
|
+
child.on("error", (err) => {
|
|
1736
|
+
process.stderr.write(`Could not start "${command.join(" ")}": ${err.message}\n`);
|
|
1737
|
+
});
|
|
1738
|
+
return { kill: () => child.kill() };
|
|
1739
|
+
}
|
|
1740
|
+
function spawnCloudflaredTunnel(target) {
|
|
1741
|
+
const child = spawn("cloudflared", [
|
|
1742
|
+
"tunnel",
|
|
1743
|
+
"--url",
|
|
1744
|
+
target
|
|
1745
|
+
], { stdio: [
|
|
1746
|
+
"ignore",
|
|
1747
|
+
"pipe",
|
|
1748
|
+
"pipe"
|
|
1749
|
+
] });
|
|
1750
|
+
let urlCb = () => {};
|
|
1751
|
+
let exitCb = () => {};
|
|
1752
|
+
let urlFound = false;
|
|
1753
|
+
const onChunk = (chunk) => {
|
|
1754
|
+
if (urlFound) return;
|
|
1755
|
+
const url = parseTunnelUrl(chunk.toString("utf8"));
|
|
1756
|
+
if (url) {
|
|
1757
|
+
urlFound = true;
|
|
1758
|
+
urlCb(url);
|
|
1759
|
+
}
|
|
1760
|
+
};
|
|
1761
|
+
child.stdout?.on("data", onChunk);
|
|
1762
|
+
child.stderr?.on("data", onChunk);
|
|
1763
|
+
child.on("exit", (code) => {
|
|
1764
|
+
exitCb(code ?? 0);
|
|
1765
|
+
});
|
|
1766
|
+
child.on("error", (err) => {
|
|
1767
|
+
process.stderr.write(`Could not start cloudflared: ${err.message}\n`);
|
|
1768
|
+
});
|
|
1769
|
+
return {
|
|
1770
|
+
onUrl: (cb) => {
|
|
1771
|
+
urlCb = cb;
|
|
1772
|
+
},
|
|
1773
|
+
onExit: (cb) => {
|
|
1774
|
+
exitCb = cb;
|
|
1775
|
+
},
|
|
1776
|
+
kill: () => {
|
|
1777
|
+
child.kill();
|
|
1778
|
+
}
|
|
1779
|
+
};
|
|
1780
|
+
}
|
|
1781
|
+
function defaultDevDeps(options) {
|
|
1782
|
+
return {
|
|
1783
|
+
hasCloudflared: hasCloudflaredOnPath,
|
|
1784
|
+
spawnTunnel: spawnCloudflaredTunnel,
|
|
1785
|
+
spawnApp: spawnAppProcess,
|
|
1786
|
+
readScripts: readPackageScripts,
|
|
1787
|
+
register: (triggerUrl) => registerTunnelOverride({
|
|
1788
|
+
apiKey: options.apiKey ?? "",
|
|
1789
|
+
apiUrl: options.apiUrl,
|
|
1790
|
+
triggerUrl
|
|
1791
|
+
}),
|
|
1792
|
+
clear: () => clearTunnelOverride({
|
|
1793
|
+
apiKey: options.apiKey ?? "",
|
|
1794
|
+
apiUrl: options.apiUrl
|
|
1795
|
+
}),
|
|
1796
|
+
onSignal: (handler) => {
|
|
1797
|
+
process.on("SIGINT", handler);
|
|
1798
|
+
process.on("SIGTERM", handler);
|
|
1799
|
+
},
|
|
1800
|
+
out: (line) => {
|
|
1801
|
+
process.stdout.write(`${line}\n`);
|
|
1802
|
+
}
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
function openerCommand() {
|
|
1806
|
+
if (process.platform === "darwin") return {
|
|
1807
|
+
command: "open",
|
|
1808
|
+
args: []
|
|
1809
|
+
};
|
|
1810
|
+
if (process.platform === "win32") return {
|
|
1811
|
+
command: "cmd",
|
|
1812
|
+
args: [
|
|
1813
|
+
"/c",
|
|
1814
|
+
"start",
|
|
1815
|
+
""
|
|
1816
|
+
]
|
|
1817
|
+
};
|
|
1818
|
+
return {
|
|
1819
|
+
command: "xdg-open",
|
|
1820
|
+
args: []
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
function openInBrowser(url) {
|
|
1824
|
+
const { command, args } = openerCommand();
|
|
1825
|
+
try {
|
|
1826
|
+
const child = spawn(command, [...args, url], {
|
|
1827
|
+
stdio: "ignore",
|
|
1828
|
+
detached: true
|
|
1829
|
+
});
|
|
1830
|
+
child.on("error", () => {});
|
|
1831
|
+
child.unref();
|
|
1832
|
+
} catch {}
|
|
1833
|
+
}
|
|
1834
|
+
function promptYesNo(question) {
|
|
1835
|
+
const rl = createInterface({
|
|
1836
|
+
input: process.stdin,
|
|
1837
|
+
output: process.stdout
|
|
1838
|
+
});
|
|
1839
|
+
return rl.question(question).then((answer) => {
|
|
1840
|
+
rl.close();
|
|
1841
|
+
const normalized = answer.trim().toLowerCase();
|
|
1842
|
+
return normalized === "y" || normalized === "yes";
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
function defaultLoginDeps() {
|
|
1846
|
+
return {
|
|
1847
|
+
confirm: promptYesNo,
|
|
1848
|
+
fetchImpl: globalThis.fetch,
|
|
1849
|
+
isInteractive: defaultIsInteractive,
|
|
1850
|
+
machineName: hostname,
|
|
1851
|
+
open: openInBrowser,
|
|
1852
|
+
out: (line) => {
|
|
1853
|
+
process.stdout.write(`${line}\n`);
|
|
1854
|
+
},
|
|
1855
|
+
readEnv: defaultReadEnv,
|
|
1856
|
+
sleep: (ms) => new Promise((resolve) => {
|
|
1857
|
+
setTimeout(resolve, ms);
|
|
1858
|
+
}),
|
|
1859
|
+
writeEnv: defaultWriteEnv
|
|
1860
|
+
};
|
|
1861
|
+
}
|
|
1862
|
+
function runInitFromArgv(argv, cwd) {
|
|
1863
|
+
return runInit({
|
|
1864
|
+
cwd,
|
|
1865
|
+
force: argv.includes("--force"),
|
|
1866
|
+
dryRun: argv.includes("--dry-run"),
|
|
1867
|
+
noLogin: argv.includes("--no-login"),
|
|
1868
|
+
openBrowser: !argv.includes("--no-browser"),
|
|
1869
|
+
schemaFlag: flagValue(argv, "--schema")
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1317
1872
|
async function main(argv) {
|
|
1318
1873
|
const [command, ...rest] = argv;
|
|
1319
1874
|
switch (command) {
|
|
1320
|
-
case "init": return await
|
|
1321
|
-
cwd: process.cwd(),
|
|
1322
|
-
force: rest.includes("--force"),
|
|
1323
|
-
dryRun: rest.includes("--dry-run"),
|
|
1324
|
-
schemaFlag: flagValue(rest, "--schema")
|
|
1325
|
-
});
|
|
1875
|
+
case "init": return await runInitFromArgv(rest, process.cwd());
|
|
1326
1876
|
case "push-schema": return await runPushSchema({
|
|
1327
1877
|
cwd: process.cwd(),
|
|
1328
1878
|
configPath: flagValue(rest, "--config")
|
|
@@ -1338,10 +1888,32 @@ async function main(argv) {
|
|
|
1338
1888
|
only: ALIAS_STAGE_IDS[command],
|
|
1339
1889
|
verbose: rest.includes("--verbose")
|
|
1340
1890
|
});
|
|
1891
|
+
case "dev": {
|
|
1892
|
+
const apiKey = process.env.CLIVLY_API_KEY;
|
|
1893
|
+
const apiUrl = flagValue(rest, "--api-url") ?? process.env.CLIVLY_API_URL ?? "https://api.clivly.com";
|
|
1894
|
+
return await runDev({
|
|
1895
|
+
cwd: process.cwd(),
|
|
1896
|
+
argv: rest,
|
|
1897
|
+
apiKey,
|
|
1898
|
+
apiUrl,
|
|
1899
|
+
deps: defaultDevDeps({
|
|
1900
|
+
apiKey,
|
|
1901
|
+
apiUrl
|
|
1902
|
+
})
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1905
|
+
case "login": return await runLogin({
|
|
1906
|
+
cwd: process.cwd(),
|
|
1907
|
+
apiUrl: flagValue(rest, "--api-url") ?? process.env.CLIVLY_API_URL ?? "https://api.clivly.com",
|
|
1908
|
+
force: rest.includes("--force"),
|
|
1909
|
+
rotate: rest.includes("--rotate"),
|
|
1910
|
+
openBrowser: !rest.includes("--no-browser"),
|
|
1911
|
+
deps: defaultLoginDeps()
|
|
1912
|
+
});
|
|
1341
1913
|
case void 0:
|
|
1342
1914
|
case "help":
|
|
1343
1915
|
case "--help":
|
|
1344
|
-
process.stdout.write("clivly — embed a CRM in your app\n\nUsage:\n clivly init [--force] [--dry-run] [--schema <path>] Scaffold Clivly (config, routes, .env) for your app\n clivly status [--verbose] [--config <path>] Check your setup, step by step\n clivly push-schema [--config <path>] Report your schema to Clivly without deploying\n");
|
|
1916
|
+
process.stdout.write("clivly — embed a CRM in your app\n\nUsage:\n clivly init [--force] [--dry-run] [--schema <path>] [--no-login] [--no-browser] Scaffold Clivly (config, routes, .env) for your app\n clivly status [--verbose] [--config <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_API_KEY / CLIVLY_SYNC_TRIGGER_SECRET to .env\n");
|
|
1345
1917
|
return 0;
|
|
1346
1918
|
default:
|
|
1347
1919
|
process.stdout.write(`Unknown command: ${command}\n`);
|
|
@@ -1352,4 +1924,4 @@ if (isEntrypoint(process.argv[1], import.meta.url)) main(process.argv.slice(2)).
|
|
|
1352
1924
|
process.exitCode = code;
|
|
1353
1925
|
});
|
|
1354
1926
|
//#endregion
|
|
1355
|
-
export { runInit };
|
|
1927
|
+
export { runInit, runInitFromArgv };
|