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/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-vu_uej2q.mjs";
5
+ import { t as createClivlySDK } from "./sdk-DtFmeuwl.mjs";
6
6
  import { createRequire } from "node:module";
7
- import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
8
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
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 report = await ctx.sdk.doctor();
1024
- const counted = report.checks.filter((check) => !check.skipped);
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: report.ok ? "pass" : "fail",
1390
+ state: counted.every((check) => check.ok) ? "pass" : "fail",
1028
1391
  detail: `${passed} of ${counted.length} passed`,
1029
- sub: report.checks.map((check) => ({
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
- printNextSteps(out, deps.authWiring.complete);
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 runInit({
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 };