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