@primitivedotdev/cli 0.24.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/README.md +47 -0
- package/bin/run.js +5 -0
- package/dist/oclif/api-command.js +755 -0
- package/dist/oclif/auth.js +223 -0
- package/dist/oclif/commands/emails-latest.js +184 -0
- package/dist/oclif/commands/emails-poll.js +121 -0
- package/dist/oclif/commands/emails-wait.js +171 -0
- package/dist/oclif/commands/emails-watch.js +165 -0
- package/dist/oclif/commands/functions-deploy.js +123 -0
- package/dist/oclif/commands/functions-init.js +262 -0
- package/dist/oclif/commands/functions-redeploy.js +112 -0
- package/dist/oclif/commands/functions-set-secret.js +212 -0
- package/dist/oclif/commands/login.js +236 -0
- package/dist/oclif/commands/logout.js +87 -0
- package/dist/oclif/commands/send.js +221 -0
- package/dist/oclif/commands/whoami.js +94 -0
- package/dist/oclif/fish-completion.js +87 -0
- package/dist/oclif/index.js +167 -0
- package/dist/oclif/lint/raw-send-mail-fetch.js +98 -0
- package/oclif.manifest.json +4287 -0
- package/package.json +108 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import { Command, Errors, Flags } from "@oclif/core";
|
|
4
|
+
import { getAccount, PrimitiveApiClient, pollCliLogin, startCliLogin, } from "@primitivedotdev/sdk/api";
|
|
5
|
+
import { API_ERROR_CODES, extractErrorCode, extractErrorPayload, removeStaleSavedCredentialOnUnauthorized, writeErrorWithHints, } from "../api-command.js";
|
|
6
|
+
import { acquireCliCredentialsLock, credentialsPath, loadCliCredentials, normalizeApiBaseUrl1, normalizeApiBaseUrl2, saveCliCredentials, } from "../auth.js";
|
|
7
|
+
const MAX_CLI_LOGIN_POLL_INTERVAL_SECONDS = 60;
|
|
8
|
+
function cliError(message) {
|
|
9
|
+
return new Errors.CLIError(message, { exit: 1 });
|
|
10
|
+
}
|
|
11
|
+
function sleep(ms) {
|
|
12
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
|
+
}
|
|
14
|
+
function openBrowser(url) {
|
|
15
|
+
const command = process.platform === "darwin"
|
|
16
|
+
? "open"
|
|
17
|
+
: process.platform === "win32"
|
|
18
|
+
? "cmd"
|
|
19
|
+
: "xdg-open";
|
|
20
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
21
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
22
|
+
child.on("error", () => undefined);
|
|
23
|
+
child.unref();
|
|
24
|
+
}
|
|
25
|
+
function unwrapData(value) {
|
|
26
|
+
const envelope = value;
|
|
27
|
+
return envelope?.data ?? null;
|
|
28
|
+
}
|
|
29
|
+
function retryAfterSeconds(result) {
|
|
30
|
+
const response = result.response;
|
|
31
|
+
const raw = response?.headers.get("retry-after");
|
|
32
|
+
if (!raw)
|
|
33
|
+
return null;
|
|
34
|
+
const parsed = Number.parseInt(raw, 10);
|
|
35
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
36
|
+
}
|
|
37
|
+
export async function checkExistingLogin(params) {
|
|
38
|
+
const baseUrlOverridden = params.apiBaseUrl1 !== undefined;
|
|
39
|
+
const probeApiBaseUrl1 = baseUrlOverridden
|
|
40
|
+
? normalizeApiBaseUrl1(params.apiBaseUrl1)
|
|
41
|
+
: params.credentials.api_base_url_1;
|
|
42
|
+
const apiClient = new PrimitiveApiClient({
|
|
43
|
+
apiKey: params.credentials.api_key,
|
|
44
|
+
apiBaseUrl1: probeApiBaseUrl1,
|
|
45
|
+
});
|
|
46
|
+
const result = await (params.checkAccount ??
|
|
47
|
+
((client) => getAccount({
|
|
48
|
+
client: client.client,
|
|
49
|
+
responseStyle: "fields",
|
|
50
|
+
})))(apiClient);
|
|
51
|
+
if (!result.error)
|
|
52
|
+
return { status: "valid" };
|
|
53
|
+
const payload = extractErrorPayload(result.error);
|
|
54
|
+
const auth = {
|
|
55
|
+
apiKey: params.credentials.api_key,
|
|
56
|
+
apiBaseUrl1: probeApiBaseUrl1,
|
|
57
|
+
// Host-2 isn't relevant to checkExistingLogin (login is on host-1
|
|
58
|
+
// only), but the auth shape requires it. Use the default.
|
|
59
|
+
apiBaseUrl2: normalizeApiBaseUrl2(undefined),
|
|
60
|
+
credentials: params.credentials,
|
|
61
|
+
source: "stored",
|
|
62
|
+
};
|
|
63
|
+
const removed = removeStaleSavedCredentialOnUnauthorized({
|
|
64
|
+
auth,
|
|
65
|
+
baseUrlOverridden,
|
|
66
|
+
configDir: params.configDir,
|
|
67
|
+
payload,
|
|
68
|
+
});
|
|
69
|
+
if (removed)
|
|
70
|
+
return { status: "removed_stale" };
|
|
71
|
+
const code = extractErrorCode(payload);
|
|
72
|
+
return {
|
|
73
|
+
status: "blocked",
|
|
74
|
+
payload,
|
|
75
|
+
message: code === API_ERROR_CODES.unauthorized
|
|
76
|
+
? "Saved Primitive CLI credentials were rejected. Run `primitive logout` to remove them before logging in again."
|
|
77
|
+
: "A saved Primitive CLI login exists, but the CLI could not verify whether it is still valid. Run `primitive logout` before logging in again.",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
class LoginCommand extends Command {
|
|
81
|
+
static description = "Log in by opening Primitive in your browser and saving an org-scoped CLI API key locally.";
|
|
82
|
+
static summary = "Log in with browser approval";
|
|
83
|
+
static examples = [
|
|
84
|
+
"<%= config.bin %> login",
|
|
85
|
+
"<%= config.bin %> login --device-name work-laptop",
|
|
86
|
+
"<%= config.bin %> login --force",
|
|
87
|
+
];
|
|
88
|
+
static flags = {
|
|
89
|
+
"api-base-url-1": Flags.string({
|
|
90
|
+
description: "Override the primary API base URL. Internal testing only; not documented to customers.",
|
|
91
|
+
env: "PRIMITIVE_API_BASE_URL_1",
|
|
92
|
+
hidden: true,
|
|
93
|
+
}),
|
|
94
|
+
"device-name": Flags.string({
|
|
95
|
+
description: "Device name shown in the browser approval screen",
|
|
96
|
+
}),
|
|
97
|
+
"no-browser": Flags.boolean({
|
|
98
|
+
description: "Do not attempt to open the browser automatically",
|
|
99
|
+
}),
|
|
100
|
+
force: Flags.boolean({
|
|
101
|
+
char: "f",
|
|
102
|
+
description: "Replace saved credentials without first verifying the existing login",
|
|
103
|
+
}),
|
|
104
|
+
};
|
|
105
|
+
async run() {
|
|
106
|
+
const { flags } = await this.parse(LoginCommand);
|
|
107
|
+
let releaseCredentialsLock;
|
|
108
|
+
try {
|
|
109
|
+
releaseCredentialsLock = acquireCliCredentialsLock(this.config.configDir);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
113
|
+
throw cliError(detail);
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
await this.runWithCredentialLock(flags);
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
releaseCredentialsLock();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async runWithCredentialLock(flags) {
|
|
123
|
+
const apiBaseUrl1 = normalizeApiBaseUrl1(flags["api-base-url-1"]);
|
|
124
|
+
let existing;
|
|
125
|
+
try {
|
|
126
|
+
existing = loadCliCredentials(this.config.configDir);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
if (!flags.force)
|
|
130
|
+
throw error;
|
|
131
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
132
|
+
process.stderr.write(`Replacing unreadable Primitive CLI credentials because --force was set: ${detail}\n`);
|
|
133
|
+
existing = null;
|
|
134
|
+
}
|
|
135
|
+
if (existing && flags.force) {
|
|
136
|
+
process.stderr.write("Replacing saved Primitive CLI credentials after browser approval because --force was set.\n");
|
|
137
|
+
}
|
|
138
|
+
else if (existing) {
|
|
139
|
+
const existingStatus = await checkExistingLogin({
|
|
140
|
+
apiBaseUrl1: flags["api-base-url-1"],
|
|
141
|
+
configDir: this.config.configDir,
|
|
142
|
+
credentials: existing,
|
|
143
|
+
});
|
|
144
|
+
if (existingStatus.status === "removed_stale") {
|
|
145
|
+
process.stderr.write("Continuing with a new Primitive CLI login...\n");
|
|
146
|
+
}
|
|
147
|
+
else if (existingStatus.status === "blocked") {
|
|
148
|
+
writeErrorWithHints(existingStatus.payload);
|
|
149
|
+
throw cliError(existingStatus.message);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
const org = existing.org_name ? ` for ${existing.org_name}` : "";
|
|
153
|
+
throw cliError(`Already logged in${org}. Run \`primitive logout\` before logging in again.`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const apiClient = new PrimitiveApiClient({ apiBaseUrl1 });
|
|
157
|
+
const deviceName = flags["device-name"] ?? hostname();
|
|
158
|
+
const started = await startCliLogin({
|
|
159
|
+
body: {
|
|
160
|
+
device_name: deviceName,
|
|
161
|
+
},
|
|
162
|
+
client: apiClient.client,
|
|
163
|
+
responseStyle: "fields",
|
|
164
|
+
});
|
|
165
|
+
if (started.error) {
|
|
166
|
+
writeErrorWithHints(extractErrorPayload(started.error));
|
|
167
|
+
throw cliError("Could not start Primitive CLI login.");
|
|
168
|
+
}
|
|
169
|
+
const start = unwrapData(started.data);
|
|
170
|
+
if (!start) {
|
|
171
|
+
throw cliError("Primitive API returned an empty CLI login response.");
|
|
172
|
+
}
|
|
173
|
+
process.stderr.write(`Your login code is: ${start.user_code}\n`);
|
|
174
|
+
if (!flags["no-browser"]) {
|
|
175
|
+
openBrowser(start.verification_uri_complete);
|
|
176
|
+
process.stderr.write("Opening Primitive in your browser...\n");
|
|
177
|
+
}
|
|
178
|
+
process.stderr.write(`If the browser did not open, visit: ${start.verification_uri_complete}\n`);
|
|
179
|
+
process.stderr.write("Waiting for browser approval...\n");
|
|
180
|
+
const deadline = Date.now() + start.expires_in * 1000;
|
|
181
|
+
let interval = Math.min(Math.max(1, start.interval), MAX_CLI_LOGIN_POLL_INTERVAL_SECONDS);
|
|
182
|
+
let nextPollDelay = 1;
|
|
183
|
+
while (Date.now() < deadline) {
|
|
184
|
+
await sleep(nextPollDelay * 1000);
|
|
185
|
+
nextPollDelay = interval;
|
|
186
|
+
const polled = await pollCliLogin({
|
|
187
|
+
body: { device_code: start.device_code },
|
|
188
|
+
client: apiClient.client,
|
|
189
|
+
responseStyle: "fields",
|
|
190
|
+
});
|
|
191
|
+
if (polled.data) {
|
|
192
|
+
const login = unwrapData(polled.data);
|
|
193
|
+
if (!login) {
|
|
194
|
+
throw cliError("Primitive API returned an empty CLI poll response.");
|
|
195
|
+
}
|
|
196
|
+
saveCliCredentials(this.config.configDir, {
|
|
197
|
+
api_key: login.api_key,
|
|
198
|
+
api_base_url_1: apiBaseUrl1,
|
|
199
|
+
created_at: new Date().toISOString(),
|
|
200
|
+
key_id: login.key_id,
|
|
201
|
+
key_prefix: login.key_prefix,
|
|
202
|
+
org_id: login.org_id,
|
|
203
|
+
org_name: login.org_name,
|
|
204
|
+
});
|
|
205
|
+
const org = login.org_name ? ` (${login.org_name})` : "";
|
|
206
|
+
process.stderr.write(`Logged in to org ${login.org_id}${org}.\n`);
|
|
207
|
+
process.stderr.write(`Saved credentials to ${credentialsPath(this.config.configDir)}.\n`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const payload = extractErrorPayload(polled.error);
|
|
211
|
+
const code = extractErrorCode(payload);
|
|
212
|
+
if (code === API_ERROR_CODES.authorizationPending) {
|
|
213
|
+
nextPollDelay = interval;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (code === API_ERROR_CODES.slowDown) {
|
|
217
|
+
interval = Math.min(retryAfterSeconds(polled) ?? interval + 5, MAX_CLI_LOGIN_POLL_INTERVAL_SECONDS);
|
|
218
|
+
nextPollDelay = interval;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (code === API_ERROR_CODES.accessDenied) {
|
|
222
|
+
throw cliError("Primitive CLI login was denied in the browser.");
|
|
223
|
+
}
|
|
224
|
+
if (code === API_ERROR_CODES.expiredToken) {
|
|
225
|
+
throw cliError("Primitive CLI login expired. Run `primitive login` again.");
|
|
226
|
+
}
|
|
227
|
+
if (code === API_ERROR_CODES.invalidDeviceCode) {
|
|
228
|
+
throw cliError("Primitive CLI login device code is invalid. Run `primitive login` again.");
|
|
229
|
+
}
|
|
230
|
+
writeErrorWithHints(payload);
|
|
231
|
+
throw cliError("Primitive CLI login failed while polling for approval.");
|
|
232
|
+
}
|
|
233
|
+
throw cliError("Primitive CLI login expired. Run `primitive login` again.");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
export default LoginCommand;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Command, Errors, Flags } from "@oclif/core";
|
|
2
|
+
import { cliLogout, PrimitiveApiClient } from "@primitivedotdev/sdk/api";
|
|
3
|
+
import { API_ERROR_CODES, extractErrorCode, extractErrorPayload, writeErrorWithHints, } from "../api-command.js";
|
|
4
|
+
import { acquireCliCredentialsLock, deleteCliCredentials, loadCliCredentials, normalizeApiBaseUrl1, } from "../auth.js";
|
|
5
|
+
function cliError(message) {
|
|
6
|
+
return new Errors.CLIError(message, { exit: 1 });
|
|
7
|
+
}
|
|
8
|
+
function unwrapData(value) {
|
|
9
|
+
const envelope = value;
|
|
10
|
+
return envelope?.data ?? null;
|
|
11
|
+
}
|
|
12
|
+
class LogoutCommand extends Command {
|
|
13
|
+
static description = "Log out by revoking the saved Primitive CLI API key and deleting local credentials.";
|
|
14
|
+
static summary = "Log out and revoke the saved CLI key";
|
|
15
|
+
static examples = ["<%= config.bin %> logout"];
|
|
16
|
+
static flags = {
|
|
17
|
+
"api-base-url-1": Flags.string({
|
|
18
|
+
description: "Override the primary API base URL. Internal testing only; not documented to customers.",
|
|
19
|
+
env: "PRIMITIVE_API_BASE_URL_1",
|
|
20
|
+
hidden: true,
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
async run() {
|
|
24
|
+
const { flags } = await this.parse(LogoutCommand);
|
|
25
|
+
let releaseCredentialsLock;
|
|
26
|
+
try {
|
|
27
|
+
releaseCredentialsLock = acquireCliCredentialsLock(this.config.configDir);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
31
|
+
throw cliError(detail);
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
await this.runWithCredentialLock(flags);
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
releaseCredentialsLock();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async runWithCredentialLock(flags) {
|
|
41
|
+
let credentials;
|
|
42
|
+
try {
|
|
43
|
+
credentials = loadCliCredentials(this.config.configDir);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
deleteCliCredentials(this.config.configDir);
|
|
47
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
48
|
+
process.stderr.write(`Removed unreadable Primitive CLI credentials. Backing API key was not revoked: ${detail}\n`);
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (!credentials) {
|
|
53
|
+
throw cliError("Not logged in. Run `primitive login` to create saved CLI credentials.");
|
|
54
|
+
}
|
|
55
|
+
const apiBaseUrl1 = flags["api-base-url-1"]
|
|
56
|
+
? normalizeApiBaseUrl1(flags["api-base-url-1"])
|
|
57
|
+
: credentials.api_base_url_1;
|
|
58
|
+
const apiClient = new PrimitiveApiClient({
|
|
59
|
+
apiKey: credentials.api_key,
|
|
60
|
+
apiBaseUrl1,
|
|
61
|
+
});
|
|
62
|
+
const result = await cliLogout({
|
|
63
|
+
body: { key_id: credentials.key_id },
|
|
64
|
+
client: apiClient.client,
|
|
65
|
+
responseStyle: "fields",
|
|
66
|
+
});
|
|
67
|
+
if (result.error) {
|
|
68
|
+
const payload = extractErrorPayload(result.error);
|
|
69
|
+
const code = extractErrorCode(payload);
|
|
70
|
+
if (code === API_ERROR_CODES.unauthorized ||
|
|
71
|
+
code === API_ERROR_CODES.notFound) {
|
|
72
|
+
deleteCliCredentials(this.config.configDir);
|
|
73
|
+
writeErrorWithHints(payload);
|
|
74
|
+
process.stderr.write("Removed saved Primitive CLI credentials because the backing API key is already unavailable.\n");
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
writeErrorWithHints(payload);
|
|
79
|
+
throw cliError("Could not revoke the saved Primitive CLI API key.");
|
|
80
|
+
}
|
|
81
|
+
const logout = unwrapData(result.data);
|
|
82
|
+
deleteCliCredentials(this.config.configDir);
|
|
83
|
+
const keyId = logout?.key_id ?? credentials.key_id;
|
|
84
|
+
process.stderr.write(`Logged out and revoked API key ${keyId}.\n`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export default LogoutCommand;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { Command, Errors, Flags } from "@oclif/core";
|
|
2
|
+
import { listDomains, PrimitiveApiClient, sendEmail, } from "@primitivedotdev/sdk/api";
|
|
3
|
+
import { API_ERROR_CODES, extractErrorCode, extractErrorPayload, formatErrorPayload, removeStaleSavedCredentialOnUnauthorized, runWithTiming, TIME_FLAG_DESCRIPTION, writeErrorWithHints, } from "../api-command.js";
|
|
4
|
+
import { resolveCliAuth } from "../auth.js";
|
|
5
|
+
// `primitive send` is the agent-grade shortcut for the most common
|
|
6
|
+
// case: send a fresh outbound email. It wraps `sending:send-email`
|
|
7
|
+
// with two ergonomic defaults that the underlying operation can't
|
|
8
|
+
// express through manifest-driven flag generation alone:
|
|
9
|
+
//
|
|
10
|
+
// 1. `--from` defaults to `agent@<first-verified-domain>` when
|
|
11
|
+
// omitted. Most agents don't know which domains their org has
|
|
12
|
+
// verified for outbound; making them list-domains first to
|
|
13
|
+
// derive a from-address is exactly the kind of email-ops cruft
|
|
14
|
+
// this command exists to hide. Customers with multiple
|
|
15
|
+
// domains, or who want a different local-part, pass --from
|
|
16
|
+
// explicitly.
|
|
17
|
+
// 2. `--subject` defaults to the first non-empty line of the body
|
|
18
|
+
// (capped). Empty subjects get spam-scored, so we always emit
|
|
19
|
+
// something. Callers who want full control pass --subject.
|
|
20
|
+
//
|
|
21
|
+
// `--body` here is the message body (text). The full `send-email`
|
|
22
|
+
// operation distinguishes `body_text` and `body_html`; this
|
|
23
|
+
// shortcut keeps it simple by exposing `--body` for text and
|
|
24
|
+
// `--html` for the HTML alternative. Users who need both can pass
|
|
25
|
+
// both flags or fall back to `sending:send-email` for the full
|
|
26
|
+
// flag list.
|
|
27
|
+
//
|
|
28
|
+
// Compared to `swaks` (which agents likely have in their training
|
|
29
|
+
// data): this is `swaks`-shaped on purpose so an agent
|
|
30
|
+
// pattern-matching from there lands in the happy path. We just
|
|
31
|
+
// don't need swaks's `--server` / `--auth-*` flags because the
|
|
32
|
+
// HTTPS API key is the auth and the server is implicit.
|
|
33
|
+
// 200 chars is a generous cap that almost never trips on natural
|
|
34
|
+
// first-line subjects (a sentence is typically <120 chars). The
|
|
35
|
+
// previous 70-char limit was tight enough that legitimate one-line
|
|
36
|
+
// bodies routinely produced ellipsis-truncated subjects in inbox
|
|
37
|
+
// listings, e.g. `"this is the simplest possible send: agent typed
|
|
38
|
+
// two flags and hit\\n e..."` from the AGX walkthrough. Real spam
|
|
39
|
+
// scoring engines don't penalize subjects under ~200 chars, so 200
|
|
40
|
+
// is both more useful and still well under the practical wire limit.
|
|
41
|
+
const SUBJECT_MAX_LENGTH = 200;
|
|
42
|
+
function deriveSubject(body) {
|
|
43
|
+
for (const line of body.split("\n")) {
|
|
44
|
+
const trimmed = line.trim();
|
|
45
|
+
if (!trimmed)
|
|
46
|
+
continue;
|
|
47
|
+
return trimmed.length > SUBJECT_MAX_LENGTH
|
|
48
|
+
? `${trimmed.slice(0, SUBJECT_MAX_LENGTH - 3)}...`
|
|
49
|
+
: trimmed;
|
|
50
|
+
}
|
|
51
|
+
return "Message";
|
|
52
|
+
}
|
|
53
|
+
function isVerifiedDomain(domain) {
|
|
54
|
+
return domain.is_active === true;
|
|
55
|
+
}
|
|
56
|
+
async function pickDefaultFromAddress(apiClient, authFailureContext) {
|
|
57
|
+
const result = await listDomains({
|
|
58
|
+
client: apiClient.client,
|
|
59
|
+
responseStyle: "fields",
|
|
60
|
+
});
|
|
61
|
+
if (result.error) {
|
|
62
|
+
const errorPayload = extractErrorPayload(result.error);
|
|
63
|
+
// If the underlying failure is an auth problem, don't pretend
|
|
64
|
+
// --from will fix it: the actual sendEmail call would 401 too.
|
|
65
|
+
// Surface the auth hint via writeErrorWithHints and bail with
|
|
66
|
+
// a focused message instead of the verbose "underlying error"
|
|
67
|
+
// wrapping.
|
|
68
|
+
if (extractErrorCode(errorPayload) === API_ERROR_CODES.unauthorized) {
|
|
69
|
+
writeErrorWithHints(errorPayload);
|
|
70
|
+
removeStaleSavedCredentialOnUnauthorized({
|
|
71
|
+
...authFailureContext,
|
|
72
|
+
payload: errorPayload,
|
|
73
|
+
});
|
|
74
|
+
// exit: 1 to match the run() unauthorized path (which uses
|
|
75
|
+
// `process.exitCode = 1`). oclif's CLIError defaults to 2,
|
|
76
|
+
// so without this override the same "unauthorized" condition
|
|
77
|
+
// exits 2 when surfaced from listDomains and 1 when surfaced
|
|
78
|
+
// from sendEmail, breaking callers that branch on exit code.
|
|
79
|
+
throw new Errors.CLIError("Cannot send: API key is missing or invalid (see hint above).", { exit: 1 });
|
|
80
|
+
}
|
|
81
|
+
throw new Errors.CLIError(`Could not look up your verified domains to default --from. Pass --from explicitly. Underlying error: ${formatErrorPayload(errorPayload)}`);
|
|
82
|
+
}
|
|
83
|
+
const envelope = result.data;
|
|
84
|
+
const first = envelope?.data?.find(isVerifiedDomain);
|
|
85
|
+
if (!first) {
|
|
86
|
+
throw new Errors.CLIError("No active verified outbound domain found on this account; pass --from explicitly. To set up outbound, claim a domain via `primitive domains:add-domain` and verify it.");
|
|
87
|
+
}
|
|
88
|
+
// Local-part: "agent". Any local-part is accepted on managed
|
|
89
|
+
// *.primitive.email subdomains, so this works out of the box for
|
|
90
|
+
// the auto-issued domain pool. For customers with BYO domains
|
|
91
|
+
// and their own MX, "agent@" may or may not be a routable
|
|
92
|
+
// mailbox; if you have a specific address you want to use, pass
|
|
93
|
+
// --from explicitly.
|
|
94
|
+
return `agent@${first.domain}`;
|
|
95
|
+
}
|
|
96
|
+
class SendCommand extends Command {
|
|
97
|
+
static description = `Send an outbound email. Agent-grade shortcut for sending:send-email with sensible defaults.
|
|
98
|
+
|
|
99
|
+
--from defaults to agent@<your-first-verified-outbound-domain> when omitted.
|
|
100
|
+
--subject defaults to the first line of the body when omitted.
|
|
101
|
+
|
|
102
|
+
For the full flag set (custom message-id threading on the wire,
|
|
103
|
+
references arrays, etc.), use \`primitive sending:send-email\`.`;
|
|
104
|
+
static summary = "Send an email (simplified, agent-friendly)";
|
|
105
|
+
static examples = [
|
|
106
|
+
"<%= config.bin %> send --to alice@example.com --body 'Hi Alice!'",
|
|
107
|
+
"<%= config.bin %> send --to alice@example.com --from support@yourcompany.com --subject 'Quick question' --body 'Are you free Thursday?'",
|
|
108
|
+
"<%= config.bin %> send --to alice@example.com --html '<p>Hello!</p>'",
|
|
109
|
+
"<%= config.bin %> send --to alice@example.com --body 'Confirmed' --wait",
|
|
110
|
+
"<%= config.bin %> send --to inbox@your-managed-domain.primitive.email --body 'self-loop smoke test' --wait # any *.primitive.email address routes back to the sending account; useful for proving outbound + inbound work end-to-end",
|
|
111
|
+
];
|
|
112
|
+
static flags = {
|
|
113
|
+
"api-key": Flags.string({
|
|
114
|
+
description: "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
|
|
115
|
+
env: "PRIMITIVE_API_KEY",
|
|
116
|
+
}),
|
|
117
|
+
"api-base-url-1": Flags.string({
|
|
118
|
+
description: "Override the primary API base URL. Internal testing only; not documented to customers.",
|
|
119
|
+
env: "PRIMITIVE_API_BASE_URL_1",
|
|
120
|
+
hidden: true,
|
|
121
|
+
}),
|
|
122
|
+
"api-base-url-2": Flags.string({
|
|
123
|
+
description: "Override the attachments-supporting send host base URL. Internal testing only; not documented to customers.",
|
|
124
|
+
env: "PRIMITIVE_API_BASE_URL_2",
|
|
125
|
+
hidden: true,
|
|
126
|
+
}),
|
|
127
|
+
to: Flags.string({
|
|
128
|
+
description: "Recipient address (e.g. alice@example.com).",
|
|
129
|
+
required: true,
|
|
130
|
+
}),
|
|
131
|
+
from: Flags.string({
|
|
132
|
+
description: "Sender address. Defaults to agent@<your-first-verified-outbound-domain>.",
|
|
133
|
+
}),
|
|
134
|
+
subject: Flags.string({
|
|
135
|
+
description: "Subject line. Defaults to the first line of --body / --html when omitted.",
|
|
136
|
+
}),
|
|
137
|
+
body: Flags.string({
|
|
138
|
+
description: "Plain-text message body. Either --body or --html (or both) is required.",
|
|
139
|
+
}),
|
|
140
|
+
html: Flags.string({
|
|
141
|
+
description: "HTML message body. Either --body or --html (or both) is required.",
|
|
142
|
+
}),
|
|
143
|
+
"in-reply-to": Flags.string({
|
|
144
|
+
description: "Message-Id of the parent email when threading a reply on the wire. For replying to an inbound message you received, prefer `primitive sending:reply-to-email --id <inbound-id>`.",
|
|
145
|
+
}),
|
|
146
|
+
wait: Flags.boolean({
|
|
147
|
+
description: "Block until the receiving MTA returns an outcome. Without --wait, the call returns once Primitive has accepted the message for delivery.",
|
|
148
|
+
}),
|
|
149
|
+
"wait-timeout-ms": Flags.integer({
|
|
150
|
+
description: "Maximum time to wait when --wait is set. Defaults to 30000ms.",
|
|
151
|
+
}),
|
|
152
|
+
time: Flags.boolean({
|
|
153
|
+
description: TIME_FLAG_DESCRIPTION,
|
|
154
|
+
}),
|
|
155
|
+
};
|
|
156
|
+
async run() {
|
|
157
|
+
const { flags } = await this.parse(SendCommand);
|
|
158
|
+
if (!flags.body && !flags.html) {
|
|
159
|
+
throw new Errors.CLIError("Either --body or --html (or both) is required.");
|
|
160
|
+
}
|
|
161
|
+
await runWithTiming(flags.time, async () => {
|
|
162
|
+
const baseUrlOverridden = flags["api-base-url-1"] !== undefined ||
|
|
163
|
+
flags["api-base-url-2"] !== undefined;
|
|
164
|
+
const auth = resolveCliAuth({
|
|
165
|
+
apiKey: flags["api-key"],
|
|
166
|
+
apiBaseUrl1: flags["api-base-url-1"],
|
|
167
|
+
apiBaseUrl2: flags["api-base-url-2"],
|
|
168
|
+
configDir: this.config.configDir,
|
|
169
|
+
});
|
|
170
|
+
const apiClient = new PrimitiveApiClient({
|
|
171
|
+
apiKey: auth.apiKey,
|
|
172
|
+
apiBaseUrl1: auth.apiBaseUrl1,
|
|
173
|
+
apiBaseUrl2: auth.apiBaseUrl2,
|
|
174
|
+
});
|
|
175
|
+
const authFailureContext = {
|
|
176
|
+
auth,
|
|
177
|
+
baseUrlOverridden,
|
|
178
|
+
configDir: this.config.configDir,
|
|
179
|
+
};
|
|
180
|
+
const from = flags.from ??
|
|
181
|
+
(await pickDefaultFromAddress(apiClient, authFailureContext));
|
|
182
|
+
const subject = flags.subject ?? (flags.body ? deriveSubject(flags.body) : "Message");
|
|
183
|
+
const result = await sendEmail({
|
|
184
|
+
body: {
|
|
185
|
+
from,
|
|
186
|
+
to: flags.to,
|
|
187
|
+
subject,
|
|
188
|
+
...(flags.body !== undefined ? { body_text: flags.body } : {}),
|
|
189
|
+
...(flags.html !== undefined ? { body_html: flags.html } : {}),
|
|
190
|
+
...(flags["in-reply-to"] !== undefined
|
|
191
|
+
? { in_reply_to: flags["in-reply-to"] }
|
|
192
|
+
: {}),
|
|
193
|
+
...(flags.wait !== undefined ? { wait: flags.wait } : {}),
|
|
194
|
+
...(flags["wait-timeout-ms"] !== undefined
|
|
195
|
+
? { wait_timeout_ms: flags["wait-timeout-ms"] }
|
|
196
|
+
: {}),
|
|
197
|
+
},
|
|
198
|
+
// /send-mail goes to the attachments-supporting host. The
|
|
199
|
+
// wrapper exposes the host-2 client as _sendClient for this
|
|
200
|
+
// and any other host-2 operation that lands here. Customer
|
|
201
|
+
// SDK callers should use PrimitiveClient.send() instead so
|
|
202
|
+
// the routing stays internal.
|
|
203
|
+
client: apiClient._sendClient,
|
|
204
|
+
responseStyle: "fields",
|
|
205
|
+
});
|
|
206
|
+
if (result.error) {
|
|
207
|
+
const errorPayload = extractErrorPayload(result.error);
|
|
208
|
+
writeErrorWithHints(errorPayload);
|
|
209
|
+
removeStaleSavedCredentialOnUnauthorized({
|
|
210
|
+
...authFailureContext,
|
|
211
|
+
payload: errorPayload,
|
|
212
|
+
});
|
|
213
|
+
process.exitCode = 1;
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const envelope = result.data;
|
|
217
|
+
this.log(JSON.stringify(envelope?.data ?? null, null, 2));
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
export default SendCommand;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Command, Errors, Flags } from "@oclif/core";
|
|
2
|
+
import { getAccount, PrimitiveApiClient } from "@primitivedotdev/sdk/api";
|
|
3
|
+
import { extractErrorPayload, removeStaleSavedCredentialOnUnauthorized, runWithTiming, TIME_FLAG_DESCRIPTION, writeErrorWithHints, } from "../api-command.js";
|
|
4
|
+
import { resolveCliAuth } from "../auth.js";
|
|
5
|
+
// `primitive whoami` is the credentials smoke-test the AGX
|
|
6
|
+
// walkthrough kept asking for. Before this command, a user with a
|
|
7
|
+
// suspect API key had no fast way to verify "is my key live and
|
|
8
|
+
// pointed at the org I expect" short of trying any other call and
|
|
9
|
+
// reading a 401. That ambiguity bit two consecutive walkthroughs.
|
|
10
|
+
//
|
|
11
|
+
// Implementation: thin wrapper over /api/v1/account that prints
|
|
12
|
+
// the account email, plan, id, and onboarding status. Any auth
|
|
13
|
+
// problem surfaces as the standard error envelope, same as the
|
|
14
|
+
// generated commands.
|
|
15
|
+
class WhoamiCommand extends Command {
|
|
16
|
+
static description = `Print the account currently authenticated by the API key. Useful as a credentials smoke test: confirms the key is live and shows which account it belongs to.`;
|
|
17
|
+
static summary = "Print the authenticated account (credentials smoke test)";
|
|
18
|
+
static examples = [
|
|
19
|
+
"<%= config.bin %> whoami",
|
|
20
|
+
"<%= config.bin %> whoami --api-key prim_...",
|
|
21
|
+
];
|
|
22
|
+
static flags = {
|
|
23
|
+
"api-key": Flags.string({
|
|
24
|
+
description: "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
|
|
25
|
+
env: "PRIMITIVE_API_KEY",
|
|
26
|
+
}),
|
|
27
|
+
"api-base-url-1": Flags.string({
|
|
28
|
+
description: "Override the primary API base URL. Internal testing only; not documented to customers.",
|
|
29
|
+
env: "PRIMITIVE_API_BASE_URL_1",
|
|
30
|
+
hidden: true,
|
|
31
|
+
}),
|
|
32
|
+
"api-base-url-2": Flags.string({
|
|
33
|
+
description: "Override the attachments-supporting send host base URL. Internal testing only; not documented to customers.",
|
|
34
|
+
env: "PRIMITIVE_API_BASE_URL_2",
|
|
35
|
+
hidden: true,
|
|
36
|
+
}),
|
|
37
|
+
time: Flags.boolean({
|
|
38
|
+
description: TIME_FLAG_DESCRIPTION,
|
|
39
|
+
}),
|
|
40
|
+
};
|
|
41
|
+
async run() {
|
|
42
|
+
const { flags } = await this.parse(WhoamiCommand);
|
|
43
|
+
await runWithTiming(flags.time, async () => {
|
|
44
|
+
const baseUrlOverridden = flags["api-base-url-1"] !== undefined ||
|
|
45
|
+
flags["api-base-url-2"] !== undefined;
|
|
46
|
+
const auth = resolveCliAuth({
|
|
47
|
+
apiKey: flags["api-key"],
|
|
48
|
+
apiBaseUrl1: flags["api-base-url-1"],
|
|
49
|
+
apiBaseUrl2: flags["api-base-url-2"],
|
|
50
|
+
configDir: this.config.configDir,
|
|
51
|
+
});
|
|
52
|
+
const apiClient = new PrimitiveApiClient({
|
|
53
|
+
apiKey: auth.apiKey,
|
|
54
|
+
apiBaseUrl1: auth.apiBaseUrl1,
|
|
55
|
+
apiBaseUrl2: auth.apiBaseUrl2,
|
|
56
|
+
});
|
|
57
|
+
const result = await getAccount({
|
|
58
|
+
client: apiClient.client,
|
|
59
|
+
responseStyle: "fields",
|
|
60
|
+
});
|
|
61
|
+
if (result.error) {
|
|
62
|
+
const errorPayload = extractErrorPayload(result.error);
|
|
63
|
+
writeErrorWithHints(errorPayload);
|
|
64
|
+
removeStaleSavedCredentialOnUnauthorized({
|
|
65
|
+
auth,
|
|
66
|
+
baseUrlOverridden,
|
|
67
|
+
configDir: this.config.configDir,
|
|
68
|
+
payload: errorPayload,
|
|
69
|
+
});
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const envelope = result.data;
|
|
74
|
+
const account = envelope?.data;
|
|
75
|
+
if (!account) {
|
|
76
|
+
process.stderr.write("Server returned an empty account body; this should not happen for a valid key.\n");
|
|
77
|
+
throw new Errors.CLIError("unexpected empty response");
|
|
78
|
+
}
|
|
79
|
+
// Concise human-readable summary on stderr; the full account
|
|
80
|
+
// JSON goes to stdout so a script can pipe it.
|
|
81
|
+
const onboarding = account.onboarding_completed === true
|
|
82
|
+
? "complete"
|
|
83
|
+
: account.onboarding_step
|
|
84
|
+
? `in progress (step: ${account.onboarding_step})`
|
|
85
|
+
: "incomplete";
|
|
86
|
+
process.stderr.write(`Authenticated as ${account.email}\n`);
|
|
87
|
+
process.stderr.write(` Account id: ${account.id}\n`);
|
|
88
|
+
process.stderr.write(` Plan: ${account.plan}\n`);
|
|
89
|
+
process.stderr.write(` Onboarding: ${onboarding}\n`);
|
|
90
|
+
this.log(JSON.stringify(account, null, 2));
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export default WhoamiCommand;
|