@primitivedotdev/cli 0.38.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.
- package/dist/oclif/index.js +458 -196
- package/package.json +1 -1
package/dist/oclif/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
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";
|
|
@@ -14742,6 +14742,173 @@ function readAttachmentFiles(paths, readFile = readFileSync) {
|
|
|
14742
14742
|
});
|
|
14743
14743
|
}
|
|
14744
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
|
|
14745
14912
|
//#region src/oclif/outbound-defaults.ts
|
|
14746
14913
|
const SUBJECT_MAX_LENGTH = 200;
|
|
14747
14914
|
function deriveSubject(body) {
|
|
@@ -15055,7 +15222,7 @@ function matchDescription(strategy) {
|
|
|
15055
15222
|
return strategy === "strict" ? "strict, matched by reply_to_sent_email_id" : "fallback, matched by sender/time window";
|
|
15056
15223
|
}
|
|
15057
15224
|
function normalizeEmailAddress(value) {
|
|
15058
|
-
return value.trim().toLowerCase();
|
|
15225
|
+
return (value.match(/<([^>]+)>/)?.[1] ?? value).trim().toLowerCase();
|
|
15059
15226
|
}
|
|
15060
15227
|
function derivedReplySubject(parent) {
|
|
15061
15228
|
const subject = parent.subject?.trim();
|
|
@@ -15233,6 +15400,28 @@ function persistActiveChat(params) {
|
|
|
15233
15400
|
return null;
|
|
15234
15401
|
}
|
|
15235
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
|
+
}
|
|
15236
15425
|
function formatChatResponse(context) {
|
|
15237
15426
|
const accepted = context.sent.accepted.join(", ") || context.recipient;
|
|
15238
15427
|
const responseBody = resolveChatResponseBody(context.reply);
|
|
@@ -15447,181 +15636,243 @@ var ChatCommand = class ChatCommand extends Command {
|
|
|
15447
15636
|
const message = flags.reply !== void 0 ? flags.reply : args.message !== void 0 && args.message !== "" ? args.message : await readStdinToString();
|
|
15448
15637
|
if (!message.trim()) throw cliError$6(replyMode ? "Reply body is empty." : "Message body is empty.");
|
|
15449
15638
|
await runWithTiming(flags.time, async () => {
|
|
15450
|
-
|
|
15451
|
-
|
|
15452
|
-
|
|
15453
|
-
|
|
15454
|
-
|
|
15455
|
-
|
|
15456
|
-
|
|
15457
|
-
|
|
15458
|
-
|
|
15459
|
-
|
|
15460
|
-
|
|
15461
|
-
|
|
15462
|
-
|
|
15463
|
-
|
|
15464
|
-
|
|
15465
|
-
|
|
15466
|
-
|
|
15467
|
-
|
|
15468
|
-
|
|
15469
|
-
|
|
15470
|
-
|
|
15471
|
-
|
|
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({
|
|
15472
15683
|
apiClient,
|
|
15473
15684
|
authFailureContext,
|
|
15474
|
-
|
|
15685
|
+
from: replyFrom,
|
|
15686
|
+
pageSize: flags["page-size"],
|
|
15687
|
+
recipient: args.recipient
|
|
15475
15688
|
});
|
|
15476
|
-
|
|
15477
|
-
|
|
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);
|
|
15478
15695
|
return {
|
|
15479
|
-
from:
|
|
15480
|
-
parentReply:
|
|
15696
|
+
from: replyFrom,
|
|
15697
|
+
parentReply: latestParentReply
|
|
15481
15698
|
};
|
|
15699
|
+
} catch (error) {
|
|
15700
|
+
progress?.fail(replyContextFailureMessage);
|
|
15701
|
+
throw error;
|
|
15482
15702
|
}
|
|
15483
|
-
|
|
15484
|
-
|
|
15485
|
-
|
|
15486
|
-
|
|
15487
|
-
|
|
15488
|
-
|
|
15489
|
-
|
|
15490
|
-
|
|
15491
|
-
|
|
15492
|
-
|
|
15493
|
-
|
|
15494
|
-
|
|
15495
|
-
|
|
15496
|
-
|
|
15497
|
-
|
|
15498
|
-
|
|
15499
|
-
|
|
15500
|
-
|
|
15501
|
-
|
|
15502
|
-
|
|
15503
|
-
|
|
15504
|
-
|
|
15505
|
-
|
|
15506
|
-
|
|
15507
|
-
|
|
15508
|
-
|
|
15509
|
-
|
|
15510
|
-
|
|
15511
|
-
|
|
15512
|
-
|
|
15513
|
-
|
|
15514
|
-
const sentAtIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
15515
|
-
if (replyMode) progress?.update(`Sending reply to ${args.recipient}`);
|
|
15516
|
-
else progress?.start(`Sending message to ${args.recipient}`);
|
|
15517
|
-
const sendResult = parentReply !== void 0 ? await replyToEmail({
|
|
15518
|
-
body: {
|
|
15519
|
-
body_text: message,
|
|
15520
|
-
from,
|
|
15521
|
-
...attachments !== void 0 ? { attachments } : {}
|
|
15522
|
-
},
|
|
15523
|
-
client: apiClient.client,
|
|
15524
|
-
path: { id: parentReply.id },
|
|
15525
|
-
responseStyle: "fields"
|
|
15526
|
-
}) : await sendEmail({
|
|
15527
|
-
body: {
|
|
15528
|
-
from,
|
|
15529
|
-
to: args.recipient,
|
|
15530
|
-
subject,
|
|
15531
|
-
body_text: message,
|
|
15532
|
-
...flags["in-reply-to"] !== void 0 ? { in_reply_to: flags["in-reply-to"] } : {},
|
|
15533
|
-
...attachments !== void 0 ? { attachments } : {}
|
|
15534
|
-
},
|
|
15535
|
-
client: apiClient.client,
|
|
15536
|
-
responseStyle: "fields"
|
|
15537
|
-
});
|
|
15538
|
-
if (sendResult.error) {
|
|
15539
|
-
progress?.fail(replyMode ? "Reply send failed." : "Message send failed.");
|
|
15540
|
-
const errorPayload = extractErrorPayload(sendResult.error);
|
|
15541
|
-
writeErrorWithHints(errorPayload);
|
|
15542
|
-
surfaceUnauthorizedHint({
|
|
15543
|
-
...authFailureContext,
|
|
15544
|
-
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"
|
|
15545
15734
|
});
|
|
15546
|
-
|
|
15547
|
-
|
|
15548
|
-
|
|
15549
|
-
|
|
15550
|
-
|
|
15551
|
-
|
|
15552
|
-
|
|
15553
|
-
|
|
15554
|
-
|
|
15555
|
-
|
|
15556
|
-
|
|
15557
|
-
|
|
15558
|
-
|
|
15559
|
-
|
|
15560
|
-
|
|
15561
|
-
|
|
15562
|
-
|
|
15563
|
-
|
|
15564
|
-
recipient: args.recipient,
|
|
15565
|
-
sent,
|
|
15566
|
-
sentAtIso,
|
|
15567
|
-
strictOnly: flags["strict-only"],
|
|
15568
|
-
strictPhaseSeconds: flags["strict-phase-seconds"],
|
|
15569
|
-
subject,
|
|
15570
|
-
timeoutSeconds: flags.timeout
|
|
15571
|
-
};
|
|
15572
|
-
let replyResult;
|
|
15573
|
-
try {
|
|
15574
|
-
replyResult = await waitForReply({
|
|
15575
|
-
apiClient,
|
|
15576
|
-
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 = {
|
|
15577
15753
|
from: replyAddress,
|
|
15578
|
-
|
|
15579
|
-
|
|
15580
|
-
|
|
15581
|
-
progress.notice(message);
|
|
15582
|
-
return;
|
|
15583
|
-
}
|
|
15584
|
-
process.stderr.write(`${message}\n`);
|
|
15585
|
-
},
|
|
15586
|
-
pageSize: flags["page-size"],
|
|
15754
|
+
json: flags.json,
|
|
15755
|
+
parentReply,
|
|
15756
|
+
quiet: flags.quiet,
|
|
15587
15757
|
recipient: args.recipient,
|
|
15758
|
+
sent,
|
|
15588
15759
|
sentAtIso,
|
|
15589
|
-
sentId: sent.id,
|
|
15590
15760
|
strictOnly: flags["strict-only"],
|
|
15591
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,
|
|
15592
15818
|
timeoutSeconds: flags.timeout
|
|
15593
15819
|
});
|
|
15594
|
-
|
|
15595
|
-
|
|
15596
|
-
|
|
15597
|
-
|
|
15598
|
-
|
|
15599
|
-
|
|
15600
|
-
|
|
15601
|
-
|
|
15602
|
-
|
|
15603
|
-
|
|
15604
|
-
|
|
15605
|
-
|
|
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();
|
|
15606
15875
|
}
|
|
15607
|
-
progress?.succeed(`Reply received from ${replyResult.reply.from_email}`);
|
|
15608
|
-
let outputContext = {
|
|
15609
|
-
...baseContext,
|
|
15610
|
-
matchStrategy: replyResult.matchStrategy,
|
|
15611
|
-
reply: replyResult.reply
|
|
15612
|
-
};
|
|
15613
|
-
const localChatId = persistActiveChat({
|
|
15614
|
-
configDir: this.config.configDir,
|
|
15615
|
-
context: outputContext,
|
|
15616
|
-
preferredLocalId: flags["chat-local-id"],
|
|
15617
|
-
writeWarning: (message) => process.stderr.write(message)
|
|
15618
|
-
});
|
|
15619
|
-
if (localChatId !== null) outputContext = {
|
|
15620
|
-
...outputContext,
|
|
15621
|
-
localChatId
|
|
15622
|
-
};
|
|
15623
|
-
if (flags.json) this.log(JSON.stringify(buildChatJsonEnvelope(outputContext), null, 2));
|
|
15624
|
-
else this.log(formatChatResponse(outputContext));
|
|
15625
15876
|
});
|
|
15626
15877
|
}
|
|
15627
15878
|
};
|
|
@@ -15699,38 +15950,49 @@ var ChatReplyCommand = class ChatReplyCommand extends Command {
|
|
|
15699
15950
|
const positionalLocalId = flags.id === void 0 && args.message !== void 0 ? parseLocalChatIdArg(args.idOrMessage) : void 0;
|
|
15700
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.");
|
|
15701
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.");
|
|
15702
|
-
|
|
15703
|
-
|
|
15704
|
-
|
|
15705
|
-
|
|
15706
|
-
|
|
15707
|
-
|
|
15708
|
-
|
|
15709
|
-
|
|
15710
|
-
|
|
15711
|
-
|
|
15712
|
-
state.
|
|
15713
|
-
"
|
|
15714
|
-
|
|
15715
|
-
|
|
15716
|
-
|
|
15717
|
-
|
|
15718
|
-
|
|
15719
|
-
|
|
15720
|
-
|
|
15721
|
-
|
|
15722
|
-
|
|
15723
|
-
|
|
15724
|
-
|
|
15725
|
-
|
|
15726
|
-
|
|
15727
|
-
|
|
15728
|
-
|
|
15729
|
-
|
|
15730
|
-
|
|
15731
|
-
|
|
15732
|
-
|
|
15733
|
-
|
|
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
|
+
}
|
|
15734
15996
|
}
|
|
15735
15997
|
};
|
|
15736
15998
|
async function waitForReply(params) {
|
|
@@ -17708,8 +17970,8 @@ const PRIMITIVE_TEAM_AUTHOR = {
|
|
|
17708
17970
|
name: "Primitive Team",
|
|
17709
17971
|
url: "https://primitive.dev"
|
|
17710
17972
|
};
|
|
17711
|
-
const SDK_VERSION_RANGE = "^0.
|
|
17712
|
-
const CLI_VERSION_RANGE = "^0.
|
|
17973
|
+
const SDK_VERSION_RANGE = "^1.0.0";
|
|
17974
|
+
const CLI_VERSION_RANGE = "^1.0.0";
|
|
17713
17975
|
const ESBUILD_VERSION_RANGE = "^0.27.0";
|
|
17714
17976
|
function renderHandler() {
|
|
17715
17977
|
return `// env.PRIMITIVE_API_KEY, env.PRIMITIVE_WEBHOOK_SECRET, and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@primitivedotdev/cli",
|
|
3
|
-
"version": "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,
|