@primitivedotdev/cli 0.37.0 → 1.0.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.
@@ -949,6 +949,22 @@ function deleteCliCredentials(configDir) {
949
949
  rmSync(credentialsPath(configDir), { force: true });
950
950
  deleteChatState(configDir);
951
951
  }
952
+ function saveSignupCredentials(params) {
953
+ deleteChatState(params.configDir);
954
+ saveCliCredentials(params.configDir, {
955
+ access_token: params.signup.access_token,
956
+ api_base_url: params.apiBaseUrl,
957
+ auth_method: "oauth",
958
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
959
+ expires_at: cliAccessTokenExpiresAt(params.signup.expires_in),
960
+ oauth_client_id: params.signup.oauth_client_id,
961
+ oauth_grant_id: params.signup.oauth_grant_id,
962
+ org_id: params.signup.org_id,
963
+ org_name: params.signup.org_name,
964
+ refresh_token: params.signup.refresh_token,
965
+ token_type: params.signup.token_type
966
+ });
967
+ }
952
968
  function deleteCliCredentialsLock(configDir) {
953
969
  rmSync(credentialsLockPath(configDir), {
954
970
  force: true,
@@ -1311,4 +1327,4 @@ function redactCliEnvironment(environment) {
1311
1327
  };
1312
1328
  }
1313
1329
  //#endregion
1314
- export { createClient as A, saveCliCredentials as C, loadChatConversationByLocalId as D, loadActiveChatState as E, saveActiveChatState as O, resolveCliAuth as S, deleteChatState as T, deleteCliCredentials as _, normalizeCliEnvironmentName as a, loadCliCredentials as b, resolveConfigEnvironment as c, validateCliHeaderName as d, validateCliHeaderValue as f, credentialsPath as g, credentialsLockPath as h, loadCliConfig as i, createConfig as j, PrimitiveApiClient as k, saveCliConfig as l, cliAccessTokenExpiresAt as m, deleteCliConfig as n, redactCliEnvironment as o, acquireCliCredentialsLock as p, emptyCliConfig as r, removeCliEnvironment as s, DEFAULT_ENVIRONMENT as t, upsertCliEnvironment as u, deleteCliCredentialsLock as v, chatStatePath as w, normalizeApiBaseUrl as x, detectPrimitiveKeyEnvMisname as y };
1330
+ export { PrimitiveApiClient as A, saveCliCredentials as C, loadActiveChatState as D, deleteChatState as E, createConfig as M, loadChatConversationByLocalId as O, resolveCliAuth as S, chatStatePath as T, deleteCliCredentials as _, normalizeCliEnvironmentName as a, loadCliCredentials as b, resolveConfigEnvironment as c, validateCliHeaderName as d, validateCliHeaderValue as f, credentialsPath as g, credentialsLockPath as h, loadCliConfig as i, createClient as j, saveActiveChatState as k, saveCliConfig as l, cliAccessTokenExpiresAt as m, deleteCliConfig as n, redactCliEnvironment as o, acquireCliCredentialsLock as p, emptyCliConfig as r, removeCliEnvironment as s, DEFAULT_ENVIRONMENT as t, upsertCliEnvironment as u, deleteCliCredentialsLock as v, saveSignupCredentials as w, normalizeApiBaseUrl as x, detectPrimitiveKeyEnvMisname as y };
@@ -1,6 +1,6 @@
1
- import { A as createClient, C as saveCliCredentials, D as loadChatConversationByLocalId, E as loadActiveChatState, O as saveActiveChatState, S as resolveCliAuth, T as deleteChatState, _ as deleteCliCredentials, a as normalizeCliEnvironmentName, b as loadCliCredentials, c as resolveConfigEnvironment, d as validateCliHeaderName, f as validateCliHeaderValue, g as credentialsPath, h as credentialsLockPath, i as loadCliConfig, j as createConfig, k as PrimitiveApiClient, l as saveCliConfig, m as cliAccessTokenExpiresAt, n as deleteCliConfig, o as redactCliEnvironment, p as acquireCliCredentialsLock, r as emptyCliConfig, s as removeCliEnvironment, u as upsertCliEnvironment, v as deleteCliCredentialsLock, w as chatStatePath, x as normalizeApiBaseUrl, y as detectPrimitiveKeyEnvMisname } from "../cli-config-D7wN_PBc.js";
1
+ import { A as PrimitiveApiClient, C as saveCliCredentials, D as loadActiveChatState, E as deleteChatState, M as createConfig, O as loadChatConversationByLocalId, S as resolveCliAuth, T as chatStatePath, _ as deleteCliCredentials, a as normalizeCliEnvironmentName, b as loadCliCredentials, c as resolveConfigEnvironment, d as validateCliHeaderName, f as validateCliHeaderValue, g as credentialsPath, h as credentialsLockPath, i as loadCliConfig, j as createClient, k as saveActiveChatState, l as saveCliConfig, m as cliAccessTokenExpiresAt, n as deleteCliConfig, o as redactCliEnvironment, p as acquireCliCredentialsLock, r as emptyCliConfig, s as removeCliEnvironment, u as upsertCliEnvironment, v as deleteCliCredentialsLock, w as saveSignupCredentials, x as normalizeApiBaseUrl, y as detectPrimitiveKeyEnvMisname } from "../cli-config-B5hrwe8q.js";
2
2
  import { Args, Command, Errors, Flags, ux } from "@oclif/core";
3
- import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
6
6
  import { hostname } from "node:os";
@@ -14570,6 +14570,16 @@ const OPERATION_HINTS = {
14570
14570
  verifyAgentSignup: "Tip: pass --verification-code <code> (or --code; both work). The response carries OAuth tokens but not your assigned inbox domain; run `primitive domains list` (or `primitive whoami`) after success to see the managed *.primitive.email address that routes to this account."
14571
14571
  };
14572
14572
  const OPERATION_FLAG_ALIASES = { verifyAgentSignup: { verification_code: ["code"] } };
14573
+ const OPERATION_SUCCESS_HOOKS = { verifyAgentSignup: ({ envelope, configDir, apiBaseUrl, writeStderr }) => {
14574
+ const data = envelope?.data;
14575
+ if (!data?.access_token || !data?.refresh_token) return;
14576
+ saveSignupCredentials({
14577
+ apiBaseUrl,
14578
+ configDir,
14579
+ signup: data
14580
+ });
14581
+ writeStderr("Credentials saved to the CLI config; `primitive whoami` will work on the next call.\n");
14582
+ } };
14573
14583
  function createOperationCommand(operation) {
14574
14584
  const { flags, bodyFieldFlagToProperty } = buildFlags(operation);
14575
14585
  const baseDescription = operation.description !== null && operation.description !== void 0 ? canonicalizeCliReferences(operation.description) : `${operation.method} ${operation.path}`;
@@ -14663,6 +14673,20 @@ function createOperationCommand(operation) {
14663
14673
  writeIdempotentReplayBannerIfReplay(envelope?.data, { write: (chunk) => {
14664
14674
  process.stderr.write(chunk);
14665
14675
  } });
14676
+ const successHook = OPERATION_SUCCESS_HOOKS[operation.sdkName];
14677
+ if (successHook) try {
14678
+ successHook({
14679
+ envelope,
14680
+ configDir: this.config.configDir,
14681
+ apiBaseUrl: auth.apiBaseUrl,
14682
+ writeStderr: (chunk) => {
14683
+ process.stderr.write(chunk);
14684
+ }
14685
+ });
14686
+ } catch (hookError) {
14687
+ const detail = hookError instanceof Error ? hookError.message : String(hookError);
14688
+ process.stderr.write(`Warning: ${operation.sdkName} succeeded but its post-success hook threw (${detail}). The response below is still valid; act on it manually.\n`);
14689
+ }
14666
14690
  this.log(JSON.stringify(operationOutputPayload(envelope, parsedFlags.envelope === true), null, 2));
14667
14691
  if (isIncompleteDomainVerification(operation, envelope)) {
14668
14692
  writeIncompleteDomainVerificationHint();
@@ -14718,6 +14742,173 @@ function readAttachmentFiles(paths, readFile = readFileSync) {
14718
14742
  });
14719
14743
  }
14720
14744
  //#endregion
14745
+ //#region src/oclif/chat-lock.ts
14746
+ /**
14747
+ * Filesystem mutex around the chat-state read → POST → save sequence.
14748
+ *
14749
+ * Why this exists. The chat reply flow is read-modify-write across an
14750
+ * RTT to the API: `loadActiveChatState` → `replyToEmail` → poll → save.
14751
+ * Two concurrent invocations (e.g. the user re-runs `primitive chat
14752
+ * reply` before the first cycle finishes its 5–12 s poll) read the
14753
+ * same stale `last_reply_email_id` and POST to it, producing a
14754
+ * duplicate /v1/emails/{id}/reply that the server deduplicates by
14755
+ * content_hash. The second invocation then polls forever for a reply
14756
+ * that arrived in response to the *first* send and has already been
14757
+ * surfaced by the *first* invocation.
14758
+ *
14759
+ * The lock is per-process-config-dir, not per-conversation: holding it
14760
+ * for a few seconds while one chat reply completes is a reasonable UX
14761
+ * constraint and clearly explained on contention. The lock is
14762
+ * re-entrant within a single Node process (ChatReplyCommand wraps
14763
+ * ChatCommand internally), but rejects cross-process contention.
14764
+ *
14765
+ * Liveness. The lock file stores the holder's PID. On EEXIST we probe
14766
+ * the holder with `process.kill(pid, 0)`; if the holder is gone (e.g.
14767
+ * a previous chat invocation crashed without releasing), we steal the
14768
+ * lock. This avoids needing a heartbeat or mtime-based stale check,
14769
+ * either of which has its own race surface.
14770
+ *
14771
+ * Releases. The returned function is idempotent. Callers must call it
14772
+ * in a finally block. We also register process-exit / signal handlers
14773
+ * so a Ctrl-C during the poll loop still cleans up.
14774
+ */
14775
+ const LOCK_FILENAME = "chat-state.lock";
14776
+ let processHolder = null;
14777
+ /**
14778
+ * Whether we've installed our exit / signal listeners on the
14779
+ * `process` object. Done lazily on first acquire and never undone:
14780
+ * adding handlers per-acquire would let them accumulate (each
14781
+ * acquire registers four listeners, and `process.once` can't be
14782
+ * un-once'd), which is harmless in production but produces
14783
+ * `MaxListenersExceededWarning` + cascading no-op fires in tests
14784
+ * that acquire/release across many `it()` blocks. Greptile P2.
14785
+ *
14786
+ * The handlers consult the current `processHolder` and act only if
14787
+ * a lock is actually held, so leaving them installed across
14788
+ * release boundaries is safe: a released or never-acquired lock
14789
+ * makes them no-ops.
14790
+ */
14791
+ let exitListenersInstalled = false;
14792
+ function installExitListenersOnce() {
14793
+ if (exitListenersInstalled) return;
14794
+ exitListenersInstalled = true;
14795
+ const cleanup = () => {
14796
+ if (processHolder === null) return;
14797
+ try {
14798
+ unlinkSync(lockPath(processHolder.configDir));
14799
+ } catch {}
14800
+ processHolder = null;
14801
+ };
14802
+ process.on("exit", cleanup);
14803
+ for (const signal of [
14804
+ "SIGINT",
14805
+ "SIGTERM",
14806
+ "SIGHUP"
14807
+ ]) {
14808
+ const handler = () => {
14809
+ cleanup();
14810
+ process.removeListener(signal, handler);
14811
+ process.kill(process.pid, signal);
14812
+ };
14813
+ process.on(signal, handler);
14814
+ }
14815
+ }
14816
+ function lockPath(configDir) {
14817
+ return join(configDir, LOCK_FILENAME);
14818
+ }
14819
+ /**
14820
+ * `process.kill(pid, 0)` returns true if the process exists and we
14821
+ * have permission to signal it. Throws ESRCH when the pid is gone,
14822
+ * EPERM if it exists but isn't ours. Both "exists" cases mean we
14823
+ * should NOT steal the lock; only ESRCH proves the holder is dead.
14824
+ */
14825
+ function pidIsAlive(pid) {
14826
+ try {
14827
+ process.kill(pid, 0);
14828
+ return true;
14829
+ } catch (err) {
14830
+ if (err.code === "ESRCH") return false;
14831
+ return true;
14832
+ }
14833
+ }
14834
+ function readHolderPid(configDir) {
14835
+ try {
14836
+ const raw = readFileSync(lockPath(configDir), "utf8").trim();
14837
+ const pid = Number.parseInt(raw, 10);
14838
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
14839
+ } catch {
14840
+ return null;
14841
+ }
14842
+ }
14843
+ var ChatLockContentionError = class extends Error {
14844
+ constructor(holderPid) {
14845
+ super(`Another \`primitive chat\` invocation (pid ${holderPid}) is in progress. Wait for it to finish, or kill it before retrying.`);
14846
+ this.holderPid = holderPid;
14847
+ this.name = "ChatLockContentionError";
14848
+ }
14849
+ };
14850
+ /**
14851
+ * Acquire the chat-state mutex for this configDir. Returns a release
14852
+ * function that is safe to call any number of times. Throws
14853
+ * `ChatLockContentionError` if another live process holds the lock.
14854
+ */
14855
+ function acquireChatLock(configDir) {
14856
+ if (processHolder?.configDir === configDir) {
14857
+ processHolder.depth += 1;
14858
+ let released = false;
14859
+ return () => {
14860
+ if (released) return;
14861
+ released = true;
14862
+ if (processHolder !== null) processHolder.depth -= 1;
14863
+ };
14864
+ }
14865
+ mkdirSync(configDir, {
14866
+ mode: 448,
14867
+ recursive: true
14868
+ });
14869
+ for (let attempt = 0; attempt < 2; attempt++) {
14870
+ let fd;
14871
+ try {
14872
+ fd = openSync(lockPath(configDir), "wx", 384);
14873
+ } catch (err) {
14874
+ if (err.code !== "EEXIST") throw err;
14875
+ const holder = readHolderPid(configDir);
14876
+ if (holder === null || !pidIsAlive(holder)) {
14877
+ try {
14878
+ unlinkSync(lockPath(configDir));
14879
+ } catch (unlinkErr) {
14880
+ if (unlinkErr.code !== "ENOENT") throw unlinkErr;
14881
+ }
14882
+ continue;
14883
+ }
14884
+ throw new ChatLockContentionError(holder);
14885
+ }
14886
+ writeSync(fd, `${process.pid}\n`);
14887
+ closeSync(fd);
14888
+ processHolder = {
14889
+ configDir,
14890
+ depth: 1
14891
+ };
14892
+ installExitListenersOnce();
14893
+ let released = false;
14894
+ return () => {
14895
+ if (released) return;
14896
+ released = true;
14897
+ if (processHolder?.configDir === configDir) {
14898
+ processHolder.depth -= 1;
14899
+ if (processHolder.depth <= 0) {
14900
+ try {
14901
+ unlinkSync(lockPath(configDir));
14902
+ } catch {}
14903
+ processHolder = null;
14904
+ }
14905
+ }
14906
+ };
14907
+ }
14908
+ /* v8 ignore next 4 -- the for-loop returns or throws on every iteration; the unreachable trailer keeps TS happy. */
14909
+ throw new Error("acquireChatLock: exhausted retries (this is a bug — should not be reachable)");
14910
+ }
14911
+ //#endregion
14721
14912
  //#region src/oclif/outbound-defaults.ts
14722
14913
  const SUBJECT_MAX_LENGTH = 200;
14723
14914
  function deriveSubject(body) {
@@ -15031,7 +15222,7 @@ function matchDescription(strategy) {
15031
15222
  return strategy === "strict" ? "strict, matched by reply_to_sent_email_id" : "fallback, matched by sender/time window";
15032
15223
  }
15033
15224
  function normalizeEmailAddress(value) {
15034
- return value.trim().toLowerCase();
15225
+ return (value.match(/<([^>]+)>/)?.[1] ?? value).trim().toLowerCase();
15035
15226
  }
15036
15227
  function derivedReplySubject(parent) {
15037
15228
  const subject = parent.subject?.trim();
@@ -15209,6 +15400,28 @@ function persistActiveChat(params) {
15209
15400
  return null;
15210
15401
  }
15211
15402
  }
15403
+ /**
15404
+ * Pick the email id to surface from the one-shot strict search we run
15405
+ * when the server returned `idempotent_replay: true`. The search has
15406
+ * no `since` filter (the existing reply, if any, predates this
15407
+ * attempt's `sentAtIso`), so we may receive multiple historical
15408
+ * inbounds matching `reply_to_sent_email_id = sent.id` if the user
15409
+ * has been hammering the same content. Prefer the most recent
15410
+ * accepted/completed row; reject pending/processing rows the way the
15411
+ * normal poll does.
15412
+ */
15413
+ async function resolveIdempotentReplayReply(page) {
15414
+ if (!page.ok || page.rows.length === 0) return null;
15415
+ let latest = null;
15416
+ for (const row of page.rows) {
15417
+ if (row.status !== "accepted" && row.status !== "completed") continue;
15418
+ if (latest === null || row.received_at.localeCompare(latest.receivedAt) > 0) latest = {
15419
+ id: row.id,
15420
+ receivedAt: row.received_at
15421
+ };
15422
+ }
15423
+ return latest?.id ?? null;
15424
+ }
15212
15425
  function formatChatResponse(context) {
15213
15426
  const accepted = context.sent.accepted.join(", ") || context.recipient;
15214
15427
  const responseBody = resolveChatResponseBody(context.reply);
@@ -15423,181 +15636,243 @@ var ChatCommand = class ChatCommand extends Command {
15423
15636
  const message = flags.reply !== void 0 ? flags.reply : args.message !== void 0 && args.message !== "" ? args.message : await readStdinToString();
15424
15637
  if (!message.trim()) throw cliError$6(replyMode ? "Reply body is empty." : "Message body is empty.");
15425
15638
  await runWithTiming(flags.time, async () => {
15426
- const { apiClient, auth, baseUrlOverridden } = await createAuthenticatedCliApiClient({
15427
- apiKey: flags["api-key"],
15428
- apiBaseUrl: flags["api-base-url"],
15429
- configDir: this.config.configDir
15430
- });
15431
- const authFailureContext = {
15432
- auth,
15433
- baseUrlOverridden,
15434
- configDir: this.config.configDir
15435
- };
15436
- const progress = flags.quiet ? null : new ChatProgressIndicator(process.stderr);
15437
- const attachments = readAttachmentFiles(flags.attachment);
15438
- let from;
15439
- let parentReply;
15440
- let subject;
15441
- if (replyMode) {
15442
- const replyContext = await (async () => {
15443
- let replyContextFailureMessage = "Could not load reply context.";
15444
- try {
15445
- if (flags["reply-to-email-id"] !== void 0) {
15446
- progress?.start(`Loading reply context for ${flags["reply-to-email-id"]}`);
15447
- const exactParentReply = await loadInboundEmailDetail({
15639
+ let releaseLock;
15640
+ try {
15641
+ releaseLock = acquireChatLock(this.config.configDir);
15642
+ } catch (err) {
15643
+ if (err instanceof ChatLockContentionError) throw cliError$6(err.message);
15644
+ throw err;
15645
+ }
15646
+ try {
15647
+ const { apiClient, auth, baseUrlOverridden } = await createAuthenticatedCliApiClient({
15648
+ apiKey: flags["api-key"],
15649
+ apiBaseUrl: flags["api-base-url"],
15650
+ configDir: this.config.configDir
15651
+ });
15652
+ const authFailureContext = {
15653
+ auth,
15654
+ baseUrlOverridden,
15655
+ configDir: this.config.configDir
15656
+ };
15657
+ const progress = flags.quiet ? null : new ChatProgressIndicator(process.stderr);
15658
+ const attachments = readAttachmentFiles(flags.attachment);
15659
+ let from;
15660
+ let parentReply;
15661
+ let subject;
15662
+ if (replyMode) {
15663
+ const replyContext = await (async () => {
15664
+ let replyContextFailureMessage = "Could not load reply context.";
15665
+ try {
15666
+ if (flags["reply-to-email-id"] !== void 0) {
15667
+ progress?.start(`Loading reply context for ${flags["reply-to-email-id"]}`);
15668
+ const exactParentReply = await loadInboundEmailDetail({
15669
+ apiClient,
15670
+ authFailureContext,
15671
+ id: flags["reply-to-email-id"]
15672
+ });
15673
+ replyContextFailureMessage = `Inbound email ${flags["reply-to-email-id"]} does not match recipient ${args.recipient}.`;
15674
+ assertParentMatchesRecipient(exactParentReply, args.recipient);
15675
+ return {
15676
+ from: flags.from ?? exactParentReply.to_email,
15677
+ parentReply: exactParentReply
15678
+ };
15679
+ }
15680
+ const replyFrom = flags.from ?? await pickDefaultFromAddress(apiClient, authFailureContext);
15681
+ progress?.start(`Finding latest inbound email from ${args.recipient}`);
15682
+ const latestParentReply = await findLatestInboundFromRecipient({
15448
15683
  apiClient,
15449
15684
  authFailureContext,
15450
- id: flags["reply-to-email-id"]
15685
+ from: replyFrom,
15686
+ pageSize: flags["page-size"],
15687
+ recipient: args.recipient
15451
15688
  });
15452
- replyContextFailureMessage = `Inbound email ${flags["reply-to-email-id"]} does not match recipient ${args.recipient}.`;
15453
- assertParentMatchesRecipient(exactParentReply, args.recipient);
15689
+ if (!latestParentReply) {
15690
+ replyContextFailureMessage = "No prior inbound email found.";
15691
+ throw cliError$6(`No prior inbound email from ${args.recipient} to ${replyFrom}. Start a new chat with \`primitive chat ${args.recipient} <message>\`, pass --from, or pass --reply-to-email-id <inbound-email-id>.`);
15692
+ }
15693
+ replyContextFailureMessage = `Inbound email ${latestParentReply.id} does not match recipient ${args.recipient}.`;
15694
+ assertParentMatchesRecipient(latestParentReply, args.recipient);
15454
15695
  return {
15455
- from: flags.from ?? exactParentReply.to_email,
15456
- parentReply: exactParentReply
15696
+ from: replyFrom,
15697
+ parentReply: latestParentReply
15457
15698
  };
15699
+ } catch (error) {
15700
+ progress?.fail(replyContextFailureMessage);
15701
+ throw error;
15458
15702
  }
15459
- const replyFrom = flags.from ?? await pickDefaultFromAddress(apiClient, authFailureContext);
15460
- progress?.start(`Finding latest inbound email from ${args.recipient}`);
15461
- const latestParentReply = await findLatestInboundFromRecipient({
15462
- apiClient,
15463
- authFailureContext,
15464
- from: replyFrom,
15465
- pageSize: flags["page-size"],
15466
- recipient: args.recipient
15467
- });
15468
- if (!latestParentReply) {
15469
- replyContextFailureMessage = "No prior inbound email found.";
15470
- throw cliError$6(`No prior inbound email from ${args.recipient} to ${replyFrom}. Start a new chat with \`primitive chat ${args.recipient} <message>\`, pass --from, or pass --reply-to-email-id <inbound-email-id>.`);
15471
- }
15472
- replyContextFailureMessage = `Inbound email ${latestParentReply.id} does not match recipient ${args.recipient}.`;
15473
- assertParentMatchesRecipient(latestParentReply, args.recipient);
15474
- return {
15475
- from: replyFrom,
15476
- parentReply: latestParentReply
15477
- };
15478
- } catch (error) {
15479
- progress?.fail(replyContextFailureMessage);
15480
- throw error;
15481
- }
15482
- })();
15483
- from = replyContext.from;
15484
- parentReply = replyContext.parentReply;
15485
- subject = derivedReplySubject(replyContext.parentReply);
15486
- } else {
15487
- from = flags.from ?? await pickDefaultFromAddress(apiClient, authFailureContext);
15488
- subject = flags.subject ?? deriveSubject(message);
15489
- }
15490
- const sentAtIso = (/* @__PURE__ */ new Date()).toISOString();
15491
- if (replyMode) progress?.update(`Sending reply to ${args.recipient}`);
15492
- else progress?.start(`Sending message to ${args.recipient}`);
15493
- const sendResult = parentReply !== void 0 ? await replyToEmail({
15494
- body: {
15495
- body_text: message,
15496
- from,
15497
- ...attachments !== void 0 ? { attachments } : {}
15498
- },
15499
- client: apiClient.client,
15500
- path: { id: parentReply.id },
15501
- responseStyle: "fields"
15502
- }) : await sendEmail({
15503
- body: {
15504
- from,
15505
- to: args.recipient,
15506
- subject,
15507
- body_text: message,
15508
- ...flags["in-reply-to"] !== void 0 ? { in_reply_to: flags["in-reply-to"] } : {},
15509
- ...attachments !== void 0 ? { attachments } : {}
15510
- },
15511
- client: apiClient.client,
15512
- responseStyle: "fields"
15513
- });
15514
- if (sendResult.error) {
15515
- progress?.fail(replyMode ? "Reply send failed." : "Message send failed.");
15516
- const errorPayload = extractErrorPayload(sendResult.error);
15517
- writeErrorWithHints(errorPayload);
15518
- surfaceUnauthorizedHint({
15519
- ...authFailureContext,
15520
- payload: errorPayload
15703
+ })();
15704
+ from = replyContext.from;
15705
+ parentReply = replyContext.parentReply;
15706
+ subject = derivedReplySubject(replyContext.parentReply);
15707
+ } else {
15708
+ from = flags.from ?? await pickDefaultFromAddress(apiClient, authFailureContext);
15709
+ subject = flags.subject ?? deriveSubject(message);
15710
+ }
15711
+ const sentAtIso = (/* @__PURE__ */ new Date()).toISOString();
15712
+ if (replyMode) progress?.update(`Sending reply to ${args.recipient}`);
15713
+ else progress?.start(`Sending message to ${args.recipient}`);
15714
+ const sendResult = parentReply !== void 0 ? await replyToEmail({
15715
+ body: {
15716
+ body_text: message,
15717
+ from,
15718
+ ...attachments !== void 0 ? { attachments } : {}
15719
+ },
15720
+ client: apiClient.client,
15721
+ path: { id: parentReply.id },
15722
+ responseStyle: "fields"
15723
+ }) : await sendEmail({
15724
+ body: {
15725
+ from,
15726
+ to: args.recipient,
15727
+ subject,
15728
+ body_text: message,
15729
+ ...flags["in-reply-to"] !== void 0 ? { in_reply_to: flags["in-reply-to"] } : {},
15730
+ ...attachments !== void 0 ? { attachments } : {}
15731
+ },
15732
+ client: apiClient.client,
15733
+ responseStyle: "fields"
15521
15734
  });
15522
- process.exitCode = 1;
15523
- return;
15524
- }
15525
- const sent = sendResult.data?.data;
15526
- if (!sent) {
15527
- progress?.fail("Send succeeded but the API returned no data.");
15528
- throw cliError$6("Send succeeded but the API returned no data.");
15529
- }
15530
- const replyAddress = sent.from || from;
15531
- progress?.update(`${replyMode ? "Reply" : "Message"} sent; waiting for reply from ${args.recipient}`, {
15532
- heartbeatMs: 15e3,
15533
- timeoutSeconds: flags.timeout
15534
- });
15535
- const baseContext = {
15536
- from: replyAddress,
15537
- json: flags.json,
15538
- parentReply,
15539
- quiet: flags.quiet,
15540
- recipient: args.recipient,
15541
- sent,
15542
- sentAtIso,
15543
- strictOnly: flags["strict-only"],
15544
- strictPhaseSeconds: flags["strict-phase-seconds"],
15545
- subject,
15546
- timeoutSeconds: flags.timeout
15547
- };
15548
- let replyResult;
15549
- try {
15550
- replyResult = await waitForReply({
15551
- apiClient,
15552
- authFailureContext,
15735
+ if (sendResult.error) {
15736
+ progress?.fail(replyMode ? "Reply send failed." : "Message send failed.");
15737
+ const errorPayload = extractErrorPayload(sendResult.error);
15738
+ writeErrorWithHints(errorPayload);
15739
+ surfaceUnauthorizedHint({
15740
+ ...authFailureContext,
15741
+ payload: errorPayload
15742
+ });
15743
+ process.exitCode = 1;
15744
+ return;
15745
+ }
15746
+ const sent = sendResult.data?.data;
15747
+ if (!sent) {
15748
+ progress?.fail("Send succeeded but the API returned no data.");
15749
+ throw cliError$6("Send succeeded but the API returned no data.");
15750
+ }
15751
+ const replyAddress = sent.from || from;
15752
+ const baseContext = {
15553
15753
  from: replyAddress,
15554
- interval: flags.interval,
15555
- notice: (message) => {
15556
- if (progress) {
15557
- progress.notice(message);
15558
- return;
15559
- }
15560
- process.stderr.write(`${message}\n`);
15561
- },
15562
- pageSize: flags["page-size"],
15754
+ json: flags.json,
15755
+ parentReply,
15756
+ quiet: flags.quiet,
15563
15757
  recipient: args.recipient,
15758
+ sent,
15564
15759
  sentAtIso,
15565
- sentId: sent.id,
15566
15760
  strictOnly: flags["strict-only"],
15567
15761
  strictPhaseSeconds: flags["strict-phase-seconds"],
15762
+ subject,
15763
+ timeoutSeconds: flags.timeout
15764
+ };
15765
+ if (sent.idempotent_replay) {
15766
+ progress?.update("Server returned idempotent_replay: looking up the existing reply");
15767
+ const replyId = await resolveIdempotentReplayReply(await fetchEmailSearchPage({
15768
+ apiClient,
15769
+ cursor: null,
15770
+ filters: { replyToSentEmailId: sent.id },
15771
+ pageSize: flags["page-size"]
15772
+ }));
15773
+ if (replyId) {
15774
+ const full = await getEmail({
15775
+ client: apiClient.client,
15776
+ path: { id: replyId },
15777
+ responseStyle: "fields"
15778
+ });
15779
+ if (full.error) {
15780
+ const payload = extractErrorPayload(full.error);
15781
+ writeErrorWithHints(payload);
15782
+ surfaceUnauthorizedHint({
15783
+ ...authFailureContext,
15784
+ payload
15785
+ });
15786
+ throw cliError$6(`Idempotent replay: existing reply found but fetching it failed (id=${replyId}).`);
15787
+ }
15788
+ const envelope = full.data;
15789
+ const detail = envelope?.data ?? envelope ?? null;
15790
+ if (!detail) throw cliError$6(`Idempotent replay: existing reply body could not be loaded (id=${replyId}).`);
15791
+ progress?.succeed(`Idempotent replay; surfacing existing reply from ${detail.from_email ?? args.recipient}`);
15792
+ let outputContext = {
15793
+ ...baseContext,
15794
+ matchStrategy: "strict",
15795
+ reply: detail
15796
+ };
15797
+ const localChatId = persistActiveChat({
15798
+ configDir: this.config.configDir,
15799
+ context: outputContext,
15800
+ preferredLocalId: flags["chat-local-id"],
15801
+ writeWarning: (message) => process.stderr.write(message)
15802
+ });
15803
+ if (localChatId !== null) outputContext = {
15804
+ ...outputContext,
15805
+ localChatId
15806
+ };
15807
+ if (flags.json) this.log(JSON.stringify(buildChatJsonEnvelope(outputContext), null, 2));
15808
+ else this.log(formatChatResponse(outputContext));
15809
+ return;
15810
+ }
15811
+ progress?.fail("Server deduplicated this send (idempotent_replay: true).");
15812
+ process.stderr.write(`${chatNoticeText(`The server detected this exact content was sent earlier and did not put a new message on the wire. The original send (sent.id=${sent.id}) has not received a reply yet. Vary the body — or change the parent (reply to a different inbound) — to retry with a fresh send.`)}\n`);
15813
+ process.exitCode = 1;
15814
+ return;
15815
+ }
15816
+ progress?.update(`${replyMode ? "Reply" : "Message"} sent; waiting for reply from ${args.recipient}`, {
15817
+ heartbeatMs: 15e3,
15568
15818
  timeoutSeconds: flags.timeout
15569
15819
  });
15570
- } catch (error) {
15571
- progress?.fail("Reply polling failed.");
15572
- process.stderr.write(`${formatChatRecoveryContext(baseContext)}\n`);
15573
- throw error;
15574
- }
15575
- if (replyResult === null) {
15576
- const timeoutMessage = `Timed out after ${flags.timeout}s waiting for a reply from ${args.recipient}.`;
15577
- progress?.fail(timeoutMessage);
15578
- if (progress === null) process.stderr.write(`${timeoutMessage}\n`);
15579
- process.stderr.write(`${formatChatRecoveryContext(baseContext)}\n`);
15580
- process.exitCode = 1;
15581
- return;
15820
+ let replyResult;
15821
+ try {
15822
+ replyResult = await waitForReply({
15823
+ apiClient,
15824
+ authFailureContext,
15825
+ from: replyAddress,
15826
+ interval: flags.interval,
15827
+ notice: (message) => {
15828
+ if (progress) {
15829
+ progress.notice(message);
15830
+ return;
15831
+ }
15832
+ process.stderr.write(`${message}\n`);
15833
+ },
15834
+ pageSize: flags["page-size"],
15835
+ recipient: args.recipient,
15836
+ sentAtIso,
15837
+ sentId: sent.id,
15838
+ strictOnly: flags["strict-only"],
15839
+ strictPhaseSeconds: flags["strict-phase-seconds"],
15840
+ timeoutSeconds: flags.timeout
15841
+ });
15842
+ } catch (error) {
15843
+ progress?.fail("Reply polling failed.");
15844
+ process.stderr.write(`${formatChatRecoveryContext(baseContext)}\n`);
15845
+ throw error;
15846
+ }
15847
+ if (replyResult === null) {
15848
+ const timeoutMessage = `Timed out after ${flags.timeout}s waiting for a reply from ${args.recipient}.`;
15849
+ progress?.fail(timeoutMessage);
15850
+ if (progress === null) process.stderr.write(`${timeoutMessage}\n`);
15851
+ process.stderr.write(`${formatChatRecoveryContext(baseContext)}\n`);
15852
+ process.exitCode = 1;
15853
+ return;
15854
+ }
15855
+ progress?.succeed(`Reply received from ${replyResult.reply.from_email}`);
15856
+ let outputContext = {
15857
+ ...baseContext,
15858
+ matchStrategy: replyResult.matchStrategy,
15859
+ reply: replyResult.reply
15860
+ };
15861
+ const localChatId = persistActiveChat({
15862
+ configDir: this.config.configDir,
15863
+ context: outputContext,
15864
+ preferredLocalId: flags["chat-local-id"],
15865
+ writeWarning: (message) => process.stderr.write(message)
15866
+ });
15867
+ if (localChatId !== null) outputContext = {
15868
+ ...outputContext,
15869
+ localChatId
15870
+ };
15871
+ if (flags.json) this.log(JSON.stringify(buildChatJsonEnvelope(outputContext), null, 2));
15872
+ else this.log(formatChatResponse(outputContext));
15873
+ } finally {
15874
+ releaseLock();
15582
15875
  }
15583
- progress?.succeed(`Reply received from ${replyResult.reply.from_email}`);
15584
- let outputContext = {
15585
- ...baseContext,
15586
- matchStrategy: replyResult.matchStrategy,
15587
- reply: replyResult.reply
15588
- };
15589
- const localChatId = persistActiveChat({
15590
- configDir: this.config.configDir,
15591
- context: outputContext,
15592
- preferredLocalId: flags["chat-local-id"],
15593
- writeWarning: (message) => process.stderr.write(message)
15594
- });
15595
- if (localChatId !== null) outputContext = {
15596
- ...outputContext,
15597
- localChatId
15598
- };
15599
- if (flags.json) this.log(JSON.stringify(buildChatJsonEnvelope(outputContext), null, 2));
15600
- else this.log(formatChatResponse(outputContext));
15601
15876
  });
15602
15877
  }
15603
15878
  };
@@ -15675,38 +15950,49 @@ var ChatReplyCommand = class ChatReplyCommand extends Command {
15675
15950
  const positionalLocalId = flags.id === void 0 && args.message !== void 0 ? parseLocalChatIdArg(args.idOrMessage) : void 0;
15676
15951
  if (flags.id === void 0 && args.message !== void 0 && positionalLocalId === null) throw cliError$6("When passing two positional arguments to `primitive chat reply`, the first must be a local chat id. Use `primitive chat reply '<message>'` for the active chat or `primitive chat reply --id <id> '<message>'` for a specific chat.");
15677
15952
  if (flags.id !== void 0 && args.message !== void 0) throw cliError$6("With --id, pass the reply body as a single positional argument or pipe it via stdin.");
15678
- const localId = flags.id ?? (typeof positionalLocalId === "number" ? positionalLocalId : void 0);
15679
- const state = localId === void 0 ? loadActiveChatState(this.config.configDir) : loadChatConversationByLocalId(this.config.configDir, localId);
15680
- if (!state) throw cliError$6(localId === void 0 ? "No open chat. Start one with `primitive chat <email> '<message>'`." : `No local chat ${localId}. Start one with \`primitive chat <email> '<message>'\` or omit --id to use the active chat.`);
15681
- const message = args.message !== void 0 ? args.message : args.idOrMessage !== void 0 && args.idOrMessage !== "" ? args.idOrMessage : await readStdinToString("No reply body provided. Pass the reply body as a positional argument or pipe it via stdin.");
15682
- if (!message.trim()) throw cliError$6("Reply body is empty.");
15683
- const argv = [
15684
- state.recipient,
15685
- "--reply",
15686
- message,
15687
- "--from",
15688
- state.from,
15689
- "--reply-to-email-id",
15690
- state.last_reply_email_id,
15691
- "--timeout",
15692
- String(flags.timeout ?? state.timeout_seconds),
15693
- "--strict-phase-seconds",
15694
- String(flags["strict-phase-seconds"] ?? state.strict_phase_seconds),
15695
- "--interval",
15696
- String(flags.interval ?? 2),
15697
- "--page-size",
15698
- String(flags["page-size"] ?? 50),
15699
- "--chat-local-id",
15700
- String(state.local_id)
15701
- ];
15702
- if (flags["api-key"] !== void 0) argv.push("--api-key", flags["api-key"]);
15703
- if (flags["api-base-url"] !== void 0) argv.push("--api-base-url", flags["api-base-url"]);
15704
- if (flags.json) argv.push("--json");
15705
- if (flags.quiet) argv.push("--quiet");
15706
- for (const attachment of flags.attachment ?? []) argv.push("--attachment", attachment);
15707
- if (state.strict_only || flags["strict-only"]) argv.push("--strict-only");
15708
- if (flags.time) argv.push("--time");
15709
- await ChatCommand.run(argv, { root: this.config.root });
15953
+ let release;
15954
+ try {
15955
+ release = acquireChatLock(this.config.configDir);
15956
+ } catch (err) {
15957
+ if (err instanceof ChatLockContentionError) throw cliError$6(err.message);
15958
+ throw err;
15959
+ }
15960
+ try {
15961
+ const localId = flags.id ?? (typeof positionalLocalId === "number" ? positionalLocalId : void 0);
15962
+ const state = localId === void 0 ? loadActiveChatState(this.config.configDir) : loadChatConversationByLocalId(this.config.configDir, localId);
15963
+ if (!state) throw cliError$6(localId === void 0 ? "No open chat. Start one with `primitive chat <email> '<message>'`." : `No local chat ${localId}. Start one with \`primitive chat <email> '<message>'\` or omit --id to use the active chat.`);
15964
+ const message = args.message !== void 0 ? args.message : args.idOrMessage !== void 0 && args.idOrMessage !== "" ? args.idOrMessage : await readStdinToString("No reply body provided. Pass the reply body as a positional argument or pipe it via stdin.");
15965
+ if (!message.trim()) throw cliError$6("Reply body is empty.");
15966
+ const argv = [
15967
+ state.recipient,
15968
+ "--reply",
15969
+ message,
15970
+ "--from",
15971
+ state.from,
15972
+ "--reply-to-email-id",
15973
+ state.last_reply_email_id,
15974
+ "--timeout",
15975
+ String(flags.timeout ?? state.timeout_seconds),
15976
+ "--strict-phase-seconds",
15977
+ String(flags["strict-phase-seconds"] ?? state.strict_phase_seconds),
15978
+ "--interval",
15979
+ String(flags.interval ?? 2),
15980
+ "--page-size",
15981
+ String(flags["page-size"] ?? 50),
15982
+ "--chat-local-id",
15983
+ String(state.local_id)
15984
+ ];
15985
+ if (flags["api-key"] !== void 0) argv.push("--api-key", flags["api-key"]);
15986
+ if (flags["api-base-url"] !== void 0) argv.push("--api-base-url", flags["api-base-url"]);
15987
+ if (flags.json) argv.push("--json");
15988
+ if (flags.quiet) argv.push("--quiet");
15989
+ for (const attachment of flags.attachment ?? []) argv.push("--attachment", attachment);
15990
+ if (state.strict_only || flags["strict-only"]) argv.push("--strict-only");
15991
+ if (flags.time) argv.push("--time");
15992
+ await ChatCommand.run(argv, { root: this.config.root });
15993
+ } finally {
15994
+ release();
15995
+ }
15710
15996
  }
15711
15997
  };
15712
15998
  async function waitForReply(params) {
@@ -17684,8 +17970,8 @@ const PRIMITIVE_TEAM_AUTHOR = {
17684
17970
  name: "Primitive Team",
17685
17971
  url: "https://primitive.dev"
17686
17972
  };
17687
- const SDK_VERSION_RANGE = "^0.37.0";
17688
- const CLI_VERSION_RANGE = "^0.37.0";
17973
+ const SDK_VERSION_RANGE = "^1.0.0";
17974
+ const CLI_VERSION_RANGE = "^1.0.0";
17689
17975
  const ESBUILD_VERSION_RANGE = "^0.27.0";
17690
17976
  function renderHandler() {
17691
17977
  return `// env.PRIMITIVE_API_KEY, env.PRIMITIVE_WEBHOOK_SECRET, and
@@ -20189,22 +20475,6 @@ async function checkExistingCredentials(params) {
20189
20475
  }
20190
20476
  throw cliError$2(`Already logged in${existing.org_name ? ` for ${existing.org_name}` : ""}. Run \`primitive logout\` before ${copy.actionGerund}.`);
20191
20477
  }
20192
- function saveSignupCredentials(params) {
20193
- deleteChatState(params.configDir);
20194
- saveCliCredentials(params.configDir, {
20195
- access_token: params.signup.access_token,
20196
- api_base_url: params.apiBaseUrl,
20197
- auth_method: "oauth",
20198
- created_at: (/* @__PURE__ */ new Date()).toISOString(),
20199
- expires_at: cliAccessTokenExpiresAt(params.signup.expires_in),
20200
- oauth_client_id: params.signup.oauth_client_id,
20201
- oauth_grant_id: params.signup.oauth_grant_id,
20202
- org_id: params.signup.org_id,
20203
- org_name: params.signup.org_name,
20204
- refresh_token: params.signup.refresh_token,
20205
- token_type: params.signup.token_type
20206
- });
20207
- }
20208
20478
  function writeStartInstructions(start, copy = DEFAULT_SIGNUP_COMMAND_COPY) {
20209
20479
  process$1.stderr.write(`Sent a ${start.verification_code_length}-digit verification code to ${start.email}.\n`);
20210
20480
  process$1.stderr.write(`The code expires in ${formatSignupSeconds(start.expires_in)}.\n`);
@@ -1,4 +1,4 @@
1
- import { c as resolveConfigEnvironment, i as loadCliConfig, x as normalizeApiBaseUrl } from "../cli-config-D7wN_PBc.js";
1
+ import { c as resolveConfigEnvironment, i as loadCliConfig, x as normalizeApiBaseUrl } from "../cli-config-B5hrwe8q.js";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { homedir } from "node:os";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitivedotdev/cli",
3
- "version": "0.37.0",
3
+ "version": "1.0.0",
4
4
  "description": "Official Primitive CLI: deploy Primitive Functions, send and inspect mail, manage endpoints, all from the terminal. Wraps the @primitivedotdev/sdk runtime client with one-shot commands.",
5
5
  "type": "module",
6
6
  "sideEffects": false,