@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.
@@ -0,0 +1,171 @@
1
+ import { Command, Errors, Flags } from "@oclif/core";
2
+ import { PrimitiveApiClient } from "@primitivedotdev/sdk/api";
3
+ import { extractErrorPayload, removeStaleSavedCredentialOnUnauthorized, writeErrorWithHints, } from "../api-command.js";
4
+ import { resolveCliAuth } from "../auth.js";
5
+ import { formatHeader, formatRow, pickIdWidth } from "./emails-latest.js";
6
+ import { collectNewAcceptedEmails, DEFAULT_EMAIL_POLL_INTERVAL_SECONDS, DEFAULT_EMAIL_POLL_PAGE_SIZE, fetchEmailSearchPage, filtersFromFlags, MAX_EMAIL_POLL_PAGE_SIZE, sinceFromFlags, sleep, } from "./emails-poll.js";
7
+ const DEFAULT_WAIT_TIMEOUT_SECONDS = 300;
8
+ function cliError(message) {
9
+ return new Errors.CLIError(message, { exit: 1 });
10
+ }
11
+ class EmailsWaitCommand extends Command {
12
+ static description = "Poll until matching inbound emails arrive, printing each match as it is found.";
13
+ static summary = "Wait for matching inbound emails";
14
+ static examples = [
15
+ "<%= config.bin %> emails wait --to test@example.com",
16
+ "<%= config.bin %> emails wait --subject verify --number 5 --timeout 120",
17
+ "<%= config.bin %> emails wait --q 'domain:example.com' --table",
18
+ ];
19
+ static flags = {
20
+ "api-key": Flags.string({
21
+ description: "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
22
+ env: "PRIMITIVE_API_KEY",
23
+ }),
24
+ "api-base-url-1": Flags.string({
25
+ description: "Override the primary API base URL. Internal testing only; not documented to customers.",
26
+ env: "PRIMITIVE_API_BASE_URL_1",
27
+ hidden: true,
28
+ }),
29
+ "api-base-url-2": Flags.string({
30
+ description: "Override the attachments-supporting send host base URL. Internal testing only; not documented to customers.",
31
+ env: "PRIMITIVE_API_BASE_URL_2",
32
+ hidden: true,
33
+ }),
34
+ body: Flags.string({
35
+ description: "Full-text body filter",
36
+ }),
37
+ domain: Flags.string({
38
+ description: "Filter by inbound email domain",
39
+ }),
40
+ "domain-id": Flags.string({
41
+ description: "Filter by domain UUID",
42
+ }),
43
+ from: Flags.string({
44
+ description: "Filter by sender address or domain",
45
+ }),
46
+ "has-attachment": Flags.boolean({
47
+ description: "Only match emails with one or more attachments",
48
+ }),
49
+ "include-existing": Flags.boolean({
50
+ description: "Start from existing matching emails instead of only new arrivals",
51
+ }),
52
+ interval: Flags.integer({
53
+ default: DEFAULT_EMAIL_POLL_INTERVAL_SECONDS,
54
+ description: "Seconds to wait between empty polls",
55
+ min: 1,
56
+ }),
57
+ number: Flags.integer({
58
+ char: "n",
59
+ default: 1,
60
+ description: "Exit successfully after this many matching emails",
61
+ min: 1,
62
+ }),
63
+ "page-size": Flags.integer({
64
+ default: DEFAULT_EMAIL_POLL_PAGE_SIZE,
65
+ description: `Emails to fetch per poll (1-${MAX_EMAIL_POLL_PAGE_SIZE})`,
66
+ max: MAX_EMAIL_POLL_PAGE_SIZE,
67
+ min: 1,
68
+ }),
69
+ q: Flags.string({
70
+ description: "Full-text search DSL query",
71
+ }),
72
+ since: Flags.string({
73
+ description: "Only match emails received on or after this date/time",
74
+ }),
75
+ "spam-score-gte": Flags.integer({
76
+ description: "Only match emails with spam score greater than or equal to this value",
77
+ }),
78
+ "spam-score-lt": Flags.integer({
79
+ description: "Only match emails with spam score below this value",
80
+ }),
81
+ subject: Flags.string({
82
+ description: "Full-text subject filter",
83
+ }),
84
+ table: Flags.boolean({
85
+ description: "Print a human-readable table instead of JSONL",
86
+ }),
87
+ timeout: Flags.integer({
88
+ default: DEFAULT_WAIT_TIMEOUT_SECONDS,
89
+ description: "Seconds to wait before exiting nonzero; 0 waits forever",
90
+ min: 0,
91
+ }),
92
+ to: Flags.string({
93
+ description: "Filter by recipient address or domain",
94
+ }),
95
+ };
96
+ async run() {
97
+ const { flags } = await this.parse(EmailsWaitCommand);
98
+ const baseUrlOverridden = flags["api-base-url-1"] !== undefined ||
99
+ flags["api-base-url-2"] !== undefined;
100
+ const auth = resolveCliAuth({
101
+ apiKey: flags["api-key"],
102
+ apiBaseUrl1: flags["api-base-url-1"],
103
+ apiBaseUrl2: flags["api-base-url-2"],
104
+ configDir: this.config.configDir,
105
+ });
106
+ const apiClient = new PrimitiveApiClient({
107
+ apiKey: auth.apiKey,
108
+ apiBaseUrl1: auth.apiBaseUrl1,
109
+ apiBaseUrl2: auth.apiBaseUrl2,
110
+ });
111
+ let since;
112
+ try {
113
+ since = sinceFromFlags(flags);
114
+ }
115
+ catch (error) {
116
+ throw cliError(error instanceof Error ? error.message : String(error));
117
+ }
118
+ const filters = filtersFromFlags(flags);
119
+ const deadline = flags.timeout === 0 ? null : Date.now() + flags.timeout * 1000;
120
+ const idWidth = pickIdWidth(Boolean(process.stdout.isTTY));
121
+ const seenIds = new Set();
122
+ let cursor = null;
123
+ let matched = 0;
124
+ let headerPrinted = false;
125
+ while (deadline === null || Date.now() < deadline) {
126
+ const page = await fetchEmailSearchPage({
127
+ apiClient,
128
+ cursor,
129
+ filters,
130
+ pageSize: flags["page-size"],
131
+ since,
132
+ });
133
+ if (!page.ok) {
134
+ const payload = extractErrorPayload(page.error);
135
+ writeErrorWithHints(payload);
136
+ removeStaleSavedCredentialOnUnauthorized({
137
+ auth,
138
+ baseUrlOverridden,
139
+ configDir: this.config.configDir,
140
+ payload,
141
+ });
142
+ process.exitCode = 1;
143
+ return;
144
+ }
145
+ cursor = page.cursor ?? cursor;
146
+ for (const email of collectNewAcceptedEmails(page.rows, seenIds)) {
147
+ if (flags.table) {
148
+ if (!headerPrinted) {
149
+ process.stderr.write(`${formatHeader(idWidth)}\n`);
150
+ headerPrinted = true;
151
+ }
152
+ this.log(formatRow(email, idWidth));
153
+ }
154
+ else {
155
+ this.log(JSON.stringify(email));
156
+ }
157
+ matched += 1;
158
+ if (matched >= flags.number)
159
+ return;
160
+ }
161
+ if (page.rows.length > 0)
162
+ continue;
163
+ if (deadline !== null && Date.now() >= deadline)
164
+ break;
165
+ await sleep(flags.interval * 1000);
166
+ }
167
+ process.stderr.write(`Timed out waiting for ${flags.number} matching email${flags.number === 1 ? "" : "s"}; received ${matched}.\n`);
168
+ process.exitCode = 1;
169
+ }
170
+ }
171
+ export default EmailsWaitCommand;
@@ -0,0 +1,165 @@
1
+ import { Command, Errors, Flags } from "@oclif/core";
2
+ import { PrimitiveApiClient } from "@primitivedotdev/sdk/api";
3
+ import { extractErrorPayload, removeStaleSavedCredentialOnUnauthorized, writeErrorWithHints, } from "../api-command.js";
4
+ import { resolveCliAuth } from "../auth.js";
5
+ import { formatHeader, formatRow, pickIdWidth } from "./emails-latest.js";
6
+ import { collectNewAcceptedEmails, DEFAULT_EMAIL_POLL_INTERVAL_SECONDS, DEFAULT_EMAIL_POLL_PAGE_SIZE, fetchEmailSearchPage, filtersFromFlags, MAX_EMAIL_POLL_PAGE_SIZE, sinceFromFlags, sleep, } from "./emails-poll.js";
7
+ function cliError(message) {
8
+ return new Errors.CLIError(message, { exit: 1 });
9
+ }
10
+ class EmailsWatchCommand extends Command {
11
+ static description = "Poll for new inbound emails and print matching messages as they arrive.";
12
+ static summary = "Watch inbound emails with filters";
13
+ static examples = [
14
+ "<%= config.bin %> emails watch --to support@example.com",
15
+ "<%= config.bin %> emails watch --subject verify --seconds 300",
16
+ "<%= config.bin %> emails watch --number 20 --jsonl",
17
+ ];
18
+ static flags = {
19
+ "api-key": Flags.string({
20
+ description: "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
21
+ env: "PRIMITIVE_API_KEY",
22
+ }),
23
+ "api-base-url-1": Flags.string({
24
+ description: "Override the primary API base URL. Internal testing only; not documented to customers.",
25
+ env: "PRIMITIVE_API_BASE_URL_1",
26
+ hidden: true,
27
+ }),
28
+ "api-base-url-2": Flags.string({
29
+ description: "Override the attachments-supporting send host base URL. Internal testing only; not documented to customers.",
30
+ env: "PRIMITIVE_API_BASE_URL_2",
31
+ hidden: true,
32
+ }),
33
+ body: Flags.string({
34
+ description: "Full-text body filter",
35
+ }),
36
+ domain: Flags.string({
37
+ description: "Filter by inbound email domain",
38
+ }),
39
+ "domain-id": Flags.string({
40
+ description: "Filter by domain UUID",
41
+ }),
42
+ from: Flags.string({
43
+ description: "Filter by sender address or domain",
44
+ }),
45
+ "has-attachment": Flags.boolean({
46
+ description: "Only show emails with one or more attachments",
47
+ }),
48
+ "include-existing": Flags.boolean({
49
+ description: "Start from existing matching emails instead of only new arrivals",
50
+ }),
51
+ interval: Flags.integer({
52
+ default: DEFAULT_EMAIL_POLL_INTERVAL_SECONDS,
53
+ description: "Seconds to wait between empty polls",
54
+ min: 1,
55
+ }),
56
+ jsonl: Flags.boolean({
57
+ description: "Print each email as one JSON object per line",
58
+ }),
59
+ number: Flags.integer({
60
+ description: "Exit after printing this many matching emails",
61
+ min: 1,
62
+ }),
63
+ "page-size": Flags.integer({
64
+ default: DEFAULT_EMAIL_POLL_PAGE_SIZE,
65
+ description: `Emails to fetch per poll (1-${MAX_EMAIL_POLL_PAGE_SIZE})`,
66
+ max: MAX_EMAIL_POLL_PAGE_SIZE,
67
+ min: 1,
68
+ }),
69
+ q: Flags.string({
70
+ description: "Full-text search DSL query",
71
+ }),
72
+ seconds: Flags.integer({
73
+ description: "Exit after this many seconds",
74
+ min: 1,
75
+ }),
76
+ since: Flags.string({
77
+ description: "Only show emails received on or after this date/time",
78
+ }),
79
+ "spam-score-gte": Flags.integer({
80
+ description: "Only show emails with spam score greater than or equal to this value",
81
+ }),
82
+ "spam-score-lt": Flags.integer({
83
+ description: "Only show emails with spam score below this value",
84
+ }),
85
+ subject: Flags.string({
86
+ description: "Full-text subject filter",
87
+ }),
88
+ to: Flags.string({
89
+ description: "Filter by recipient address or domain",
90
+ }),
91
+ };
92
+ async run() {
93
+ const { flags } = await this.parse(EmailsWatchCommand);
94
+ const baseUrlOverridden = flags["api-base-url-1"] !== undefined ||
95
+ flags["api-base-url-2"] !== undefined;
96
+ const auth = resolveCliAuth({
97
+ apiKey: flags["api-key"],
98
+ apiBaseUrl1: flags["api-base-url-1"],
99
+ apiBaseUrl2: flags["api-base-url-2"],
100
+ configDir: this.config.configDir,
101
+ });
102
+ const apiClient = new PrimitiveApiClient({
103
+ apiKey: auth.apiKey,
104
+ apiBaseUrl1: auth.apiBaseUrl1,
105
+ apiBaseUrl2: auth.apiBaseUrl2,
106
+ });
107
+ let since;
108
+ try {
109
+ since = sinceFromFlags(flags);
110
+ }
111
+ catch (error) {
112
+ throw cliError(error instanceof Error ? error.message : String(error));
113
+ }
114
+ const filters = filtersFromFlags(flags);
115
+ const deadline = flags.seconds ? Date.now() + flags.seconds * 1000 : null;
116
+ const idWidth = pickIdWidth(Boolean(process.stdout.isTTY));
117
+ const seenIds = new Set();
118
+ let cursor = null;
119
+ let printed = 0;
120
+ let headerPrinted = false;
121
+ while (deadline === null || Date.now() < deadline) {
122
+ const page = await fetchEmailSearchPage({
123
+ apiClient,
124
+ cursor,
125
+ filters,
126
+ pageSize: flags["page-size"],
127
+ since,
128
+ });
129
+ if (!page.ok) {
130
+ const payload = extractErrorPayload(page.error);
131
+ writeErrorWithHints(payload);
132
+ removeStaleSavedCredentialOnUnauthorized({
133
+ auth,
134
+ baseUrlOverridden,
135
+ configDir: this.config.configDir,
136
+ payload,
137
+ });
138
+ process.exitCode = 1;
139
+ return;
140
+ }
141
+ cursor = page.cursor ?? cursor;
142
+ for (const email of collectNewAcceptedEmails(page.rows, seenIds)) {
143
+ if (flags.jsonl) {
144
+ this.log(JSON.stringify(email));
145
+ }
146
+ else {
147
+ if (!headerPrinted) {
148
+ process.stderr.write(`${formatHeader(idWidth)}\n`);
149
+ headerPrinted = true;
150
+ }
151
+ this.log(formatRow(email, idWidth));
152
+ }
153
+ printed += 1;
154
+ if (flags.number && printed >= flags.number)
155
+ return;
156
+ }
157
+ if (page.rows.length > 0)
158
+ continue;
159
+ if (deadline !== null && Date.now() >= deadline)
160
+ break;
161
+ await sleep(flags.interval * 1000);
162
+ }
163
+ }
164
+ }
165
+ export default EmailsWatchCommand;
@@ -0,0 +1,123 @@
1
+ import { Command, Flags } from "@oclif/core";
2
+ import { createFunction, PrimitiveApiClient } from "@primitivedotdev/sdk/api";
3
+ import { extractErrorPayload, readTextFileFlag, removeStaleSavedCredentialOnUnauthorized, runWithTiming, TIME_FLAG_DESCRIPTION, writeErrorWithHints, } from "../api-command.js";
4
+ import { resolveCliAuth } from "../auth.js";
5
+ import { emitRawSendMailFetchWarning } from "../lint/raw-send-mail-fetch.js";
6
+ // `primitive functions:deploy` is the agent-grade shortcut for
7
+ // `functions:create-function`. The underlying operation takes `code`
8
+ // as a string in the JSON body, which is awkward at the CLI for
9
+ // multi-line bundles: agents would otherwise have to shell-escape an
10
+ // entire ESM file or write a temp body.json. This command reads the
11
+ // bundle straight off disk via --file, so the natural workflow is:
12
+ //
13
+ // esbuild handler.ts --bundle --format=esm --outfile=bundle.js
14
+ // primitive functions:deploy --name myfn --file bundle.js
15
+ //
16
+ // Source maps follow the same shape via --source-map-file. They are
17
+ // stored only on the runtime side (not in our database) so dropping
18
+ // them later in the pipeline is fine; the CLI just hands them through.
19
+ //
20
+ // For full control (raw body, --raw-body JSON, etc.) the underlying
21
+ // `functions:create-function` operation stays available.
22
+ class FunctionsDeployCommand extends Command {
23
+ static description = `Deploy a new function from a bundled handler file. Agent-grade shortcut for functions:create-function.
24
+
25
+ Reads the bundle off disk (--file) instead of forcing the caller to
26
+ serialize the source into a JSON body. Use the underlying operation
27
+ \`functions:create-function\` if you need the full flag surface
28
+ (raw-body JSON, etc.).`;
29
+ static summary = "Deploy a new function from a bundled handler file";
30
+ static examples = [
31
+ "<%= config.bin %> functions:deploy --name forwarder --file ./bundle.js",
32
+ "<%= config.bin %> functions:deploy --name forwarder --file ./bundle.js --source-map-file ./bundle.js.map",
33
+ ];
34
+ static flags = {
35
+ "api-key": Flags.string({
36
+ description: "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
37
+ env: "PRIMITIVE_API_KEY",
38
+ }),
39
+ "api-base-url-1": Flags.string({
40
+ description: "Override the primary API base URL. Internal testing only; not documented to customers.",
41
+ env: "PRIMITIVE_API_BASE_URL_1",
42
+ hidden: true,
43
+ }),
44
+ "api-base-url-2": Flags.string({
45
+ description: "Override the attachments-supporting send host base URL. Internal testing only; not documented to customers.",
46
+ env: "PRIMITIVE_API_BASE_URL_2",
47
+ hidden: true,
48
+ }),
49
+ name: Flags.string({
50
+ description: "Slug-style name. Lowercase letters, digits, hyphens, underscores. 1-64 chars. Must be unique within the org.",
51
+ required: true,
52
+ }),
53
+ file: Flags.string({
54
+ description: "Path to the bundled ESM handler file (single self-contained module). Loaded as the `code` body field.",
55
+ required: true,
56
+ }),
57
+ "source-map-file": Flags.string({
58
+ description: "Optional path to a source map for the bundle. Stored only on the runtime side and used to symbolicate stack traces.",
59
+ }),
60
+ time: Flags.boolean({
61
+ description: TIME_FLAG_DESCRIPTION,
62
+ }),
63
+ };
64
+ async run() {
65
+ const { flags } = await this.parse(FunctionsDeployCommand);
66
+ await runWithTiming(flags.time, async () => {
67
+ // Reads are inside the timed block so --time captures disk I/O
68
+ // alongside the API call. A pathological filesystem (NFS, slow
69
+ // FUSE mount) showing up here is exactly the kind of latency
70
+ // surprise --time is meant to surface.
71
+ const code = readTextFileFlag(flags.file, "--file");
72
+ const sourceMap = flags["source-map-file"]
73
+ ? readTextFileFlag(flags["source-map-file"], "--source-map-file")
74
+ : undefined;
75
+ // Non-blocking deploy-time lint: if the bundle has a raw
76
+ // fetch(...) call against /send-mail, nudge the author toward
77
+ // `createPrimitiveClient` from `@primitivedotdev/sdk/api`.
78
+ // The warning lands on stderr so it never contaminates the
79
+ // JSON stdout the caller may pipe into jq.
80
+ emitRawSendMailFetchWarning(code, (chunk) => process.stderr.write(chunk));
81
+ const baseUrlOverridden = flags["api-base-url-1"] !== undefined ||
82
+ flags["api-base-url-2"] !== undefined;
83
+ const auth = resolveCliAuth({
84
+ apiKey: flags["api-key"],
85
+ apiBaseUrl1: flags["api-base-url-1"],
86
+ apiBaseUrl2: flags["api-base-url-2"],
87
+ configDir: this.config.configDir,
88
+ });
89
+ const apiClient = new PrimitiveApiClient({
90
+ apiKey: auth.apiKey,
91
+ apiBaseUrl1: auth.apiBaseUrl1,
92
+ apiBaseUrl2: auth.apiBaseUrl2,
93
+ });
94
+ const authFailureContext = {
95
+ auth,
96
+ baseUrlOverridden,
97
+ configDir: this.config.configDir,
98
+ };
99
+ const result = await createFunction({
100
+ body: {
101
+ name: flags.name,
102
+ code,
103
+ ...(sourceMap !== undefined ? { sourceMap } : {}),
104
+ },
105
+ client: apiClient.client,
106
+ responseStyle: "fields",
107
+ });
108
+ if (result.error) {
109
+ const errorPayload = extractErrorPayload(result.error);
110
+ writeErrorWithHints(errorPayload);
111
+ removeStaleSavedCredentialOnUnauthorized({
112
+ ...authFailureContext,
113
+ payload: errorPayload,
114
+ });
115
+ process.exitCode = 1;
116
+ return;
117
+ }
118
+ const envelope = result.data;
119
+ this.log(JSON.stringify(envelope?.data ?? null, null, 2));
120
+ });
121
+ }
122
+ }
123
+ export default FunctionsDeployCommand;