@primitivedotdev/sdk 0.18.0 → 0.20.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,233 @@
1
+ import { spawn } from "node:child_process";
2
+ import { hostname } from "node:os";
3
+ import { Command, Errors, Flags } from "@oclif/core";
4
+ import { getAccount, pollCliLogin, startCliLogin, } from "../../api/generated/sdk.gen.js";
5
+ import { PrimitiveApiClient } from "../../api/index.js";
6
+ import { API_ERROR_CODES, extractErrorCode, extractErrorPayload, removeStaleSavedCredentialOnUnauthorized, writeErrorWithHints, } from "../api-command.js";
7
+ import { acquireCliCredentialsLock, credentialsPath, loadCliCredentials, normalizeBaseUrl, saveCliCredentials, } from "../auth.js";
8
+ const MAX_CLI_LOGIN_POLL_INTERVAL_SECONDS = 60;
9
+ function cliError(message) {
10
+ return new Errors.CLIError(message, { exit: 1 });
11
+ }
12
+ function sleep(ms) {
13
+ return new Promise((resolve) => setTimeout(resolve, ms));
14
+ }
15
+ function openBrowser(url) {
16
+ const command = process.platform === "darwin"
17
+ ? "open"
18
+ : process.platform === "win32"
19
+ ? "cmd"
20
+ : "xdg-open";
21
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
22
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
23
+ child.on("error", () => undefined);
24
+ child.unref();
25
+ }
26
+ function unwrapData(value) {
27
+ const envelope = value;
28
+ return envelope?.data ?? null;
29
+ }
30
+ function retryAfterSeconds(result) {
31
+ const response = result.response;
32
+ const raw = response?.headers.get("retry-after");
33
+ if (!raw)
34
+ return null;
35
+ const parsed = Number.parseInt(raw, 10);
36
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
37
+ }
38
+ export async function checkExistingLogin(params) {
39
+ const baseUrlOverridden = params.baseUrl !== undefined;
40
+ const probeBaseUrl = baseUrlOverridden
41
+ ? normalizeBaseUrl(params.baseUrl)
42
+ : params.credentials.base_url;
43
+ const apiClient = new PrimitiveApiClient({
44
+ apiKey: params.credentials.api_key,
45
+ baseUrl: probeBaseUrl,
46
+ });
47
+ const result = await (params.checkAccount ??
48
+ ((client) => getAccount({
49
+ client: client.client,
50
+ responseStyle: "fields",
51
+ })))(apiClient);
52
+ if (!result.error)
53
+ return { status: "valid" };
54
+ const payload = extractErrorPayload(result.error);
55
+ const auth = {
56
+ apiKey: params.credentials.api_key,
57
+ baseUrl: probeBaseUrl,
58
+ credentials: params.credentials,
59
+ source: "stored",
60
+ };
61
+ const removed = removeStaleSavedCredentialOnUnauthorized({
62
+ auth,
63
+ baseUrlOverridden,
64
+ configDir: params.configDir,
65
+ payload,
66
+ });
67
+ if (removed)
68
+ return { status: "removed_stale" };
69
+ const code = extractErrorCode(payload);
70
+ return {
71
+ status: "blocked",
72
+ payload,
73
+ message: code === API_ERROR_CODES.unauthorized
74
+ ? "Saved Primitive CLI credentials were rejected. Run `primitive logout` to remove them before logging in again."
75
+ : "A saved Primitive CLI login exists, but the CLI could not verify whether it is still valid. Run `primitive logout` before logging in again.",
76
+ };
77
+ }
78
+ class LoginCommand extends Command {
79
+ static description = "Log in by opening Primitive in your browser and saving an org-scoped CLI API key locally.";
80
+ static summary = "Log in with browser approval";
81
+ static examples = [
82
+ "<%= config.bin %> login",
83
+ "<%= config.bin %> login --device-name work-laptop",
84
+ "<%= config.bin %> login --force",
85
+ ];
86
+ static flags = {
87
+ "base-url": Flags.string({
88
+ description: "API base URL (defaults to PRIMITIVE_API_URL or production)",
89
+ env: "PRIMITIVE_API_URL",
90
+ }),
91
+ "device-name": Flags.string({
92
+ description: "Device name shown in the browser approval screen",
93
+ }),
94
+ "no-browser": Flags.boolean({
95
+ description: "Do not attempt to open the browser automatically",
96
+ }),
97
+ force: Flags.boolean({
98
+ char: "f",
99
+ description: "Replace saved credentials without first verifying the existing login",
100
+ }),
101
+ };
102
+ async run() {
103
+ const { flags } = await this.parse(LoginCommand);
104
+ let releaseCredentialsLock;
105
+ try {
106
+ releaseCredentialsLock = acquireCliCredentialsLock(this.config.configDir);
107
+ }
108
+ catch (error) {
109
+ const detail = error instanceof Error ? error.message : String(error);
110
+ throw cliError(detail);
111
+ }
112
+ try {
113
+ await this.runWithCredentialLock(flags);
114
+ }
115
+ finally {
116
+ releaseCredentialsLock();
117
+ }
118
+ }
119
+ async runWithCredentialLock(flags) {
120
+ const baseUrl = normalizeBaseUrl(flags["base-url"]);
121
+ let existing;
122
+ try {
123
+ existing = loadCliCredentials(this.config.configDir);
124
+ }
125
+ catch (error) {
126
+ if (!flags.force)
127
+ throw error;
128
+ const detail = error instanceof Error ? error.message : String(error);
129
+ process.stderr.write(`Replacing unreadable Primitive CLI credentials because --force was set: ${detail}\n`);
130
+ existing = null;
131
+ }
132
+ if (existing && flags.force) {
133
+ process.stderr.write("Replacing saved Primitive CLI credentials after browser approval because --force was set.\n");
134
+ }
135
+ else if (existing) {
136
+ const existingStatus = await checkExistingLogin({
137
+ baseUrl: flags["base-url"],
138
+ configDir: this.config.configDir,
139
+ credentials: existing,
140
+ });
141
+ if (existingStatus.status === "removed_stale") {
142
+ process.stderr.write("Continuing with a new Primitive CLI login...\n");
143
+ }
144
+ else if (existingStatus.status === "blocked") {
145
+ writeErrorWithHints(existingStatus.payload);
146
+ throw cliError(existingStatus.message);
147
+ }
148
+ else {
149
+ const org = existing.org_name ? ` for ${existing.org_name}` : "";
150
+ throw cliError(`Already logged in${org}. Run \`primitive logout\` before logging in again.`);
151
+ }
152
+ }
153
+ const apiClient = new PrimitiveApiClient({ baseUrl });
154
+ const deviceName = flags["device-name"] ?? hostname();
155
+ const started = await startCliLogin({
156
+ body: {
157
+ device_name: deviceName,
158
+ },
159
+ client: apiClient.client,
160
+ responseStyle: "fields",
161
+ });
162
+ if (started.error) {
163
+ writeErrorWithHints(extractErrorPayload(started.error));
164
+ throw cliError("Could not start Primitive CLI login.");
165
+ }
166
+ const start = unwrapData(started.data);
167
+ if (!start) {
168
+ throw cliError("Primitive API returned an empty CLI login response.");
169
+ }
170
+ process.stderr.write(`Your login code is: ${start.user_code}\n`);
171
+ if (!flags["no-browser"]) {
172
+ openBrowser(start.verification_uri_complete);
173
+ process.stderr.write("Opening Primitive in your browser...\n");
174
+ }
175
+ process.stderr.write(`If the browser did not open, visit: ${start.verification_uri_complete}\n`);
176
+ process.stderr.write("Waiting for browser approval...\n");
177
+ const deadline = Date.now() + start.expires_in * 1000;
178
+ let interval = Math.min(Math.max(1, start.interval), MAX_CLI_LOGIN_POLL_INTERVAL_SECONDS);
179
+ let nextPollDelay = 1;
180
+ while (Date.now() < deadline) {
181
+ await sleep(nextPollDelay * 1000);
182
+ nextPollDelay = interval;
183
+ const polled = await pollCliLogin({
184
+ body: { device_code: start.device_code },
185
+ client: apiClient.client,
186
+ responseStyle: "fields",
187
+ });
188
+ if (polled.data) {
189
+ const login = unwrapData(polled.data);
190
+ if (!login) {
191
+ throw cliError("Primitive API returned an empty CLI poll response.");
192
+ }
193
+ saveCliCredentials(this.config.configDir, {
194
+ api_key: login.api_key,
195
+ base_url: baseUrl,
196
+ created_at: new Date().toISOString(),
197
+ key_id: login.key_id,
198
+ key_prefix: login.key_prefix,
199
+ org_id: login.org_id,
200
+ org_name: login.org_name,
201
+ });
202
+ const org = login.org_name ? ` (${login.org_name})` : "";
203
+ process.stderr.write(`Logged in to org ${login.org_id}${org}.\n`);
204
+ process.stderr.write(`Saved credentials to ${credentialsPath(this.config.configDir)}.\n`);
205
+ return;
206
+ }
207
+ const payload = extractErrorPayload(polled.error);
208
+ const code = extractErrorCode(payload);
209
+ if (code === API_ERROR_CODES.authorizationPending) {
210
+ nextPollDelay = interval;
211
+ continue;
212
+ }
213
+ if (code === API_ERROR_CODES.slowDown) {
214
+ interval = Math.min(retryAfterSeconds(polled) ?? interval + 5, MAX_CLI_LOGIN_POLL_INTERVAL_SECONDS);
215
+ nextPollDelay = interval;
216
+ continue;
217
+ }
218
+ if (code === API_ERROR_CODES.accessDenied) {
219
+ throw cliError("Primitive CLI login was denied in the browser.");
220
+ }
221
+ if (code === API_ERROR_CODES.expiredToken) {
222
+ throw cliError("Primitive CLI login expired. Run `primitive login` again.");
223
+ }
224
+ if (code === API_ERROR_CODES.invalidDeviceCode) {
225
+ throw cliError("Primitive CLI login device code is invalid. Run `primitive login` again.");
226
+ }
227
+ writeErrorWithHints(payload);
228
+ throw cliError("Primitive CLI login failed while polling for approval.");
229
+ }
230
+ throw cliError("Primitive CLI login expired. Run `primitive login` again.");
231
+ }
232
+ }
233
+ export default LoginCommand;
@@ -0,0 +1,87 @@
1
+ import { Command, Errors, Flags } from "@oclif/core";
2
+ import { cliLogout } from "../../api/generated/sdk.gen.js";
3
+ import { PrimitiveApiClient } from "../../api/index.js";
4
+ import { API_ERROR_CODES, extractErrorCode, extractErrorPayload, writeErrorWithHints, } from "../api-command.js";
5
+ import { acquireCliCredentialsLock, deleteCliCredentials, loadCliCredentials, normalizeBaseUrl, } from "../auth.js";
6
+ function cliError(message) {
7
+ return new Errors.CLIError(message, { exit: 1 });
8
+ }
9
+ function unwrapData(value) {
10
+ const envelope = value;
11
+ return envelope?.data ?? null;
12
+ }
13
+ class LogoutCommand extends Command {
14
+ static description = "Log out by revoking the saved Primitive CLI API key and deleting local credentials.";
15
+ static summary = "Log out and revoke the saved CLI key";
16
+ static examples = ["<%= config.bin %> logout"];
17
+ static flags = {
18
+ "base-url": Flags.string({
19
+ description: "Override the API base URL used for key revocation",
20
+ env: "PRIMITIVE_API_URL",
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 baseUrl = flags["base-url"]
56
+ ? normalizeBaseUrl(flags["base-url"])
57
+ : credentials.base_url;
58
+ const apiClient = new PrimitiveApiClient({
59
+ apiKey: credentials.api_key,
60
+ baseUrl,
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;
@@ -1,7 +1,8 @@
1
1
  import { Command, Errors, Flags } from "@oclif/core";
2
2
  import { listDomains, sendEmail } from "../../api/generated/sdk.gen.js";
3
3
  import { PrimitiveApiClient } from "../../api/index.js";
4
- import { extractErrorCode, extractErrorPayload, formatErrorPayload, runWithTiming, TIME_FLAG_DESCRIPTION, writeErrorWithHints, } from "../api-command.js";
4
+ import { API_ERROR_CODES, extractErrorCode, extractErrorPayload, formatErrorPayload, removeStaleSavedCredentialOnUnauthorized, runWithTiming, TIME_FLAG_DESCRIPTION, writeErrorWithHints, } from "../api-command.js";
5
+ import { resolveCliAuth } from "../auth.js";
5
6
  // `primitive send` is the agent-grade shortcut for the most common
6
7
  // case: send a fresh outbound email. It wraps `sending:send-email`
7
8
  // with two ergonomic defaults that the underlying operation can't
@@ -53,7 +54,7 @@ function deriveSubject(body) {
53
54
  function isVerifiedDomain(domain) {
54
55
  return domain.is_active === true;
55
56
  }
56
- async function pickDefaultFromAddress(apiClient) {
57
+ async function pickDefaultFromAddress(apiClient, authFailureContext) {
57
58
  const result = await listDomains({
58
59
  client: apiClient.client,
59
60
  responseStyle: "fields",
@@ -65,8 +66,12 @@ async function pickDefaultFromAddress(apiClient) {
65
66
  // Surface the auth hint via writeErrorWithHints and bail with
66
67
  // a focused message instead of the verbose "underlying error"
67
68
  // wrapping.
68
- if (extractErrorCode(errorPayload) === "unauthorized") {
69
+ if (extractErrorCode(errorPayload) === API_ERROR_CODES.unauthorized) {
69
70
  writeErrorWithHints(errorPayload);
71
+ removeStaleSavedCredentialOnUnauthorized({
72
+ ...authFailureContext,
73
+ payload: errorPayload,
74
+ });
70
75
  // exit: 1 to match the run() unauthorized path (which uses
71
76
  // `process.exitCode = 1`). oclif's CLIError defaults to 2,
72
77
  // so without this override the same "unauthorized" condition
@@ -107,7 +112,7 @@ class SendCommand extends Command {
107
112
  ];
108
113
  static flags = {
109
114
  "api-key": Flags.string({
110
- description: "Primitive API key (defaults to PRIMITIVE_API_KEY)",
115
+ description: "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
111
116
  env: "PRIMITIVE_API_KEY",
112
117
  }),
113
118
  "base-url": Flags.string({
@@ -149,11 +154,23 @@ class SendCommand extends Command {
149
154
  throw new Errors.CLIError("Either --body or --html (or both) is required.");
150
155
  }
151
156
  await runWithTiming(flags.time, async () => {
152
- const apiClient = new PrimitiveApiClient({
157
+ const baseUrlOverridden = flags["base-url"] !== undefined;
158
+ const auth = resolveCliAuth({
153
159
  apiKey: flags["api-key"],
154
160
  baseUrl: flags["base-url"],
161
+ configDir: this.config.configDir,
162
+ });
163
+ const apiClient = new PrimitiveApiClient({
164
+ apiKey: auth.apiKey,
165
+ baseUrl: auth.baseUrl,
155
166
  });
156
- const from = flags.from ?? (await pickDefaultFromAddress(apiClient));
167
+ const authFailureContext = {
168
+ auth,
169
+ baseUrlOverridden,
170
+ configDir: this.config.configDir,
171
+ };
172
+ const from = flags.from ??
173
+ (await pickDefaultFromAddress(apiClient, authFailureContext));
157
174
  const subject = flags.subject ?? (flags.body ? deriveSubject(flags.body) : "Message");
158
175
  const result = await sendEmail({
159
176
  body: {
@@ -174,7 +191,12 @@ class SendCommand extends Command {
174
191
  responseStyle: "fields",
175
192
  });
176
193
  if (result.error) {
177
- writeErrorWithHints(extractErrorPayload(result.error));
194
+ const errorPayload = extractErrorPayload(result.error);
195
+ writeErrorWithHints(errorPayload);
196
+ removeStaleSavedCredentialOnUnauthorized({
197
+ ...authFailureContext,
198
+ payload: errorPayload,
199
+ });
178
200
  process.exitCode = 1;
179
201
  return;
180
202
  }
@@ -1,7 +1,8 @@
1
1
  import { Command, Errors, Flags } from "@oclif/core";
2
2
  import { getAccount } from "../../api/generated/sdk.gen.js";
3
3
  import { PrimitiveApiClient } from "../../api/index.js";
4
- import { extractErrorPayload, runWithTiming, TIME_FLAG_DESCRIPTION, writeErrorWithHints, } from "../api-command.js";
4
+ import { extractErrorPayload, removeStaleSavedCredentialOnUnauthorized, runWithTiming, TIME_FLAG_DESCRIPTION, writeErrorWithHints, } from "../api-command.js";
5
+ import { resolveCliAuth } from "../auth.js";
5
6
  // `primitive whoami` is the credentials smoke-test the AGX
6
7
  // walkthrough kept asking for. Before this command, a user with a
7
8
  // suspect API key had no fast way to verify "is my key live and
@@ -21,7 +22,7 @@ class WhoamiCommand extends Command {
21
22
  ];
22
23
  static flags = {
23
24
  "api-key": Flags.string({
24
- description: "Primitive API key (defaults to PRIMITIVE_API_KEY)",
25
+ description: "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
25
26
  env: "PRIMITIVE_API_KEY",
26
27
  }),
27
28
  "base-url": Flags.string({
@@ -35,16 +36,29 @@ class WhoamiCommand extends Command {
35
36
  async run() {
36
37
  const { flags } = await this.parse(WhoamiCommand);
37
38
  await runWithTiming(flags.time, async () => {
38
- const apiClient = new PrimitiveApiClient({
39
+ const baseUrlOverridden = flags["base-url"] !== undefined;
40
+ const auth = resolveCliAuth({
39
41
  apiKey: flags["api-key"],
40
42
  baseUrl: flags["base-url"],
43
+ configDir: this.config.configDir,
44
+ });
45
+ const apiClient = new PrimitiveApiClient({
46
+ apiKey: auth.apiKey,
47
+ baseUrl: auth.baseUrl,
41
48
  });
42
49
  const result = await getAccount({
43
50
  client: apiClient.client,
44
51
  responseStyle: "fields",
45
52
  });
46
53
  if (result.error) {
47
- writeErrorWithHints(extractErrorPayload(result.error));
54
+ const errorPayload = extractErrorPayload(result.error);
55
+ writeErrorWithHints(errorPayload);
56
+ removeStaleSavedCredentialOnUnauthorized({
57
+ auth,
58
+ baseUrlOverridden,
59
+ configDir: this.config.configDir,
60
+ payload: errorPayload,
61
+ });
48
62
  process.exitCode = 1;
49
63
  return;
50
64
  }
@@ -73,7 +73,7 @@ export function renderFishCompletion(binName) {
73
73
  ]) {
74
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
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)'`, `complete -c ${binName} -n '${operationCondition(operation).replace(BIN_PLACEHOLDER, binName)}' -l 'base-url' -r -d 'API base URL (defaults to PRIMITIVE_API_URL or production)'`);
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)'`, `complete -c ${binName} -n '${operationCondition(operation).replace(BIN_PLACEHOLDER, binName)}' -l 'base-url' -r -d 'API base URL (defaults to PRIMITIVE_API_URL or production)'`);
77
77
  if (operation.hasJsonBody) {
78
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
79
  }
@@ -2,6 +2,10 @@ import { Args, Command, Errors } from "@oclif/core";
2
2
  import { operationManifest, } from "../openapi/index.js";
3
3
  import { createOperationCommand } from "./api-command.js";
4
4
  import EmailsLatestCommand from "./commands/emails-latest.js";
5
+ import FunctionsDeployCommand from "./commands/functions-deploy.js";
6
+ import FunctionsRedeployCommand from "./commands/functions-redeploy.js";
7
+ import LoginCommand from "./commands/login.js";
8
+ import LogoutCommand from "./commands/logout.js";
5
9
  import SendCommand from "./commands/send.js";
6
10
  import WhoamiCommand from "./commands/whoami.js";
7
11
  import { renderFishCompletion } from "./fish-completion.js";
@@ -116,6 +120,10 @@ export const COMMANDS = {
116
120
  // operation stays available under sending:send-email for callers
117
121
  // who want every flag.
118
122
  send: SendCommand,
123
+ // `login` creates and stores an org-scoped CLI API key via browser approval.
124
+ login: LoginCommand,
125
+ // `logout` revokes the saved CLI API key and removes local credentials.
126
+ logout: LogoutCommand,
119
127
  // `whoami` is the credentials smoke test. Prints the account the
120
128
  // current API key authenticates as. AGX walkthroughs kept
121
129
  // wanting this before risking a real call against a possibly-
@@ -125,5 +133,13 @@ export const COMMANDS = {
125
133
  // inbound emails as a compact text table. emails:list-emails stays
126
134
  // available for the full JSON envelope + cursor pagination.
127
135
  "emails:latest": EmailsLatestCommand,
136
+ // `functions:deploy` and `functions:redeploy` are file-input
137
+ // shortcuts for create-function / update-function. The underlying
138
+ // ops take `code` as a body string, which is awkward at the CLI
139
+ // for multi-line bundles; these read the bundle off disk and pass
140
+ // it through. The auto-generated functions:* operations stay
141
+ // available for callers that want the full surface.
142
+ "functions:deploy": FunctionsDeployCommand,
143
+ "functions:redeploy": FunctionsRedeployCommand,
128
144
  ...generatedCommands,
129
145
  };