convene-cli 1.11.0 → 1.12.0

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/dist/api.js CHANGED
@@ -42,6 +42,8 @@ class ConveneApi {
42
42
  headers['x-convene-session-instance'] = this.instance;
43
43
  if (opts.idempotencyKey)
44
44
  headers['idempotency-key'] = opts.idempotencyKey;
45
+ if (opts.headers)
46
+ Object.assign(headers, opts.headers);
45
47
  const res = await fetch(`${this.baseUrl}${brand_1.BRAND.apiBase}${apiPath}`, {
46
48
  method,
47
49
  headers,
@@ -154,6 +156,14 @@ class ConveneApi {
154
156
  join(slug, body, timeoutMs) {
155
157
  return this.request('POST', `/projects/${encodeURIComponent(slug)}/join`, { body, timeoutMs });
156
158
  }
159
+ /** Device enrollment — start (no bearer). Emails the member a confirm link if one exists. */
160
+ enrollStart(body, timeoutMs) {
161
+ return this.request('POST', '/enroll/device/start', { body, timeoutMs });
162
+ }
163
+ /** Device enrollment — poll for the minted key, presenting the raw device secret (no bearer). */
164
+ enrollPoll(enrollmentId, rawSecret, timeoutMs) {
165
+ return this.request('GET', `/enroll/device/poll?id=${encodeURIComponent(String(enrollmentId))}`, { headers: { 'x-device-secret': rawSecret }, timeoutMs });
166
+ }
157
167
  /** Self-recovery: re-grant your own membership via the committed join token (no bearer
158
168
  * auth required). Restores OWNER only from a server-recorded prior-owner row. */
159
169
  reclaim(slug, body, timeoutMs) {
package/dist/brand.js CHANGED
@@ -13,8 +13,8 @@ exports.BRAND = {
13
13
  product: 'Convene',
14
14
  slug: 'convene',
15
15
  bin: 'convene',
16
- domain: 'dev.convene.live',
17
- baseUrl: 'https://dev.convene.live',
16
+ domain: 'convene.live',
17
+ baseUrl: 'https://convene.live',
18
18
  /** Public product / front-door base URL for human-facing links (dashboard, /start, docs). */
19
19
  siteDomain: 'convene.live',
20
20
  siteUrl: 'https://convene.live',
package/dist/cache.js CHANGED
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.OVERRIDE_TTL_MS = exports.writeWatchHighWater = exports.LIVE_SESSION_RECENT_SEC = exports.LIVE_SESSION_WINDOW_SEC = void 0;
6
+ exports.OVERRIDE_TTL_MS = exports.LIVE_SESSION_RECENT_SEC = exports.LIVE_SESSION_WINDOW_SEC = void 0;
7
7
  exports.readCache = readCache;
8
8
  exports.writeCache = writeCache;
9
9
  exports.ageSeconds = ageSeconds;
@@ -25,11 +25,8 @@ exports.announceAlreadyPosted = announceAlreadyPosted;
25
25
  exports.markAnnounced = markAnnounced;
26
26
  exports.readLastBroadcastSha = readLastBroadcastSha;
27
27
  exports.writeLastBroadcastSha = writeLastBroadcastSha;
28
- exports.readWatchHighWater = readWatchHighWater;
29
- exports.persistHighWater = persistHighWater;
30
- exports.appendWatchEntry = appendWatchEntry;
31
- exports.appendWatch = appendWatch;
32
- exports.readWatchSince = readWatchSince;
28
+ exports.readWatchCursor = readWatchCursor;
29
+ exports.persistWatchCursor = persistWatchCursor;
33
30
  exports.touchWatchHeartbeat = touchWatchHeartbeat;
34
31
  exports.watchHeartbeatAgeSec = watchHeartbeatAgeSec;
35
32
  exports.writeWatchPid = writeWatchPid;
@@ -390,19 +387,32 @@ function writeLastBroadcastSha(slug, sha) {
390
387
  /* best-effort */
391
388
  }
392
389
  }
393
- function watchFile(slug) {
394
- return slugFile(slug, 'watch.jsonl');
395
- }
396
- function watchHighWaterFile(slug) {
390
+ // ── watch poll resume cursor (WP12) ──────────────────────────────────────────
391
+ // The `convene watch` daemon (cli/src/commands/watch.ts) long-polls the bus,
392
+ // stamps a liveness heartbeat each iteration (the health line reads its age), and
393
+ // optionally desktop-notifies on a directed halt. It persists a MONOTONIC poll
394
+ // cursor here so a relaunch (every SessionStart) resumes incrementally instead of
395
+ // re-scanning the backlog from zero.
396
+ //
397
+ // The daemon does NOT render anything into agent-facing context: directed halts
398
+ // reach the agent via the server-truth PULL surfaces (fetch / session-open /
399
+ // lane-state), which query the server — the single authority for halt state. (The
400
+ // daemon formerly APPENDED matched halts to a per-slug `.watch.jsonl` for a reader
401
+ // to drain, but that reader was never wired into any surface, and a local cache
402
+ // could drift from server truth and mislead. The jsonl was removed in favor of the
403
+ // pull path; only this resume cursor + the heartbeat remain.)
404
+ function watchCursorFile(slug) {
405
+ // Legacy `.watch.hw` (high-water) extension retained so existing caches aren't
406
+ // orphaned on upgrade — the file is now the daemon's own poll resume cursor.
397
407
  return slugFile(slug, 'watch.hw');
398
408
  }
399
409
  function watchHeartbeatFile(slug) {
400
410
  return slugFile(slug, 'watch.hb');
401
411
  }
402
- /** Read the persisted high-water seq for the watch jsonl (0 if none). */
403
- function readWatchHighWater(slug) {
412
+ /** Read the daemon's persisted poll resume cursor (0 if none). */
413
+ function readWatchCursor(slug) {
404
414
  try {
405
- const n = parseInt(node_fs_1.default.readFileSync(watchHighWaterFile(slug), 'utf8').trim(), 10);
415
+ const n = parseInt(node_fs_1.default.readFileSync(watchCursorFile(slug), 'utf8').trim(), 10);
406
416
  return Number.isFinite(n) ? n : 0;
407
417
  }
408
418
  catch {
@@ -410,87 +420,21 @@ function readWatchHighWater(slug) {
410
420
  }
411
421
  }
412
422
  /**
413
- * Persist the high-water seq for the watch jsonl — MONOTONIC. A lower seq is
414
- * ignored (GREATEST semantics) so a reader can never rewind past material it
415
- * already drained. NEVER truncates the jsonl.
423
+ * Persist the daemon's poll resume cursor — MONOTONIC. A lower seq is ignored
424
+ * (GREATEST semantics) so a relaunch never rewinds the cursor and re-scans
425
+ * material it already polled past. Best-effort; never throws.
416
426
  */
417
- function persistHighWater(slug, seq) {
427
+ function persistWatchCursor(slug, seq) {
418
428
  try {
419
429
  node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
420
- const cur = readWatchHighWater(slug);
430
+ const cur = readWatchCursor(slug);
421
431
  if (seq > cur)
422
- node_fs_1.default.writeFileSync(watchHighWaterFile(slug), String(seq) + '\n', { mode: 0o600 });
432
+ node_fs_1.default.writeFileSync(watchCursorFile(slug), String(seq) + '\n', { mode: 0o600 });
423
433
  }
424
434
  catch {
425
435
  /* best-effort */
426
436
  }
427
437
  }
428
- /** Back-compat alias for the WP2 stub name; identical monotonic behavior. */
429
- exports.writeWatchHighWater = persistHighWater;
430
- /**
431
- * APPEND a watch entry to the jsonl (append-only — never rewrites the file).
432
- * The append is atomic at the OS level for a single small write, so a concurrent
433
- * reader sees whole lines. Best-effort; never throws (the watch daemon is
434
- * fail-open). Entries WITHOUT a finite numeric seq are dropped — an un-keyed
435
- * entry can't participate in the high-water drain and would be re-rendered
436
- * forever.
437
- */
438
- function appendWatchEntry(slug, entry) {
439
- if (!entry || typeof entry.seq !== 'number' || !Number.isFinite(entry.seq))
440
- return;
441
- try {
442
- node_fs_1.default.mkdirSync(config_1.CACHE_DIR, { recursive: true, mode: 0o700 });
443
- node_fs_1.default.appendFileSync(watchFile(slug), JSON.stringify(entry) + '\n', { mode: 0o600 });
444
- }
445
- catch {
446
- /* best-effort */
447
- }
448
- }
449
- /** Back-compat alias for the WP2 stub name. */
450
- function appendWatch(slug, entry) {
451
- appendWatchEntry(slug, entry);
452
- }
453
- /**
454
- * Read all watch entries with seq > highWater, ascending by seq, de-duplicated
455
- * on seq (the daemon may re-append the same entry after a resume — the reader
456
- * tolerates duplicates by keeping the first per seq). Does NOT advance the
457
- * high-water and does NOT truncate — the caller decides what was actually
458
- * rendered and calls persistHighWater(slug, lastRenderedSeq). A malformed line
459
- * is skipped, never fatal.
460
- */
461
- function readWatchSince(slug, highWater) {
462
- let raw;
463
- try {
464
- raw = node_fs_1.default.readFileSync(watchFile(slug), 'utf8');
465
- }
466
- catch {
467
- return [];
468
- }
469
- const seen = new Set();
470
- const out = [];
471
- for (const line of raw.split('\n')) {
472
- const s = line.trim();
473
- if (!s)
474
- continue;
475
- let e;
476
- try {
477
- e = JSON.parse(s);
478
- }
479
- catch {
480
- continue; // a torn/partial line — skip, the next drain re-reads it whole
481
- }
482
- if (typeof e.seq !== 'number' || !Number.isFinite(e.seq))
483
- continue;
484
- if (e.seq <= highWater)
485
- continue;
486
- if (seen.has(e.seq))
487
- continue;
488
- seen.add(e.seq);
489
- out.push(e);
490
- }
491
- out.sort((a, b) => a.seq - b.seq);
492
- return out;
493
- }
494
438
  // ── watch heartbeat (WP12) ───────────────────────────────────────────────────
495
439
  // The daemon stamps a heartbeat each loop iteration; the health line / doctor
496
440
  // read its age. A stale/absent heartbeat ⇒ watch is down ⇒ surface DEGRADED.
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.enrollDevice = enrollDevice;
7
+ /**
8
+ * `convene enroll-device` — connect THIS machine to your EXISTING identity without
9
+ * copying a key. Proves email ownership (an emailed confirm link) + device ownership
10
+ * (a CLI-held secret), then the server mints a fresh per-device key that ONLY this
11
+ * CLI can claim. Also auto-invoked by `convene setup`/`join` when the bus reports an
12
+ * identity already exists for your email (EMAIL_TAKEN).
13
+ */
14
+ const node_crypto_1 = __importDefault(require("node:crypto"));
15
+ const node_os_1 = __importDefault(require("node:os"));
16
+ const api_1 = require("../api");
17
+ const config_1 = require("../config");
18
+ const git_1 = require("../git");
19
+ const ctx_1 = require("../ctx");
20
+ const log = (m) => process.stdout.write(m + '\n');
21
+ const sha256 = (s) => node_crypto_1.default.createHash('sha256').update(s, 'utf8').digest('hex');
22
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
23
+ /**
24
+ * Run the device-enrollment flow. Returns true once a fresh key is saved to the
25
+ * local config; on timeout / expiry it prints guidance and exits the process.
26
+ */
27
+ async function enrollDevice(opts) {
28
+ const top = (0, git_1.gitToplevel)();
29
+ const proj = (0, config_1.loadProjectConfig)(top);
30
+ const cfg = (0, config_1.resolveConfigForRepo)(top, proj);
31
+ const baseUrl = (opts.baseUrl || cfg.baseUrl).replace(/\/$/, '');
32
+ const email = opts.email || (0, git_1.gitUserEmail)(top ?? undefined) || undefined;
33
+ if (!email)
34
+ (0, ctx_1.die)('no email to enroll — pass `--email <you@example.com>` (or set git user.email)');
35
+ // CLI-held secret: send only its hash to start; present the raw secret at poll so
36
+ // ONLY this process can claim the minted key (a browser that clicks the link cannot).
37
+ const rawSecret = node_crypto_1.default.randomBytes(32).toString('base64url');
38
+ const secretHash = sha256(rawSecret);
39
+ const label = (opts.label || node_os_1.default.hostname() || 'cli').slice(0, 80);
40
+ const api = new api_1.ConveneApi(baseUrl, null);
41
+ const start = await api.enrollStart({ email: email, secret_hash: secretHash, device_label: label }, 10_000);
42
+ if (!start.ok || !start.json) {
43
+ (0, ctx_1.die)(`could not start device enrollment (${start.status}): ${start.error ?? 'unreachable'}`);
44
+ }
45
+ const { enrollment_id, poll_interval_ms, expires_in_s } = start.json;
46
+ const ttlS = expires_in_s ?? 900;
47
+ const mins = Math.max(1, Math.round(ttlS / 60));
48
+ log('');
49
+ log(`📧 We emailed a confirmation link to ${email}.`);
50
+ log(` Open it and click "Authorize device" to connect this machine (expires in ${mins} min).`);
51
+ log(` Waiting…`);
52
+ const intervalMs = Math.max(1000, poll_interval_ms ?? 2000);
53
+ const deadline = Date.now() + ttlS * 1000;
54
+ while (Date.now() < deadline) {
55
+ await sleep(intervalMs);
56
+ const poll = await api.enrollPoll(enrollment_id, rawSecret, 8000);
57
+ const st = poll.json?.status;
58
+ if (st === 'ok' && poll.json?.api_key) {
59
+ const member = poll.json.member?.handle ?? '';
60
+ (0, config_1.saveFileConfig)({ apiKey: poll.json.api_key, baseUrl, member });
61
+ log('');
62
+ log(`✓ Authorized as ${member}. This machine is connected (config saved 0600).`);
63
+ return true;
64
+ }
65
+ if (st === 'expired') {
66
+ (0, ctx_1.die)('the confirmation link expired or was already used — run `convene setup` again to retry.');
67
+ }
68
+ // 'pending' / 'not_found' → the human hasn't confirmed yet; keep waiting.
69
+ }
70
+ (0, ctx_1.die)('timed out waiting for email confirmation — run `convene setup` again to retry.');
71
+ return false; // unreachable: die() exits.
72
+ }
@@ -15,6 +15,7 @@ const config_1 = require("../config");
15
15
  const git_1 = require("../git");
16
16
  const hook_1 = require("../hook");
17
17
  const githook_1 = require("../githook");
18
+ const enroll_1 = require("./enroll");
18
19
  const ctx_1 = require("../ctx");
19
20
  const log = (m) => process.stdout.write(m + '\n');
20
21
  async function join(opts) {
@@ -34,8 +35,25 @@ async function join(opts) {
34
35
  // unauthenticated mode otherwise (creates a new identity + key).
35
36
  const api = new api_1.ConveneApi(baseUrl, cfg.apiKey ?? null);
36
37
  const res = await api.join(slug, { token, handle, email, display_name: handle, tool: 'cli' }, 10_000);
37
- if (res.status === 409)
38
- (0, ctx_1.die)(`the handle "${handle}" is already taken — re-run with --handle <unique-handle>`);
38
+ if (res.status === 409) {
39
+ const code = res.json?.code;
40
+ const keyHint = `paste your key (Settings → API key at ${brand_1.BRAND.siteUrl}/settings, or ~/.convene/config.json on your other machine)`;
41
+ if (code === 'EMAIL_TAKEN') {
42
+ // Same human, new machine: connect THIS machine to the existing identity with NO key
43
+ // copying — prove email ownership via an emailed link, then re-run join authenticated
44
+ // so the (now-known) member is added to this project (idempotent if already a member).
45
+ log(`An identity already exists for ${email ?? 'your email'} — connecting this machine to it (no key copying needed).`);
46
+ const ok = await (0, enroll_1.enrollDevice)({ email, baseUrl });
47
+ if (ok)
48
+ return join(opts);
49
+ return; // enrollDevice prints guidance + exits on failure
50
+ }
51
+ (0, ctx_1.die)(`The handle "${handle}" is already taken on this bus.\n` +
52
+ `If that's you (same person, another machine), connect to your existing identity:\n` +
53
+ ` convene login --api-key - # ${keyHint}\n` +
54
+ `If you want a SEPARATE identity here, pick a different handle via your email:\n` +
55
+ ` convene setup --email <a-different-email> # the handle is derived from the email's local part`);
56
+ }
39
57
  if (res.status === 401 && cfg.apiKey)
40
58
  (0, ctx_1.die)('join token is invalid/expired/revoked, or your saved key is bad — ask an owner');
41
59
  if (res.status === 401)
@@ -17,21 +17,24 @@ exports.watch = watch;
17
17
  * - A bounded run (no live config / not on the bus) exits 0 silently so a
18
18
  * SessionStart launch on a non-bus repo is a no-op.
19
19
  *
20
- * APPEND-ONLY + HIGH-WATER (awareness/concurrency #5 the lost-interrupt race):
21
- * - Each matched halt/interrupt is APPENDED to a per-slug jsonl via
22
- * appendWatchEntry. The daemon NEVER truncates the log.
23
- * - Readers (fetch / doctor / the health line) render entries with seq >
24
- * high-water and advance a MONOTONIC high-water with persistHighWater. The
25
- * reader, not the writer, owns the cursor, so a read can never race an append
26
- * into losing an interrupt.
20
+ * RENDERING IS NOT THIS DAEMON'S JOB. Directed halts reach the agent's context
21
+ * via the server-truth PULL surfaces (fetch / session-open / lane-state), which
22
+ * query the server the single authority for halt state. This daemon only:
23
+ * - stamps a liveness heartbeat each iteration (the health line reads its age,
24
+ * surfacing DEGRADED if the watcher is down);
25
+ * - optionally fires a best-effort desktop notification per matched halt
26
+ * (--notify; off by default — never enabled by the SessionStart spawn);
27
+ * - persists a MONOTONIC poll cursor so a relaunch (every SessionStart) resumes
28
+ * incrementally instead of re-scanning the backlog.
29
+ * It formerly appended matched halts to a per-slug jsonl for a reader to drain,
30
+ * but that reader was never wired up and a local cache could drift from server
31
+ * truth and mislead — so the jsonl was removed in favor of the pull path.
27
32
  *
28
- * TRUST: the daemon writes the message TYPE + server-derived routing/handles +
29
- * the (UNTRUSTED) body verbatim into the jsonl. The body is NEVER interpreted
30
- * here the consumer renders it inert. The block DECISION is the guard's, from
31
- * lane-state; watch only narrows the window for non-deploy turns.
33
+ * TRUST: the block DECISION is the guard's, from lane-state. A halt body is
34
+ * UNTRUSTED and is NEVER interpreted here the notification is a fixed template
35
+ * that never splices the body. watch only narrows the window for non-deploy turns.
32
36
  *
33
- * --notify: best-effort desktop ping per surfaced halt (delegates to the notify
34
- * verb's mechanism if present; otherwise silently skipped). Never blocks the loop.
37
+ * --notify: best-effort desktop ping per matched halt. Never blocks the loop.
35
38
  */
36
39
  const node_child_process_1 = require("node:child_process");
37
40
  const git_1 = require("../git");
@@ -80,30 +83,12 @@ function watchShouldExit(args) {
80
83
  }
81
84
  const HALT_TYPES = new Set(['halt', 'interrupt']);
82
85
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
83
- /** Map a server feed/poll message to an inert WatchEntry. Returns null for non-halts. */
84
- function toEntry(m) {
85
- if (!m || typeof m !== 'object')
86
- return null;
87
- const type = typeof m.type === 'string' ? m.type : '';
88
- if (!HALT_TYPES.has(type))
89
- return null;
90
- // seq is the messages.id (enrich exposes it as `seq`, falling back to numeric id).
91
- const seq = typeof m.seq === 'number' ? m.seq : typeof m.id === 'number' ? m.id : Number(m.id);
92
- if (!Number.isFinite(seq))
93
- return null;
94
- return {
95
- seq,
96
- type,
97
- short_id: typeof m.short_id === 'string' ? m.short_id : null,
98
- // from/to/body are DISPLAY/UNTRUSTED — copied verbatim, never interpreted here.
99
- from: typeof m.from_handle === 'string' ? m.from_handle : typeof m.from === 'string' ? m.from : null,
100
- to: typeof m.to === 'string' ? m.to : typeof m.to_member === 'string' ? m.to_member : null,
101
- body: typeof m.body === 'string' ? m.body : null,
102
- at: typeof m.created_at === 'string' ? m.created_at : null,
103
- };
86
+ /** True if a server feed/poll message is a halt/interrupt control message. */
87
+ function isHaltMessage(m) {
88
+ return !!m && typeof m === 'object' && typeof m.type === 'string' && HALT_TYPES.has(m.type);
104
89
  }
105
90
  /** Best-effort desktop notification; never blocks or throws. */
106
- function notifyBestEffort(entry) {
91
+ function notifyBestEffort() {
107
92
  try {
108
93
  if (process.platform === 'darwin') {
109
94
  // A halt is a control signal; the body is UNTRUSTED so we do NOT splice it
@@ -132,10 +117,9 @@ async function loop(opts) {
132
117
  return 0; // can't authenticate → silent no-op
133
118
  const instance = (0, cache_1.ensureSessionInstance)(slug);
134
119
  const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
135
- // Resume cursor: start from the persisted high-water so a relaunch never
136
- // re-reads material the reader already drained. (The reader's high-water is the
137
- // canonical "already surfaced" mark; the daemon resumes from there.)
138
- let cursor = (0, cache_1.readWatchHighWater)(slug);
120
+ // Resume from the persisted poll cursor so a relaunch (every SessionStart)
121
+ // resumes incrementally instead of re-scanning the backlog from zero.
122
+ let cursor = (0, cache_1.readWatchCursor)(slug);
139
123
  let backoff = BACKOFF_BASE_MS;
140
124
  let iterations = 0;
141
125
  const limit = typeof opts.maxIterations === 'number' ? opts.maxIterations : Infinity;
@@ -195,19 +179,17 @@ async function loop(opts) {
195
179
  backoff = BACKOFF_BASE_MS; // recovered
196
180
  const msgs = Array.isArray(res.json.messages) ? res.json.messages : [];
197
181
  for (const m of msgs) {
198
- const entry = toEntry(m);
199
- if (!entry)
182
+ if (!isHaltMessage(m))
200
183
  continue;
201
- (0, cache_1.appendWatchEntry)(slug, entry);
202
184
  if (opts.notify)
203
- notifyBestEffort(entry);
185
+ notifyBestEffort();
204
186
  }
205
- // Advance the resume cursor to the server's reported cursor (monotonic). This
206
- // is the long-poll resume seq, NOT the reader's high-water the daemon must
207
- // move past EVERY message it saw (incl. non-halts) or it would re-fetch them
208
- // forever. The reader's high-water only advances over rendered halts.
209
- if (typeof res.json.cursor === 'number' && res.json.cursor > cursor)
187
+ // Advance the resume cursor past EVERY message seen (incl. non-halts) so the
188
+ // daemon never re-fetches them, and PERSIST it so a relaunch resumes here.
189
+ if (typeof res.json.cursor === 'number' && res.json.cursor > cursor) {
210
190
  cursor = res.json.cursor;
191
+ (0, cache_1.persistWatchCursor)(slug, cursor);
192
+ }
211
193
  iterations++;
212
194
  }
213
195
  }
package/dist/index.js CHANGED
@@ -48,6 +48,7 @@ const post = __importStar(require("./commands/post"));
48
48
  const inbox_1 = require("./commands/inbox");
49
49
  const feedback_1 = require("./commands/feedback");
50
50
  const auth_1 = require("./commands/auth");
51
+ const enroll_1 = require("./commands/enroll");
51
52
  const init_1 = require("./commands/init");
52
53
  const offboard_1 = require("./commands/offboard");
53
54
  const join_1 = require("./commands/join");
@@ -94,6 +95,15 @@ program
94
95
  .option('--api-key <key>', 'API key, or "-" to read from stdin (no shell history)')
95
96
  .option('--base-url <url>', 'Convene base URL')
96
97
  .action((opts) => (0, auth_1.login)(opts));
98
+ program
99
+ .command('enroll-device')
100
+ .description('connect THIS machine to your existing identity via an emailed link (no key copying)')
101
+ .option('--email <email>', 'email of your existing identity (defaults to git user.email)')
102
+ .option('--base-url <url>', 'Convene base URL')
103
+ .option('--label <label>', 'a label for this device (defaults to hostname)')
104
+ .action(async (opts) => {
105
+ await (0, enroll_1.enrollDevice)({ email: opts.email, baseUrl: opts.baseUrl, label: opts.label });
106
+ });
97
107
  program.command('whoami').description('show identity, base URL, session, bus status').action(() => (0, auth_1.whoami)());
98
108
  program
99
109
  .command('fetch')
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "convene-cli",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "Convene CLI — AI development coordination bus client + UserPromptSubmit hook. Install: npm i -g convene-cli; then `convene setup`.",
5
5
  "license": "MIT",
6
- "homepage": "https://dev.convene.live",
6
+ "homepage": "https://convene.live",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/alex-hawkinson/Convene.git",