@primitivedotdev/cli 0.26.2 → 0.26.3
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 +12554 -178
- package/dist/oclif/proxy-auto-detect.js +38 -57
- package/package.json +7 -9
- package/dist/oclif/api-command.js +0 -799
- package/dist/oclif/auth.js +0 -223
- package/dist/oclif/commands/doctor.js +0 -361
- package/dist/oclif/commands/emails-latest.js +0 -184
- package/dist/oclif/commands/emails-poll.js +0 -121
- package/dist/oclif/commands/emails-wait.js +0 -171
- package/dist/oclif/commands/emails-watch.js +0 -165
- package/dist/oclif/commands/functions-deploy.js +0 -302
- package/dist/oclif/commands/functions-init.js +0 -374
- package/dist/oclif/commands/functions-redeploy.js +0 -240
- package/dist/oclif/commands/functions-set-secret.js +0 -212
- package/dist/oclif/commands/functions-test-function.js +0 -238
- package/dist/oclif/commands/login.js +0 -236
- package/dist/oclif/commands/logout.js +0 -87
- package/dist/oclif/commands/send.js +0 -221
- package/dist/oclif/commands/whoami.js +0 -94
- package/dist/oclif/endpoints-test-redirect.js +0 -94
- package/dist/oclif/fish-completion.js +0 -87
- package/dist/oclif/lint/raw-send-mail-fetch.js +0 -98
- package/dist/oclif/secret-flags.js +0 -59
- package/oclif.manifest.json +0 -4462
|
@@ -1,221 +0,0 @@
|
|
|
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;
|
|
@@ -1,94 +0,0 @@
|
|
|
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;
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
// `endpoints:test-endpoint` calls POST /endpoints/{id}/test. The server
|
|
2
|
-
// implementation only resolves http-kind endpoints by url and returns
|
|
3
|
-
// `not_found` for function-kind endpoints (where url is null). The same
|
|
4
|
-
// function-endpoint id IS returned by `endpoints:list-endpoints`, so a
|
|
5
|
-
// caller naturally tries it against test-endpoint and is greeted with
|
|
6
|
-
// "Endpoint not found." Confusing.
|
|
7
|
-
//
|
|
8
|
-
// This helper closes the loop on the CLI side. After test-endpoint
|
|
9
|
-
// returns `not_found`, the dispatcher in `api-command.ts` calls
|
|
10
|
-
// `detectFunctionEndpoint` to see whether the id actually belongs to a
|
|
11
|
-
// function-kind endpoint owned by the caller. If yes, we replace the
|
|
12
|
-
// generic envelope with a redirect to `functions:test-function`,
|
|
13
|
-
// surfacing both the endpoint id and the function id so the caller
|
|
14
|
-
// does not have to look the function id up themselves.
|
|
15
|
-
//
|
|
16
|
-
// `kind` and `function_id` are not currently declared on the OpenAPI
|
|
17
|
-
// `Endpoint` schema (so they are absent from the generated TS types),
|
|
18
|
-
// but they are present in the JSON the server returns. We read them
|
|
19
|
-
// off a loose Record<string, unknown> rather than relying on the
|
|
20
|
-
// generated type, then sanity-check both fields before treating an
|
|
21
|
-
// endpoint as a function endpoint.
|
|
22
|
-
// Returns a `FunctionEndpointMatch` if and only if `endpointId` matches
|
|
23
|
-
// an endpoint in the caller's `listEndpoints` response whose `kind` is
|
|
24
|
-
// `function` and whose `function_id` is a non-empty string. Any other
|
|
25
|
-
// outcome (no match, http-kind match, list call failure, missing
|
|
26
|
-
// fields) returns `null` so the dispatcher falls back to surfacing the
|
|
27
|
-
// original error envelope unchanged.
|
|
28
|
-
export async function detectFunctionEndpoint(endpointId, listEndpoints) {
|
|
29
|
-
let response;
|
|
30
|
-
try {
|
|
31
|
-
response = await listEndpoints();
|
|
32
|
-
}
|
|
33
|
-
catch {
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
if (response.error)
|
|
37
|
-
return null;
|
|
38
|
-
const rows = response.data?.data;
|
|
39
|
-
if (!Array.isArray(rows))
|
|
40
|
-
return null;
|
|
41
|
-
// Relies on `listEndpoints` returning every endpoint in a single response.
|
|
42
|
-
// True today: the operation has `query?: never` in the generated types and
|
|
43
|
-
// the server returns all rows. If pagination is ever added, an endpoint on
|
|
44
|
-
// a later page would silently miss the redirect and reduce this to the
|
|
45
|
-
// original "Endpoint not found" UX. Update this call (filter-by-id or
|
|
46
|
-
// exhaust-pages) at the same time pagination lands on listEndpoints.
|
|
47
|
-
for (const row of rows) {
|
|
48
|
-
if (!row || typeof row !== "object")
|
|
49
|
-
continue;
|
|
50
|
-
if (row.id !== endpointId)
|
|
51
|
-
continue;
|
|
52
|
-
if (row.kind !== "function")
|
|
53
|
-
return null;
|
|
54
|
-
const functionId = row.function_id;
|
|
55
|
-
if (typeof functionId !== "string" || functionId.length === 0)
|
|
56
|
-
return null;
|
|
57
|
-
return { endpointId, functionId };
|
|
58
|
-
}
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
// Stderr copy printed when the dispatcher detects the
|
|
62
|
-
// `endpoints:test-endpoint` `not_found` was really a function-kind
|
|
63
|
-
// endpoint. Surfaces both ids so the caller does not have to run
|
|
64
|
-
// `endpoints:list-endpoints` again to find the function_id. Returned
|
|
65
|
-
// as a string rather than written here so the call site controls the
|
|
66
|
-
// stream (stderr, in practice) and the tests can assert on the value.
|
|
67
|
-
export function formatFunctionEndpointRedirect(match) {
|
|
68
|
-
return [
|
|
69
|
-
"This is a function endpoint. Function endpoints are tested differently. Run:",
|
|
70
|
-
"",
|
|
71
|
-
` primitive functions:test-function --id ${match.functionId}`,
|
|
72
|
-
"",
|
|
73
|
-
`(pass the function id, not the endpoint id. endpoint_id=${match.endpointId} function_id=${match.functionId})`,
|
|
74
|
-
].join("\n");
|
|
75
|
-
}
|
|
76
|
-
// Post-error hook: if the operation that just failed is
|
|
77
|
-
// `endpoints:test-endpoint`, the failure is a `not_found`, and the
|
|
78
|
-
// caller's id matches a function-kind endpoint they own, print a
|
|
79
|
-
// redirect to `functions:test-function`. Returns the resolved match
|
|
80
|
-
// so the caller (and the test) can assert the branch taken without
|
|
81
|
-
// scraping stderr.
|
|
82
|
-
export async function maybeWriteFunctionEndpointRedirect(inputs) {
|
|
83
|
-
if (inputs.sdkName !== "testEndpoint")
|
|
84
|
-
return null;
|
|
85
|
-
if (inputs.errorCode !== "not_found")
|
|
86
|
-
return null;
|
|
87
|
-
if (!inputs.endpointId)
|
|
88
|
-
return null;
|
|
89
|
-
const match = await detectFunctionEndpoint(inputs.endpointId, inputs.listEndpoints);
|
|
90
|
-
if (!match)
|
|
91
|
-
return null;
|
|
92
|
-
inputs.writeStderr(`${formatFunctionEndpointRedirect(match)}\n`);
|
|
93
|
-
return match;
|
|
94
|
-
}
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import { openapiDocument, operationManifest, } from "@primitivedotdev/sdk/openapi";
|
|
2
|
-
function fishEscape(value) {
|
|
3
|
-
return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
4
|
-
}
|
|
5
|
-
function toKebabCase(value) {
|
|
6
|
-
return value
|
|
7
|
-
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
8
|
-
.replace(/[^a-zA-Z0-9]+/g, "-")
|
|
9
|
-
.replace(/^-+|-+$/g, "")
|
|
10
|
-
.toLowerCase();
|
|
11
|
-
}
|
|
12
|
-
function tagDescriptions() {
|
|
13
|
-
const descriptions = new Map();
|
|
14
|
-
const tags = (openapiDocument.tags ??
|
|
15
|
-
[]);
|
|
16
|
-
for (const tag of tags) {
|
|
17
|
-
if (tag.name) {
|
|
18
|
-
descriptions.set(toKebabCase(tag.name), tag.description ?? tag.name);
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
return descriptions;
|
|
22
|
-
}
|
|
23
|
-
function operationCondition(operation) {
|
|
24
|
-
return `__fish_${fishEscape(BIN_PLACEHOLDER)}_using_operation ${fishEscape(operation.tagCommand)} ${fishEscape(operation.command)}`;
|
|
25
|
-
}
|
|
26
|
-
const BIN_PLACEHOLDER = "__BIN__";
|
|
27
|
-
export function renderFishCompletion(binName) {
|
|
28
|
-
const tagDescriptionByCommand = tagDescriptions();
|
|
29
|
-
const topLevelTopics = [
|
|
30
|
-
...new Set(operationManifest.map((operation) => operation.tagCommand)),
|
|
31
|
-
];
|
|
32
|
-
const lines = [
|
|
33
|
-
`function __fish_${binName}_needs_command`,
|
|
34
|
-
" set -l cmd (commandline -opc)",
|
|
35
|
-
" test (count $cmd) -le 1",
|
|
36
|
-
"end",
|
|
37
|
-
"",
|
|
38
|
-
`function __fish_${binName}_topic_needs_subcommand`,
|
|
39
|
-
" set -l cmd (commandline -opc)",
|
|
40
|
-
" test (count $cmd) -eq 2",
|
|
41
|
-
' and test "$cmd[2]" = "$argv[1]"',
|
|
42
|
-
"end",
|
|
43
|
-
"",
|
|
44
|
-
`function __fish_${binName}_using_operation`,
|
|
45
|
-
" set -l cmd (commandline -opc)",
|
|
46
|
-
" test (count $cmd) -ge 3",
|
|
47
|
-
' and test "$cmd[2]" = "$argv[1]"',
|
|
48
|
-
' and test "$cmd[3]" = "$argv[2]"',
|
|
49
|
-
"end",
|
|
50
|
-
"",
|
|
51
|
-
`function __fish_${binName}_using_root_command`,
|
|
52
|
-
" set -l cmd (commandline -opc)",
|
|
53
|
-
" test (count $cmd) -eq 2",
|
|
54
|
-
' and test "$cmd[2]" = "$argv[1]"',
|
|
55
|
-
"end",
|
|
56
|
-
"",
|
|
57
|
-
`complete -c ${binName} -f -n '__fish_${binName}_needs_command' -a 'list-operations' -d 'List all generated API operations'`,
|
|
58
|
-
`complete -c ${binName} -f -n '__fish_${binName}_needs_command' -a 'completion' -d 'Show shell completion output or installation instructions'`,
|
|
59
|
-
`complete -c ${binName} -f -n '__fish_${binName}_needs_command' -a 'autocomplete' -d 'Install or display shell autocomplete for bash, zsh, and powershell'`,
|
|
60
|
-
`complete -c ${binName} -f -n '__fish_${binName}_needs_command' -a 'help' -d 'Display help for ${binName}'`,
|
|
61
|
-
];
|
|
62
|
-
for (const topic of topLevelTopics) {
|
|
63
|
-
lines.push(`complete -c ${binName} -f -n '__fish_${binName}_needs_command' -a '${fishEscape(topic)}' -d '${fishEscape(tagDescriptionByCommand.get(topic) ?? topic)}'`);
|
|
64
|
-
}
|
|
65
|
-
lines.push(`complete -c ${binName} -f -n '__fish_${binName}_using_root_command completion' -a 'bash zsh powershell fish' -d 'Shell type'`);
|
|
66
|
-
for (const topic of topLevelTopics) {
|
|
67
|
-
const topicOperations = operationManifest.filter((operation) => operation.tagCommand === topic);
|
|
68
|
-
for (const operation of topicOperations) {
|
|
69
|
-
lines.push(`complete -c ${binName} -f -n '__fish_${binName}_topic_needs_subcommand ${fishEscape(topic)}' -a '${fishEscape(operation.command)}' -d '${fishEscape(operation.summary ?? `${operation.method} ${operation.path}`)}'`);
|
|
70
|
-
for (const parameter of [
|
|
71
|
-
...operation.pathParams,
|
|
72
|
-
...operation.queryParams,
|
|
73
|
-
]) {
|
|
74
|
-
lines.push(`complete -c ${binName} -n '${operationCondition(operation).replace(BIN_PLACEHOLDER, binName)}' -l '${fishEscape(parameter.name.replace(/_/g, "-"))}' -r -d '${fishEscape(parameter.description ?? parameter.name)}'`);
|
|
75
|
-
}
|
|
76
|
-
lines.push(`complete -c ${binName} -n '${operationCondition(operation).replace(BIN_PLACEHOLDER, binName)}' -l 'api-key' -r -d 'Primitive API key (defaults to PRIMITIVE_API_KEY or saved primitive login credentials)'`);
|
|
77
|
-
if (operation.hasJsonBody) {
|
|
78
|
-
lines.push(`complete -c ${binName} -n '${operationCondition(operation).replace(BIN_PLACEHOLDER, binName)}' -l 'body' -r -d 'JSON request body'`, `complete -c ${binName} -n '${operationCondition(operation).replace(BIN_PLACEHOLDER, binName)}' -l 'body-file' -r -d 'Path to a JSON file used as the request body'`);
|
|
79
|
-
}
|
|
80
|
-
if (operation.binaryResponse) {
|
|
81
|
-
lines.push(`complete -c ${binName} -n '${operationCondition(operation).replace(BIN_PLACEHOLDER, binName)}' -l 'output' -r -d 'Write binary response bytes to a file'`);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
lines.push(`complete -c ${binName} -l help -d 'Show help for ${binName}'`, `complete -c ${binName} -l version -d 'Show version for ${binName}'`);
|
|
86
|
-
return `${lines.join("\n")}\n`;
|
|
87
|
-
}
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
// Deploy-time lint for `functions:deploy --file <bundle>` and
|
|
2
|
-
// `functions:redeploy --file <bundle>`. Looks for a raw
|
|
3
|
-
// `fetch("...primitive.dev/.../send-mail", ...)` call in the bundle
|
|
4
|
-
// text and, on a match, emits a stderr warning telling the author
|
|
5
|
-
// to prefer `createPrimitiveClient` from `@primitivedotdev/sdk/api`.
|
|
6
|
-
//
|
|
7
|
-
// Why: a recurring pattern in agent-assisted Function deploys is to
|
|
8
|
-
// copy the REST snippet from the docs and call `fetch` directly
|
|
9
|
-
// against the Primitive send-mail endpoint, even after the docs
|
|
10
|
-
// switched to leading with the SDK and `functions:init` ships a
|
|
11
|
-
// scaffold that uses `createPrimitiveClient`. The SDK already
|
|
12
|
-
// handles dual-host routing, error envelopes, and send-permission
|
|
13
|
-
// gate denials; raw `fetch` re-discovers each of those by hand. The
|
|
14
|
-
// warning is the catch-net at deploy time: the deploy still
|
|
15
|
-
// proceeds.
|
|
16
|
-
//
|
|
17
|
-
// Scope by design:
|
|
18
|
-
// - We only flag the empirically observed footgun: `/send-mail`.
|
|
19
|
-
// Not arbitrary calls to api.primitive.dev. Other endpoints have
|
|
20
|
-
// not surfaced the same pattern.
|
|
21
|
-
// - We require the URL string literal to immediately follow the
|
|
22
|
-
// `fetch(` token (allowing whitespace) so we don't trip on a
|
|
23
|
-
// comment that merely mentions the URL. esbuild strips most
|
|
24
|
-
// comments anyway, but anchoring on `fetch(` keeps the rule
|
|
25
|
-
// honest without trying to parse JS.
|
|
26
|
-
// - We only look at the bundle text passed in. Source maps and
|
|
27
|
-
// sibling files are not scanned.
|
|
28
|
-
// - Variable-URL cases (`fetch(url, ...)` where `url` was assembled
|
|
29
|
-
// elsewhere) are accepted false negatives. The value here is
|
|
30
|
-
// catching the obvious inline-literal case, not full taint
|
|
31
|
-
// analysis.
|
|
32
|
-
// Match `fetch(` then a string literal (single, double, or backtick)
|
|
33
|
-
// whose contents include `primitive.dev` and end with `/send-mail`
|
|
34
|
-
// (optionally with a query string or trailing path boundary). Examples
|
|
35
|
-
// matched:
|
|
36
|
-
// fetch("https://api.primitive.dev/v1/send-mail", {...})
|
|
37
|
-
// fetch(`https://www.primitive.dev/api/v1/send-mail`, {...})
|
|
38
|
-
// fetch('https://primitive.dev/api/v1/send-mail?wait=1')
|
|
39
|
-
//
|
|
40
|
-
// The `[^`'"]*` inside the literal forbids the closing quote
|
|
41
|
-
// character itself so we can't accidentally span across two adjacent
|
|
42
|
-
// string literals. The trailing `(?![A-Za-z0-9_-])` forbids any
|
|
43
|
-
// letter, digit, underscore, or hyphen immediately after `send-mail`
|
|
44
|
-
// so `/send-mail-template-preview` does not trip the rule. (Plain
|
|
45
|
-
// `\b` does not help here because `-` is itself a non-word character,
|
|
46
|
-
// so `mail\b` still matches `mail-...`.)
|
|
47
|
-
const RAW_SEND_MAIL_FETCH_REGEX = /fetch\s*\(\s*[`'"][^`'"]*primitive\.dev[^`'"]*\/send-mail(?![A-Za-z0-9_-])/g;
|
|
48
|
-
// How much surrounding text to include on either side of the match
|
|
49
|
-
// when building the sample snippet. Kept short on purpose: bundles
|
|
50
|
-
// are minified and a 120-char window is enough to spot the call
|
|
51
|
-
// without flooding stderr.
|
|
52
|
-
const SNIPPET_PADDING = 60;
|
|
53
|
-
export function detectRawSendMailFetch(bundleText) {
|
|
54
|
-
// Reset lastIndex defensively: this regex is module-scoped with
|
|
55
|
-
// the /g flag, so a prior call's state would skip the next match.
|
|
56
|
-
RAW_SEND_MAIL_FETCH_REGEX.lastIndex = 0;
|
|
57
|
-
const match = RAW_SEND_MAIL_FETCH_REGEX.exec(bundleText);
|
|
58
|
-
if (!match) {
|
|
59
|
-
return { found: false, sampleSnippet: null };
|
|
60
|
-
}
|
|
61
|
-
const start = Math.max(0, match.index - SNIPPET_PADDING);
|
|
62
|
-
const end = Math.min(bundleText.length, match.index + match[0].length + SNIPPET_PADDING);
|
|
63
|
-
const raw = bundleText.slice(start, end);
|
|
64
|
-
// Collapse newlines and runs of whitespace so the snippet is one
|
|
65
|
-
// readable line on stderr regardless of how the bundle was
|
|
66
|
-
// formatted.
|
|
67
|
-
const sampleSnippet = raw.replace(/\s+/g, " ").trim();
|
|
68
|
-
return { found: true, sampleSnippet };
|
|
69
|
-
}
|
|
70
|
-
// The stderr warning copy. Three beats: name the issue, name the
|
|
71
|
-
// SDK alternative, link the docs. Plus a one-line "deploy proceeds"
|
|
72
|
-
// reassurance. Kept punctuation simple (commas, periods, line
|
|
73
|
-
// breaks) so it doesn't trip the no-em-dashes hook and so the lines
|
|
74
|
-
// wrap predictably in a terminal.
|
|
75
|
-
export const RAW_SEND_MAIL_FETCH_WARNING_LINES = [
|
|
76
|
-
"warning: this bundle calls fetch(...) against /send-mail directly.",
|
|
77
|
-
"The Primitive SDK exposes createPrimitiveClient from",
|
|
78
|
-
"@primitivedotdev/sdk/api which handles host routing, error envelopes,",
|
|
79
|
-
"and gate denials for you. See https://www.primitive.dev/docs/functions",
|
|
80
|
-
"for the recommended in-handler pattern. Continuing with deploy.",
|
|
81
|
-
];
|
|
82
|
-
export function formatRawSendMailFetchWarning(finding) {
|
|
83
|
-
const lines = [...RAW_SEND_MAIL_FETCH_WARNING_LINES];
|
|
84
|
-
if (finding.sampleSnippet) {
|
|
85
|
-
lines.push(` found: ${finding.sampleSnippet}`);
|
|
86
|
-
}
|
|
87
|
-
return `${lines.join("\n")}\n`;
|
|
88
|
-
}
|
|
89
|
-
// Convenience: run the detector and, on a match, write the warning
|
|
90
|
-
// to a stderr-shaped writer. Pulled out so both deploy and redeploy
|
|
91
|
-
// share one code path and so the unit tests can pass a fake writer.
|
|
92
|
-
export function emitRawSendMailFetchWarning(bundleText, write) {
|
|
93
|
-
const finding = detectRawSendMailFetch(bundleText);
|
|
94
|
-
if (finding.found) {
|
|
95
|
-
write(formatRawSendMailFetchWarning(finding));
|
|
96
|
-
}
|
|
97
|
-
return finding;
|
|
98
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
// Shared parsing for the `--secret KEY=VALUE` flag used by both
|
|
2
|
-
// functions:deploy and functions:redeploy. Lives in its own module so
|
|
3
|
-
// neither command implicitly depends on the other's file path.
|
|
4
|
-
// Server-side constraint on secret keys. Mirrored client-side so
|
|
5
|
-
// malformed input is rejected before any side-effecting API call.
|
|
6
|
-
export const SECRET_KEY_RE = /^[A-Z_][A-Z0-9_]*$/;
|
|
7
|
-
// Split each `--secret KEY=VALUE` on the FIRST `=`. KEY must match
|
|
8
|
-
// `^[A-Z_][A-Z0-9_]*$`; VALUE may contain `=` (only the first one
|
|
9
|
-
// is treated as a delimiter). Duplicate KEYs are rejected: silently
|
|
10
|
-
// accepting two pairs with the same key would fan out to two
|
|
11
|
-
// setFunctionSecret writes where only the second wins, which is
|
|
12
|
-
// almost always a typo and never the intent.
|
|
13
|
-
export function parseSecretFlags(raw) {
|
|
14
|
-
const secrets = [];
|
|
15
|
-
const seenKeys = new Set();
|
|
16
|
-
for (const entry of raw) {
|
|
17
|
-
const eq = entry.indexOf("=");
|
|
18
|
-
if (eq === -1) {
|
|
19
|
-
return {
|
|
20
|
-
kind: "error",
|
|
21
|
-
message: `--secret expects KEY=VALUE (got ${JSON.stringify(entry)}). Example: --secret API_TOKEN=abc123`,
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
const key = entry.slice(0, eq);
|
|
25
|
-
const value = entry.slice(eq + 1);
|
|
26
|
-
if (key.length === 0) {
|
|
27
|
-
return {
|
|
28
|
-
kind: "error",
|
|
29
|
-
message: `--secret is missing a KEY before '=' (got ${JSON.stringify(entry)}). Example: --secret API_TOKEN=abc123`,
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
if (!SECRET_KEY_RE.test(key)) {
|
|
33
|
-
return {
|
|
34
|
-
kind: "error",
|
|
35
|
-
message: `--secret KEY ${JSON.stringify(key)} does not match ${SECRET_KEY_RE.source} (uppercase letters, digits, underscores; first character is a letter or underscore).`,
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
if (seenKeys.has(key)) {
|
|
39
|
-
return {
|
|
40
|
-
kind: "error",
|
|
41
|
-
message: `--secret KEY ${JSON.stringify(key)} was passed more than once. Each key may only appear once per command.`,
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
seenKeys.add(key);
|
|
45
|
-
secrets.push({ key, value });
|
|
46
|
-
}
|
|
47
|
-
return { kind: "ok", secrets };
|
|
48
|
-
}
|
|
49
|
-
// Shared flag-description copy so both functions:deploy and
|
|
50
|
-
// functions:redeploy advertise the same security caveat and KEY
|
|
51
|
-
// constraints. The shell-history note is the load-bearing piece:
|
|
52
|
-
// CLI flag values land in ~/.bash_history, `ps aux`, and
|
|
53
|
-
// /proc/[pid]/cmdline, so callers handling sensitive values
|
|
54
|
-
// should set them via a shell variable (ideally read via `read -s`
|
|
55
|
-
// or piped from a secrets manager) and reference the variable on
|
|
56
|
-
// the command line. The variable still appears in `ps`-visible
|
|
57
|
-
// argv, but at least the literal value does not get archived in
|
|
58
|
-
// the user's shell history.
|
|
59
|
-
export const SECRET_FLAG_SECURITY_NOTE = 'Note: values passed on the command line are visible in shell history (e.g. ~/.bash_history) and to other users via `ps aux` / /proc/[pid]/cmdline. For sensitive values prefer `--secret KEY="$VAR"` where `$VAR` is set out-of-band (read -s, a secrets manager, etc.).';
|