@primitivedotdev/cli 0.26.1 → 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.
@@ -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.).';