clivly 0.3.0-next.2 → 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 +596 -31
- package/index.d.cts +6 -1
- package/index.d.mts +6 -1
- package/index.mjs +598 -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
|
{
|
|
@@ -1018,6 +1372,7 @@ const configStage = {
|
|
|
1018
1372
|
}
|
|
1019
1373
|
}
|
|
1020
1374
|
};
|
|
1375
|
+
const LADDER_OWNED_CHECKS = new Set(["API key present", "Heartbeat"]);
|
|
1021
1376
|
/** The full ladder, in the order a developer completes setup. */
|
|
1022
1377
|
const STATUS_STAGES = [
|
|
1023
1378
|
apiKeyStage,
|
|
@@ -1028,13 +1383,13 @@ const STATUS_STAGES = [
|
|
|
1028
1383
|
label: "Setup checks",
|
|
1029
1384
|
needs: [configStage.id],
|
|
1030
1385
|
async run(ctx) {
|
|
1031
|
-
const
|
|
1032
|
-
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);
|
|
1033
1388
|
const passed = counted.filter((check) => check.ok).length;
|
|
1034
1389
|
return {
|
|
1035
|
-
state:
|
|
1390
|
+
state: counted.every((check) => check.ok) ? "pass" : "fail",
|
|
1036
1391
|
detail: `${passed} of ${counted.length} passed`,
|
|
1037
|
-
sub:
|
|
1392
|
+
sub: owned.map((check) => ({
|
|
1038
1393
|
label: check.name,
|
|
1039
1394
|
ok: check.ok,
|
|
1040
1395
|
skipped: check.skipped,
|
|
@@ -1156,6 +1511,9 @@ function defaultReadEnv(cwd) {
|
|
|
1156
1511
|
function defaultWriteEnv(cwd, contents) {
|
|
1157
1512
|
writeFileSync(join(cwd, ".env"), contents, "utf8");
|
|
1158
1513
|
}
|
|
1514
|
+
function defaultIsInteractive() {
|
|
1515
|
+
return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY) && !process.env.CI;
|
|
1516
|
+
}
|
|
1159
1517
|
function resolveInitDeps(options) {
|
|
1160
1518
|
return {
|
|
1161
1519
|
out: options.out ?? ((line) => process.stdout.write(line)),
|
|
@@ -1179,6 +1537,15 @@ function resolveInitDeps(options) {
|
|
|
1179
1537
|
readEnv: options.readEnv ?? defaultReadEnv,
|
|
1180
1538
|
readOwnVersion: options.readOwnVersion ?? readOwnVersion,
|
|
1181
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
|
+
})),
|
|
1182
1549
|
framework: "unknown",
|
|
1183
1550
|
authWiring: renderAuthWiring("unknown")
|
|
1184
1551
|
};
|
|
@@ -1284,14 +1651,23 @@ function previewMutations(deps, mutations) {
|
|
|
1284
1651
|
}
|
|
1285
1652
|
out("\nRe-run without --dry-run to apply.\n");
|
|
1286
1653
|
}
|
|
1287
|
-
function printNextSteps(out, authComplete) {
|
|
1654
|
+
function printNextSteps(out, authComplete, loginRan) {
|
|
1288
1655
|
out("\nNext steps:\n");
|
|
1289
|
-
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");
|
|
1290
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");
|
|
1291
1659
|
const step = authComplete ? 3 : 4;
|
|
1292
1660
|
if (!authComplete) out(" 3. Finish resolveUser in the auth/verify route\n");
|
|
1293
1661
|
out(` ${step}. Run \`clivly status\` to see where you are\n`);
|
|
1294
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
|
+
}
|
|
1295
1671
|
async function scaffold(options, deps, plan) {
|
|
1296
1672
|
const { out } = deps;
|
|
1297
1673
|
const pm = deps.detectPackageManager(options.cwd);
|
|
@@ -1311,7 +1687,15 @@ async function scaffold(options, deps, plan) {
|
|
|
1311
1687
|
if (result.ok) out(`Installed clivly (${pm})\n`);
|
|
1312
1688
|
else out(`Could not install automatically — run:\n ${result.command}\n`);
|
|
1313
1689
|
applyMutations(options, deps, mutations);
|
|
1314
|
-
|
|
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);
|
|
1315
1699
|
return plan.contactDetected ? 0 : 1;
|
|
1316
1700
|
}
|
|
1317
1701
|
async function runInit(options) {
|
|
@@ -1322,15 +1706,173 @@ async function runInit(options) {
|
|
|
1322
1706
|
printPlan(deps.out, plan);
|
|
1323
1707
|
return scaffold(options, deps, plan);
|
|
1324
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
|
+
}
|
|
1325
1872
|
async function main(argv) {
|
|
1326
1873
|
const [command, ...rest] = argv;
|
|
1327
1874
|
switch (command) {
|
|
1328
|
-
case "init": return await
|
|
1329
|
-
cwd: process.cwd(),
|
|
1330
|
-
force: rest.includes("--force"),
|
|
1331
|
-
dryRun: rest.includes("--dry-run"),
|
|
1332
|
-
schemaFlag: flagValue(rest, "--schema")
|
|
1333
|
-
});
|
|
1875
|
+
case "init": return await runInitFromArgv(rest, process.cwd());
|
|
1334
1876
|
case "push-schema": return await runPushSchema({
|
|
1335
1877
|
cwd: process.cwd(),
|
|
1336
1878
|
configPath: flagValue(rest, "--config")
|
|
@@ -1346,10 +1888,32 @@ async function main(argv) {
|
|
|
1346
1888
|
only: ALIAS_STAGE_IDS[command],
|
|
1347
1889
|
verbose: rest.includes("--verbose")
|
|
1348
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
|
+
});
|
|
1349
1913
|
case void 0:
|
|
1350
1914
|
case "help":
|
|
1351
1915
|
case "--help":
|
|
1352
|
-
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");
|
|
1353
1917
|
return 0;
|
|
1354
1918
|
default:
|
|
1355
1919
|
process.stdout.write(`Unknown command: ${command}\n`);
|
|
@@ -1360,4 +1924,4 @@ if (isEntrypoint(process.argv[1], import.meta.url)) main(process.argv.slice(2)).
|
|
|
1360
1924
|
process.exitCode = code;
|
|
1361
1925
|
});
|
|
1362
1926
|
//#endregion
|
|
1363
|
-
export { runInit };
|
|
1927
|
+
export { runInit, runInitFromArgv };
|