clivly 0.3.0-next.2 → 0.3.0-next.4

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
  {
@@ -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 report = await ctx.sdk.doctor();
1032
- 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);
1033
1388
  const passed = counted.filter((check) => check.ok).length;
1034
1389
  return {
1035
- state: report.ok ? "pass" : "fail",
1390
+ state: counted.every((check) => check.ok) ? "pass" : "fail",
1036
1391
  detail: `${passed} of ${counted.length} passed`,
1037
- sub: report.checks.map((check) => ({
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);
@@ -1301,7 +1677,7 @@ async function scaffold(options, deps, plan) {
1301
1677
  if (options.dryRun) {
1302
1678
  out(`\nWould install (${pm}): ${packages.join(", ")}\n`);
1303
1679
  previewMutations(deps, mutations);
1304
- return plan.contactDetected ? 0 : 1;
1680
+ return 0;
1305
1681
  }
1306
1682
  const result = await deps.install({
1307
1683
  cwd: 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
- 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);
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 runInit({
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 };