@oxygen-agent/cli 1.244.2 → 1.256.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.js +1066 -21
- package/node_modules/@oxygen/shared/dist/directory.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/directory.js +34 -0
- package/node_modules/@oxygen/shared/dist/email-tracking-token.d.ts +82 -0
- package/node_modules/@oxygen/shared/dist/email-tracking-token.js +130 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/index.js +3 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +55 -8
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +25 -15
- package/node_modules/@oxygen/shared/dist/sequence-template.d.ts +60 -0
- package/node_modules/@oxygen/shared/dist/sequence-template.js +0 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +234 -12
- package/node_modules/@oxygen/shared/dist/sequences.js +423 -28
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
7
7
|
import { stdin as input, stdout as output } from "node:process";
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
9
|
import { Command, Option } from "commander";
|
|
10
|
-
import { formatCellForDisplay, formatPublicBudgetScopes, OXYGEN_VERSION, OxygenError, success, toFailure, } from "@oxygen/shared";
|
|
10
|
+
import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, formatCellForDisplay, formatPublicBudgetScopes, OXYGEN_VERSION, OxygenError, success, toFailure, } from "@oxygen/shared";
|
|
11
11
|
import { inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, } from "@oxygen/shared/file-import";
|
|
12
12
|
import { assertRecipeBundleSafe, assertWorkflowManifest, buildRecipeManifest, compileWorkflowDefinition, isAnyWorkflowManifest, isRecipeManifest, isWorkflowDefinition, isWorkflowManifest, } from "@oxygen/workflows";
|
|
13
13
|
import { isRecipeDefinition } from "@oxygen/recipe-sdk";
|
|
@@ -323,6 +323,60 @@ function readCustomIntegrationManifest(options) {
|
|
|
323
323
|
}
|
|
324
324
|
return parseJsonObject(manifestJson ?? readFileSync(resolve(manifestPath ?? ""), "utf8"));
|
|
325
325
|
}
|
|
326
|
+
const CUSTOM_INTEGRATION_SECRET_KEYS = ["API_KEY", "TOKEN", "PASSWORD", "SECRET"];
|
|
327
|
+
function readCustomIntegrationSecret(options) {
|
|
328
|
+
const apiKey = readOption(options.apiKey);
|
|
329
|
+
const secretsFile = readOption(options.secretsFile);
|
|
330
|
+
if (apiKey && secretsFile) {
|
|
331
|
+
throw new OxygenError("invalid_request", "Pass either --api-key or --secrets-file, not both.", {
|
|
332
|
+
exitCode: 1,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
if (apiKey)
|
|
336
|
+
return apiKey;
|
|
337
|
+
if (!secretsFile) {
|
|
338
|
+
throw new OxygenError("invalid_request", "Pass --api-key or --secrets-file.", {
|
|
339
|
+
exitCode: 1,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
const entries = parseEnvFile(readFileSync(resolve(secretsFile), "utf8"));
|
|
343
|
+
const keys = Object.keys(entries);
|
|
344
|
+
if (keys.length === 0) {
|
|
345
|
+
throw new OxygenError("invalid_request", "Secrets file has no KEY=VALUE entries.", {
|
|
346
|
+
details: { file: secretsFile },
|
|
347
|
+
exitCode: 1,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
if (keys.length === 1)
|
|
351
|
+
return entries[keys[0]];
|
|
352
|
+
const preferred = CUSTOM_INTEGRATION_SECRET_KEYS.find((key) => entries[key] !== undefined);
|
|
353
|
+
if (preferred)
|
|
354
|
+
return entries[preferred];
|
|
355
|
+
throw new OxygenError("invalid_request", "Secrets file is ambiguous: use a single entry or name one API_KEY, TOKEN, PASSWORD, or SECRET.", {
|
|
356
|
+
details: { file: secretsFile, keys },
|
|
357
|
+
exitCode: 1,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
function parseEnvFile(content) {
|
|
361
|
+
const entries = {};
|
|
362
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
363
|
+
const line = rawLine.trim();
|
|
364
|
+
if (!line || line.startsWith("#"))
|
|
365
|
+
continue;
|
|
366
|
+
const separator = line.indexOf("=");
|
|
367
|
+
if (separator <= 0)
|
|
368
|
+
continue;
|
|
369
|
+
const key = line.slice(0, separator).trim().replace(/^export\s+/, "");
|
|
370
|
+
let value = line.slice(separator + 1).trim();
|
|
371
|
+
if ((value.startsWith("\"") && value.endsWith("\"") && value.length >= 2)
|
|
372
|
+
|| (value.startsWith("'") && value.endsWith("'") && value.length >= 2)) {
|
|
373
|
+
value = value.slice(1, -1);
|
|
374
|
+
}
|
|
375
|
+
if (key && value)
|
|
376
|
+
entries[key] = value;
|
|
377
|
+
}
|
|
378
|
+
return entries;
|
|
379
|
+
}
|
|
326
380
|
function readFileIfPresent(value) {
|
|
327
381
|
const candidate = resolve(value);
|
|
328
382
|
try {
|
|
@@ -1104,11 +1158,13 @@ export function createProgram() {
|
|
|
1104
1158
|
.option("--body <body>", "What you were doing, what happened, and what you tried.")
|
|
1105
1159
|
.option("--severity <severity>", "low | normal | high. Defaults to normal.")
|
|
1106
1160
|
.option("--category <category>", "Optional category label.")
|
|
1161
|
+
.option("--source <source>", "Where the ticket originated. Only slack is supported today.")
|
|
1107
1162
|
.option("--operation <operation>", "The operation/tool that failed, e.g. oxygen_columns_run.")
|
|
1108
1163
|
.option("--error-code <code>", "The error envelope code you received.")
|
|
1109
1164
|
.option("--run-ids <ids>", "Comma-separated run ids to attach.")
|
|
1110
1165
|
.option("--table-ids <ids>", "Comma-separated table ids to attach.")
|
|
1111
1166
|
.option("--deep-links <urls>", "Comma-separated https://oxygen-agent.com/... links.")
|
|
1167
|
+
.option("--slack-permalink <url>", "Slack message permalink (https://*.slack.com/...) to attach as context.")
|
|
1112
1168
|
.option("--json", "Print a JSON envelope.")
|
|
1113
1169
|
.action(async (options) => {
|
|
1114
1170
|
await handleAsyncAction("support ticket create", options, () => requestOxygen("/api/cli/support/tickets", {
|
|
@@ -1190,6 +1246,30 @@ export function createProgram() {
|
|
|
1190
1246
|
method: "POST",
|
|
1191
1247
|
body: { resolution: readOption(options.resolution) },
|
|
1192
1248
|
}));
|
|
1249
|
+
}))
|
|
1250
|
+
.addCommand(new Command("workflow")
|
|
1251
|
+
.description("Track verify/plan/draft triage progress on a support ticket (staff only).")
|
|
1252
|
+
.argument("<ticketId>", "Ticket UUID.")
|
|
1253
|
+
.option("--verify-status <status>", "Verify step: pending | in_progress | done | blocked | skipped.")
|
|
1254
|
+
.option("--verify-notes <text>", "Verification notes. Pass an empty string to clear.")
|
|
1255
|
+
.option("--plan-status <status>", "Plan step: pending | in_progress | done | blocked | skipped.")
|
|
1256
|
+
.option("--plan <text>", "Remediation plan. Pass an empty string to clear.")
|
|
1257
|
+
.option("--draft-status <status>", "Draft step: pending | in_progress | done | blocked | skipped.")
|
|
1258
|
+
.option("--draft <text>", "Draft reply to the opener. Pass an empty string to clear.")
|
|
1259
|
+
.option("--json", "Print a JSON envelope.")
|
|
1260
|
+
.action(async (ticketId, options) => {
|
|
1261
|
+
await handleSupportAdminWorkflowAction(ticketId, options);
|
|
1262
|
+
}))
|
|
1263
|
+
.addCommand(new Command("update")
|
|
1264
|
+
.description("Change a support ticket's status or assignee, with an optional staff note (staff only).")
|
|
1265
|
+
.argument("<ticketId>", "Ticket UUID.")
|
|
1266
|
+
.option("--status <status>", "open | triaging | waiting_on_user | closed. Use the resolve command to resolve.")
|
|
1267
|
+
.option("--assign <email>", "Assign the ticket to a staff email.")
|
|
1268
|
+
.option("--unassign", "Clear the ticket assignment.")
|
|
1269
|
+
.option("--note <text>", "Append a staff message to the ticket thread.")
|
|
1270
|
+
.option("--json", "Print a JSON envelope.")
|
|
1271
|
+
.action(async (ticketId, options) => {
|
|
1272
|
+
await handleSupportAdminUpdateAction(ticketId, options);
|
|
1193
1273
|
})));
|
|
1194
1274
|
program
|
|
1195
1275
|
.command("feedback")
|
|
@@ -3781,8 +3861,8 @@ export function createProgram() {
|
|
|
3781
3861
|
await handleAsyncAction("billing balance", options, () => requestOxygen("/api/cli/billing/balance"));
|
|
3782
3862
|
}))
|
|
3783
3863
|
.addCommand(new Command("usage")
|
|
3784
|
-
.description("Show credit ledger events
|
|
3785
|
-
.option("--meter <meter>", "credits or
|
|
3864
|
+
.description("Show credit ledger events, automation action usage, or implied LinkedIn seat COGS plus a RECOMMENDED per-account price preview (provider_seats).")
|
|
3865
|
+
.option("--meter <meter>", "credits, automation_actions, or provider_seats (rolling-30d peak connected LinkedIn accounts, implied Unipile COGS, and a RECOMMENDED per-account charge preview + implied margin — preview only, not yet billed). Defaults to credits.")
|
|
3786
3866
|
.option("--days <n>", "Lookback window in days. Defaults to 30.")
|
|
3787
3867
|
.option("--from <iso>", "Only include ledger events at or after this ISO timestamp.")
|
|
3788
3868
|
.option("--to <iso>", "Only include ledger events at or before this ISO timestamp.")
|
|
@@ -3929,6 +4009,116 @@ export function createProgram() {
|
|
|
3929
4009
|
method: "DELETE",
|
|
3930
4010
|
}));
|
|
3931
4011
|
}));
|
|
4012
|
+
program.addCommand(new Command("egress")
|
|
4013
|
+
.description("Managed mailbox-egress IP pool: status, inventory, dedicated-IP add-on, and rotation.")
|
|
4014
|
+
.addCommand(new Command("status")
|
|
4015
|
+
.description("Show egress mode, this org's open mailbox->IP assignments, and IP health counts. Read-only, 0 credits.")
|
|
4016
|
+
.option("--json", "Print a JSON envelope.")
|
|
4017
|
+
.action(async (options) => {
|
|
4018
|
+
await handleAsyncAction("egress status", options, () => requestOxygen("/api/cli/egress"));
|
|
4019
|
+
}))
|
|
4020
|
+
.addCommand(new Command("ips")
|
|
4021
|
+
.description("List the egress IPs this org may send through (shared pool for this env + any dedicated IP). Read-only.")
|
|
4022
|
+
.option("--json", "Print a JSON envelope.")
|
|
4023
|
+
.action(async (options) => {
|
|
4024
|
+
await handleAsyncAction("egress ips", options, () => requestOxygen("/api/cli/egress?view=ips"));
|
|
4025
|
+
}))
|
|
4026
|
+
.addCommand(new Command("dedicated")
|
|
4027
|
+
.description("Dedicated-egress-IP add-on ($400/mo): request a preview quote or check the add-on status. PREVIEW ONLY — never charges; checkout is human via Stripe.")
|
|
4028
|
+
.addCommand(new Command("request")
|
|
4029
|
+
.description("Preview the dedicated-egress-IP add-on ($400/mo). PREVIEW ONLY — never charges; checkout is human via Stripe.")
|
|
4030
|
+
.option("--json", "Print a JSON envelope.")
|
|
4031
|
+
.action(async (options) => {
|
|
4032
|
+
await handleAsyncAction("egress dedicated request", options, () => requestOxygen("/api/cli/egress/dedicated", { method: "POST", body: {} }));
|
|
4033
|
+
}))
|
|
4034
|
+
.addCommand(new Command("status")
|
|
4035
|
+
.description("Show this org's dedicated-egress-IP add-on state (status, quantity, activation, price). Read-only, 0 credits.")
|
|
4036
|
+
.option("--json", "Print a JSON envelope.")
|
|
4037
|
+
.action(async (options) => {
|
|
4038
|
+
await handleAsyncAction("egress dedicated status", options, () => requestOxygen("/api/cli/egress/dedicated"));
|
|
4039
|
+
})))
|
|
4040
|
+
.addCommand(new Command("rotate")
|
|
4041
|
+
.description("Rotate a mailbox's egress IP. Approval-gated: without --approve it previews and makes no change.")
|
|
4042
|
+
.requiredOption("--mailbox <id>", "Mailbox id to rotate.")
|
|
4043
|
+
.option("--vendor <vendor>", "Egress vendor (pingproxies|decodo). Defaults to the current IP's vendor.")
|
|
4044
|
+
.option("--approve", "Execute the rotation (paid vendor action). Omit for a no-side-effect preview.")
|
|
4045
|
+
.option("--json", "Print a JSON envelope.")
|
|
4046
|
+
.action(async (options) => {
|
|
4047
|
+
await handleAsyncAction("egress rotate", options, () => requestOxygen("/api/cli/egress/rotate", {
|
|
4048
|
+
method: "POST",
|
|
4049
|
+
body: {
|
|
4050
|
+
mailbox_id: readOption(options.mailbox),
|
|
4051
|
+
...(readOption(options.vendor) ? { vendor: readOption(options.vendor) } : {}),
|
|
4052
|
+
...(options.approve ? { approve: true } : {}),
|
|
4053
|
+
},
|
|
4054
|
+
}));
|
|
4055
|
+
})));
|
|
4056
|
+
program
|
|
4057
|
+
.command("directory")
|
|
4058
|
+
.description("Manage this organization's public OXYGEN agency directory listing.")
|
|
4059
|
+
.addCommand(new Command("get")
|
|
4060
|
+
.description("Read the listing, its publish state, and its public/settings deep-links.")
|
|
4061
|
+
.option("--json", "Print a JSON envelope.")
|
|
4062
|
+
.action(async (options) => {
|
|
4063
|
+
await handleAsyncAction("directory get", options, () => requestOxygen("/api/cli/directory/listing"));
|
|
4064
|
+
}))
|
|
4065
|
+
.addCommand(new Command("update")
|
|
4066
|
+
.description("Update listing profile fields. Only invited organizations can edit.")
|
|
4067
|
+
.option("--name <text>", "Agency display name.")
|
|
4068
|
+
.option("--slug <slug>", "Public URL slug (lowercase letters, digits, dashes).")
|
|
4069
|
+
.option("--logo-url <url>", "Logo image URL.")
|
|
4070
|
+
.option("--tagline <text>", "One-line pitch shown on the directory card.")
|
|
4071
|
+
.option("--description <text>", "Full profile description (plain text; blank lines separate paragraphs).")
|
|
4072
|
+
.option("--description-file <path>", "Path to a plain-text file with the description.")
|
|
4073
|
+
.option("--website <url>", "Company website URL.")
|
|
4074
|
+
.option("--company-linkedin <url>", "Company LinkedIn page URL.")
|
|
4075
|
+
.option("--founders-json <json>", "JSON array of founding-team members: [{\"name\", \"title\", \"linkedin_url\"}].")
|
|
4076
|
+
.option("--booking-url <url>", "Book-a-call URL (Cal.com, Calendly, ...).")
|
|
4077
|
+
.option("--contact-email <email>", "Public contact email.")
|
|
4078
|
+
.option("--logo-file <path>", "Upload a local png/jpeg/webp/svg logo instead of passing --logo-url.")
|
|
4079
|
+
.option("--services <csv>", `Comma-separated services from the curated set: ${AGENCY_DIRECTORY_SERVICES.join(", ")}.`)
|
|
4080
|
+
.option("--regions <csv>", `Comma-separated regions from the curated set: ${AGENCY_DIRECTORY_REGIONS.join(", ")}.`)
|
|
4081
|
+
.option("--profile-json <json>", "JSON object with any listing fields; flags override it. Use null values to clear fields.")
|
|
4082
|
+
.option("--json", "Print a JSON envelope.")
|
|
4083
|
+
.action(async (options) => {
|
|
4084
|
+
await handleAsyncAction("directory update", options, async () => {
|
|
4085
|
+
const logoPath = readOption(options.logoFile);
|
|
4086
|
+
const uploaded = logoPath ? await uploadDirectoryLogo(logoPath) : null;
|
|
4087
|
+
const profile = await buildDirectoryProfileBody(options);
|
|
4088
|
+
if (Object.keys(profile).length === 0 && uploaded)
|
|
4089
|
+
return uploaded;
|
|
4090
|
+
return requestOxygen("/api/cli/directory/listing/update", {
|
|
4091
|
+
method: "POST",
|
|
4092
|
+
body: { profile },
|
|
4093
|
+
});
|
|
4094
|
+
});
|
|
4095
|
+
}))
|
|
4096
|
+
.addCommand(new Command("publish")
|
|
4097
|
+
.description("Publish the listing to the public directory at https://oxygen-agent.com/agencies.")
|
|
4098
|
+
.option("--json", "Print a JSON envelope.")
|
|
4099
|
+
.action(async (options) => {
|
|
4100
|
+
await handleAsyncAction("directory publish", options, () => requestOxygen("/api/cli/directory/listing/publish", { method: "POST", body: {} }));
|
|
4101
|
+
}))
|
|
4102
|
+
.addCommand(new Command("unpublish")
|
|
4103
|
+
.description("Remove the listing from the public directory (keeps the draft editable).")
|
|
4104
|
+
.option("--json", "Print a JSON envelope.")
|
|
4105
|
+
.action(async (options) => {
|
|
4106
|
+
await handleAsyncAction("directory unpublish", options, () => requestOxygen("/api/cli/directory/listing/unpublish", { method: "POST", body: {} }));
|
|
4107
|
+
}))
|
|
4108
|
+
.addCommand(new Command("apply")
|
|
4109
|
+
.description("Apply to be listed in the public agency directory (reviewed by the OXYGEN team).")
|
|
4110
|
+
.option("--message <text>", "What you do, who you serve, and how you use OXYGEN for client work.")
|
|
4111
|
+
.option("--website <url>", "Your agency website URL.")
|
|
4112
|
+
.option("--json", "Print a JSON envelope.")
|
|
4113
|
+
.action(async (options) => {
|
|
4114
|
+
await handleAsyncAction("directory apply", options, () => requestOxygen("/api/cli/directory/apply", {
|
|
4115
|
+
method: "POST",
|
|
4116
|
+
body: {
|
|
4117
|
+
...(readOption(options.message) ? { message: readOption(options.message) } : {}),
|
|
4118
|
+
...(readOption(options.website) ? { website: readOption(options.website) } : {}),
|
|
4119
|
+
},
|
|
4120
|
+
}));
|
|
4121
|
+
}));
|
|
3932
4122
|
program
|
|
3933
4123
|
.command("admin")
|
|
3934
4124
|
.description("Staff-only commands.")
|
|
@@ -3950,7 +4140,55 @@ export function createProgram() {
|
|
|
3950
4140
|
const suffix = params.toString() ? `?${params.toString()}` : "";
|
|
3951
4141
|
return requestOxygen(`/api/cli/admin/costs${suffix}`);
|
|
3952
4142
|
});
|
|
3953
|
-
}))
|
|
4143
|
+
}))
|
|
4144
|
+
.addCommand(new Command("directory")
|
|
4145
|
+
.description("Curate the public agency directory (invite, prefill, delist). Staff only.")
|
|
4146
|
+
.addCommand(new Command("list")
|
|
4147
|
+
.description("List every agency directory listing across all organizations.")
|
|
4148
|
+
.option("--json", "Print a JSON envelope.")
|
|
4149
|
+
.action(async (options) => {
|
|
4150
|
+
await handleAsyncAction("admin directory list", options, () => requestOxygen("/api/cli/admin/directory"));
|
|
4151
|
+
}))
|
|
4152
|
+
.addCommand(new Command("enable")
|
|
4153
|
+
.description("Invite an organization to the directory, optionally prefilling its profile.")
|
|
4154
|
+
.argument("<organization>", "Organization id, slug, or Clerk org id.")
|
|
4155
|
+
.option("--name <text>", "Agency display name (defaults to the organization name).")
|
|
4156
|
+
.option("--slug <slug>", "Public URL slug (defaults to a slugified name).")
|
|
4157
|
+
.option("--logo-url <url>", "Logo image URL.")
|
|
4158
|
+
.option("--tagline <text>", "One-line pitch shown on the directory card.")
|
|
4159
|
+
.option("--description <text>", "Full profile description (plain text; blank lines separate paragraphs).")
|
|
4160
|
+
.option("--description-file <path>", "Path to a plain-text file with the description.")
|
|
4161
|
+
.option("--website <url>", "Company website URL.")
|
|
4162
|
+
.option("--company-linkedin <url>", "Company LinkedIn page URL.")
|
|
4163
|
+
.option("--founders-json <json>", "JSON array of founding-team members: [{\"name\", \"title\", \"linkedin_url\"}].")
|
|
4164
|
+
.option("--booking-url <url>", "Book-a-call URL (Cal.com, Calendly, ...).")
|
|
4165
|
+
.option("--contact-email <email>", "Public contact email.")
|
|
4166
|
+
.option("--services <csv>", `Comma-separated services from the curated set: ${AGENCY_DIRECTORY_SERVICES.join(", ")}.`)
|
|
4167
|
+
.option("--regions <csv>", `Comma-separated regions from the curated set: ${AGENCY_DIRECTORY_REGIONS.join(", ")}.`)
|
|
4168
|
+
.option("--featured", "Pin the listing to the top of the public directory.")
|
|
4169
|
+
.option("--profile-json <json>", "JSON object with any listing fields; flags override it.")
|
|
4170
|
+
.option("--json", "Print a JSON envelope.")
|
|
4171
|
+
.action(async (organization, options) => {
|
|
4172
|
+
await handleAsyncAction("admin directory enable", options, async () => {
|
|
4173
|
+
const profile = await buildDirectoryProfileBody(options);
|
|
4174
|
+
if (options.featured)
|
|
4175
|
+
profile.featured = true;
|
|
4176
|
+
return requestOxygen("/api/cli/admin/directory/enable", {
|
|
4177
|
+
method: "POST",
|
|
4178
|
+
body: { organization, profile },
|
|
4179
|
+
});
|
|
4180
|
+
});
|
|
4181
|
+
}))
|
|
4182
|
+
.addCommand(new Command("delist")
|
|
4183
|
+
.description("Remove an organization's listing from the public directory (kill switch).")
|
|
4184
|
+
.argument("<organization>", "Organization id, slug, or Clerk org id.")
|
|
4185
|
+
.option("--json", "Print a JSON envelope.")
|
|
4186
|
+
.action(async (organization, options) => {
|
|
4187
|
+
await handleAsyncAction("admin directory delist", options, () => requestOxygen("/api/cli/admin/directory/delist", {
|
|
4188
|
+
method: "POST",
|
|
4189
|
+
body: { organization },
|
|
4190
|
+
}));
|
|
4191
|
+
})));
|
|
3954
4192
|
program
|
|
3955
4193
|
.command("signup-leads")
|
|
3956
4194
|
.description("Signup lead delivery queue commands.")
|
|
@@ -4205,7 +4443,7 @@ export function createProgram() {
|
|
|
4205
4443
|
.command("custom-integrations")
|
|
4206
4444
|
.description("Custom HTTP integration commands.")
|
|
4207
4445
|
.addCommand(new Command("apply")
|
|
4208
|
-
.description("Create or update a custom HTTP integration manifest.
|
|
4446
|
+
.description("Create or update a custom HTTP integration manifest. Attach credentials with `custom-integrations connect`.")
|
|
4209
4447
|
.option("--manifest <path>", "Path to a custom HTTP manifest JSON file.")
|
|
4210
4448
|
.option("--manifest-json <json>", "Custom HTTP manifest as a JSON object.")
|
|
4211
4449
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -4217,6 +4455,21 @@ export function createProgram() {
|
|
|
4217
4455
|
body: { manifest },
|
|
4218
4456
|
});
|
|
4219
4457
|
});
|
|
4458
|
+
}))
|
|
4459
|
+
.addCommand(new Command("connect")
|
|
4460
|
+
.description("Attach the credential (bearer token, API key, or basic password) for an applied custom HTTP integration.")
|
|
4461
|
+
.argument("<slug>", "Custom integration slug from the applied manifest.")
|
|
4462
|
+
.option("--api-key <value>", "Credential value. Prefer --secrets-file to keep it out of shell history.")
|
|
4463
|
+
.option("--secrets-file <path>", "Path to a .env-style file holding the credential (a single entry, or one named API_KEY/TOKEN/PASSWORD/SECRET).")
|
|
4464
|
+
.option("--json", "Print a JSON envelope.")
|
|
4465
|
+
.action(async (slug, options) => {
|
|
4466
|
+
await handleAsyncAction("custom-integrations connect", options, () => {
|
|
4467
|
+
const apiKey = readCustomIntegrationSecret(options);
|
|
4468
|
+
return requestOxygen("/api/cli/custom-integrations/connect", {
|
|
4469
|
+
method: "POST",
|
|
4470
|
+
body: { slug, api_key: apiKey },
|
|
4471
|
+
});
|
|
4472
|
+
});
|
|
4220
4473
|
}))
|
|
4221
4474
|
.addCommand(new Command("list")
|
|
4222
4475
|
.description("List custom HTTP integrations for the active organization.")
|
|
@@ -4696,20 +4949,29 @@ export function createProgram() {
|
|
|
4696
4949
|
const suffix = params.toString();
|
|
4697
4950
|
return requestOxygen(`/api/cli/senders${suffix ? `?${suffix}` : ""}`);
|
|
4698
4951
|
});
|
|
4952
|
+
}))
|
|
4953
|
+
.addCommand(new Command("checkpoints")
|
|
4954
|
+
.description("List LinkedIn senders stuck on a security checkpoint (2FA / OTP / in-app validation), each with status, warm-up day, today's usage vs. caps, and the latest error — plus a fleet tally of every sender by status. Read-only; no provider call, no credits. Solve one with `senders checkpoint solve <id> <code>`.")
|
|
4955
|
+
.option("--json", "Print a JSON envelope.")
|
|
4956
|
+
.action(async (options) => {
|
|
4957
|
+
await handleAsyncAction("senders checkpoints", options, () => requestOxygen("/api/cli/senders/checkpoints"));
|
|
4699
4958
|
}))
|
|
4700
4959
|
.addCommand(new Command("connect")
|
|
4701
|
-
.description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). Open
|
|
4960
|
+
.description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). Use --count to mint several links at once for bulk onboarding. Open each URL in a browser to complete authentication.")
|
|
4702
4961
|
.option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
|
|
4703
4962
|
.option("--sales-nav", "Request Classic + Sales Navigator access during Unipile hosted authentication.")
|
|
4963
|
+
.option("--count <n>", "Mint N hosted-auth links in one call for bulk onboarding (1-25, default 1). Each link connects a different account. Ignored when reconnecting.")
|
|
4704
4964
|
.option("--json", "Print a JSON envelope.")
|
|
4705
4965
|
.action(async (options) => {
|
|
4706
4966
|
await handleAsyncAction("senders connect", options, () => {
|
|
4707
4967
|
const reconnect = readOption(options.reconnect);
|
|
4968
|
+
const count = readPositiveInt(options.count);
|
|
4708
4969
|
return requestOxygen("/api/cli/senders/connect", {
|
|
4709
4970
|
method: "POST",
|
|
4710
4971
|
body: {
|
|
4711
4972
|
...(reconnect ? { reconnect_connection_id: reconnect } : {}),
|
|
4712
4973
|
...(options.salesNav ? { sales_nav: true } : {}),
|
|
4974
|
+
...(count ? { count } : {}),
|
|
4713
4975
|
},
|
|
4714
4976
|
});
|
|
4715
4977
|
});
|
|
@@ -4808,6 +5070,9 @@ export function createProgram() {
|
|
|
4808
5070
|
.option("--warmup-restart", "Start (or restart) the warm-up ramp now — gradually raises this account's invite + message caps to full over ~2 weeks.")
|
|
4809
5071
|
.option("--warmup-disable", "Turn off warm-up for this account (treat it as already warm and use its full configured caps).")
|
|
4810
5072
|
.option("--warmup-start-date <date>", "Set the warm-up start date (ISO, e.g. 2026-01-31). A past date credits prior warming and advances the ramp.")
|
|
5073
|
+
.option("--warmup-preset <preset>", "Warm-up ramp curve: conservative (~3 weeks), standard (~2 weeks, default), or fast (~1 week for an aged account).")
|
|
5074
|
+
.option("--randomize-caps", "Randomise this account's daily send caps to 80-100% of configured so volume looks human day to day.")
|
|
5075
|
+
.option("--no-randomize-caps", "Turn off randomised daily caps (use the exact configured caps every day).")
|
|
4811
5076
|
.option("--json", "Print a JSON envelope.")
|
|
4812
5077
|
.action(async (id, options) => {
|
|
4813
5078
|
await handleAsyncAction("senders limits set", options, () => {
|
|
@@ -4926,6 +5191,77 @@ export function createProgram() {
|
|
|
4926
5191
|
return requestOxygen(`/api/cli/linkedin/viewers/status${suffix ? `?${suffix}` : ""}`);
|
|
4927
5192
|
});
|
|
4928
5193
|
})));
|
|
5194
|
+
program.addCommand(new Command("posts")
|
|
5195
|
+
.description("Read and publish LinkedIn posts through a connected account. `get` returns a post and the composite social_id that `comments`/`reactions` and `oxygen engagement harvest` need; `create` publishes a real post (approval-gated). All calls are metered against the sender account's daily quota.")
|
|
5196
|
+
.addCommand(new Command("get")
|
|
5197
|
+
.description("Read a LinkedIn post. Returns the post and its composite social_id (reuse that social_id for `posts comments`, `posts reactions`, and `engagement harvest --source unipile` — NOT the raw activity URN). Metered as an account read.")
|
|
5198
|
+
.requiredOption("--post <id>", "Numeric activity id, activity URL, or composite social_id.")
|
|
5199
|
+
.option("--account <ref>", "Sender account to read through (sender id, connection id, or Unipile account id). Omit for the org default.")
|
|
5200
|
+
.option("--json", "Print a JSON envelope.")
|
|
5201
|
+
.action(async (options) => {
|
|
5202
|
+
await handleAsyncAction("posts get", options, () => {
|
|
5203
|
+
const params = new URLSearchParams();
|
|
5204
|
+
params.set("post", readOption(options.post) ?? "");
|
|
5205
|
+
const account = readOption(options.account);
|
|
5206
|
+
if (account)
|
|
5207
|
+
params.set("account", account);
|
|
5208
|
+
return requestOxygen(`/api/cli/linkedin/posts?${params.toString()}`);
|
|
5209
|
+
});
|
|
5210
|
+
}))
|
|
5211
|
+
.addCommand(new Command("comments")
|
|
5212
|
+
.description("List a post's comments (or the replies to a comment with --comment-id). --post MUST be the composite social_id from `posts get`, not the activity URN. Metered as an account read.")
|
|
5213
|
+
.requiredOption("--post <social_id>", "Composite post social_id from `posts get`.")
|
|
5214
|
+
.option("--comment-id <id>", "List replies to this comment instead of top-level comments.")
|
|
5215
|
+
.option("--sort-by <sort>", "Comment sort order (provider-defined, e.g. recent|relevant).")
|
|
5216
|
+
.option("--cursor <cursor>", "Pagination cursor from a previous page.")
|
|
5217
|
+
.option("--limit <n>", "Max comments to return this page.")
|
|
5218
|
+
.option("--account <ref>", "Sender account to read through. Omit for the org default.")
|
|
5219
|
+
.option("--json", "Print a JSON envelope.")
|
|
5220
|
+
.action(async (options) => {
|
|
5221
|
+
await handleAsyncAction("posts comments", options, () => requestOxygen(`/api/cli/linkedin/posts/comments?${buildPostEngagementQuery(options)}`));
|
|
5222
|
+
}))
|
|
5223
|
+
.addCommand(new Command("reactions")
|
|
5224
|
+
.description("List a post's reactions (or a comment's reactions with --comment-id). --post MUST be the composite social_id from `posts get`. Metered as an account read.")
|
|
5225
|
+
.requiredOption("--post <social_id>", "Composite post social_id from `posts get`.")
|
|
5226
|
+
.option("--comment-id <id>", "List reactions to this comment instead of the post.")
|
|
5227
|
+
.option("--cursor <cursor>", "Pagination cursor from a previous page.")
|
|
5228
|
+
.option("--limit <n>", "Max reactions to return this page.")
|
|
5229
|
+
.option("--account <ref>", "Sender account to read through. Omit for the org default.")
|
|
5230
|
+
.option("--json", "Print a JSON envelope.")
|
|
5231
|
+
.action(async (options) => {
|
|
5232
|
+
await handleAsyncAction("posts reactions", options, () => requestOxygen(`/api/cli/linkedin/posts/reactions?${buildPostEngagementQuery(options)}`));
|
|
5233
|
+
}))
|
|
5234
|
+
.addCommand(new Command("create")
|
|
5235
|
+
.description("Publish a LinkedIn post from the connected account. A REAL public write, so it prints a preview by default and only publishes with --approved. Counts against the sender account's daily action quota. (Company-page posting is not supported — Unipile exposes it only via the fragile raw route.)")
|
|
5236
|
+
.option("--text <text>", "Post body text.")
|
|
5237
|
+
.option("--text-file <path>", "Read the post body from a file (alternative to --text).")
|
|
5238
|
+
.option("--account <ref>", "Sender account to post from. Omit for the org default.")
|
|
5239
|
+
.option("--as-organization <id>", "Publish as a company page the account administers (advanced; may require raw-route support).")
|
|
5240
|
+
.option("--external-link <url>", "Attach an external link to the post.")
|
|
5241
|
+
.option("--approved", "Publish for real. Without this flag, prints a preview only.")
|
|
5242
|
+
.option("--json", "Print a JSON envelope.")
|
|
5243
|
+
.action(async (options) => {
|
|
5244
|
+
await handleAsyncAction("posts create", options, () => {
|
|
5245
|
+
const inlineText = readOption(options.text);
|
|
5246
|
+
const textFile = readOption(options.textFile);
|
|
5247
|
+
const text = inlineText ?? (textFile ? readFileSync(resolve(textFile), "utf8") : undefined);
|
|
5248
|
+
if (!text || !text.trim())
|
|
5249
|
+
throw new Error("--text or --text-file is required to publish a post.");
|
|
5250
|
+
const account = readOption(options.account);
|
|
5251
|
+
const asOrganization = readOption(options.asOrganization);
|
|
5252
|
+
const externalLink = readOption(options.externalLink);
|
|
5253
|
+
return requestOxygen("/api/cli/linkedin/posts", {
|
|
5254
|
+
method: "POST",
|
|
5255
|
+
body: {
|
|
5256
|
+
text,
|
|
5257
|
+
...(account ? { account } : {}),
|
|
5258
|
+
...(asOrganization ? { as_organization: asOrganization } : {}),
|
|
5259
|
+
...(externalLink ? { external_link: externalLink } : {}),
|
|
5260
|
+
...(options.approved ? { approved: true } : {}),
|
|
5261
|
+
},
|
|
5262
|
+
});
|
|
5263
|
+
});
|
|
5264
|
+
})));
|
|
4929
5265
|
program.addCommand(new Command("engagement")
|
|
4930
5266
|
.description("Harvest a LinkedIn post's engagers (reactors + commenters) into an enrollable CRM static list. The harvest runs as a slow, durable background drip under a dedicated conservative read budget — it never bursts and never starves the sequencer's own reads.")
|
|
4931
5267
|
.addCommand(new Command("harvest")
|
|
@@ -5043,7 +5379,100 @@ export function createProgram() {
|
|
|
5043
5379
|
},
|
|
5044
5380
|
});
|
|
5045
5381
|
});
|
|
5046
|
-
}))
|
|
5382
|
+
}))
|
|
5383
|
+
.addCommand(new Command("watch")
|
|
5384
|
+
.description("Declarative engagement watches (the signals wedge): stand up a watch on a post's engagers or 'who viewed my profile' that harvests people into a table and, under a standing approval, auto-enrolls them into a sequence. The watch materializes the harvest drip and enrolls newly-harvested people each cycle — the sequence still gates its own sends.")
|
|
5385
|
+
.addCommand(new Command("create")
|
|
5386
|
+
.description("Arm an engagement watch. `--kind post` watches a post's reactors + commenters (needs --post social_id; a cookieless post also needs --post-url); `--kind profile_viewers` watches 'who viewed my profile' (source unipile). With --auto-enroll it enrolls harvested people into --sequence, capped by --max-enrolls-per-day. --max-credits-per-cycle is the standing per-cycle spend cap (required for --auto-enroll and cookieless). No messages are sent by the watch itself.")
|
|
5387
|
+
.requiredOption("--kind <kind>", "post | profile_viewers.")
|
|
5388
|
+
.requiredOption("--account <ref>", "Reading LinkedIn sender (sender id, connection id, or Unipile account id).")
|
|
5389
|
+
.option("--post <social_id>", "Composite post social_id from `oxygen posts get` (required for --kind post).")
|
|
5390
|
+
.option("--post-url <url>", "Public LinkedIn post URL (required for a cookieless post watch).")
|
|
5391
|
+
.option("--source <source>", "cookieless | unipile. Defaults to cookieless for post, unipile for profile_viewers.")
|
|
5392
|
+
.option("--table <id_or_slug>", "Existing table to land harvested people into. Omit to create one.")
|
|
5393
|
+
.option("--sequence <id_or_slug>", "Sequence to auto-enroll harvested people into (required with --auto-enroll).")
|
|
5394
|
+
.option("--auto-enroll", "Auto-enroll newly harvested people into --sequence under the standing approval.")
|
|
5395
|
+
.option("--max-credits-per-cycle <credits>", "Standing per-cycle managed-spend cap (required for --auto-enroll and cookieless).")
|
|
5396
|
+
.option("--max-enrolls-per-day <n>", "Cap auto-enrollments per day.")
|
|
5397
|
+
.option("--recurrence <recurrence>", "once | every_6h | daily | weekly (default once).")
|
|
5398
|
+
.option("--json", "Print a JSON envelope.")
|
|
5399
|
+
.action(async (options) => {
|
|
5400
|
+
await handleAsyncAction("engagement watch create", options, () => {
|
|
5401
|
+
const kind = readOption(options.kind);
|
|
5402
|
+
const account = readOption(options.account);
|
|
5403
|
+
const post = readOption(options.post);
|
|
5404
|
+
const postUrl = readOption(options.postUrl);
|
|
5405
|
+
const source = readOption(options.source);
|
|
5406
|
+
const table = readOption(options.table);
|
|
5407
|
+
const sequence = readOption(options.sequence);
|
|
5408
|
+
const maxCreditsPerCycle = readPositiveNumber(options.maxCreditsPerCycle);
|
|
5409
|
+
const maxEnrollsPerDay = readPositiveNumber(options.maxEnrollsPerDay);
|
|
5410
|
+
const recurrence = readOption(options.recurrence);
|
|
5411
|
+
return requestOxygen("/api/cli/linkedin/engagement/watches", {
|
|
5412
|
+
method: "POST",
|
|
5413
|
+
body: {
|
|
5414
|
+
...(kind ? { kind } : {}),
|
|
5415
|
+
...(account ? { account } : {}),
|
|
5416
|
+
...(post ? { post } : {}),
|
|
5417
|
+
...(postUrl ? { post_url: postUrl } : {}),
|
|
5418
|
+
...(source ? { source } : {}),
|
|
5419
|
+
...(table ? { table } : {}),
|
|
5420
|
+
...(sequence ? { sequence } : {}),
|
|
5421
|
+
...(options.autoEnroll ? { auto_enroll: true } : {}),
|
|
5422
|
+
...(maxCreditsPerCycle !== undefined ? { max_credits_per_cycle: maxCreditsPerCycle } : {}),
|
|
5423
|
+
...(maxEnrollsPerDay !== undefined ? { max_enrolls_per_day: maxEnrollsPerDay } : {}),
|
|
5424
|
+
...(recurrence ? { recurrence } : {}),
|
|
5425
|
+
},
|
|
5426
|
+
});
|
|
5427
|
+
});
|
|
5428
|
+
}))
|
|
5429
|
+
.addCommand(new Command("list")
|
|
5430
|
+
.description("List the org's engagement watches (status, kind, sender, sequence, per-day enroll count) with a table deep-link.")
|
|
5431
|
+
.option("--status <status>", "Filter by status: active | paused | exhausted | completed.")
|
|
5432
|
+
.option("--kind <kind>", "Filter by kind.")
|
|
5433
|
+
.option("--json", "Print a JSON envelope.")
|
|
5434
|
+
.action(async (options) => {
|
|
5435
|
+
await handleAsyncAction("engagement watch list", options, () => {
|
|
5436
|
+
const status = readOption(options.status);
|
|
5437
|
+
const kind = readOption(options.kind);
|
|
5438
|
+
const params = new URLSearchParams();
|
|
5439
|
+
if (status)
|
|
5440
|
+
params.set("status", status);
|
|
5441
|
+
if (kind)
|
|
5442
|
+
params.set("kind", kind);
|
|
5443
|
+
const suffix = params.toString();
|
|
5444
|
+
return requestOxygen(`/api/cli/linkedin/engagement/watches${suffix ? `?${suffix}` : ""}`);
|
|
5445
|
+
});
|
|
5446
|
+
}))
|
|
5447
|
+
.addCommand(new Command("pause")
|
|
5448
|
+
.description("Pause a watch (stop materializing its harvest + auto-enrolling).")
|
|
5449
|
+
.argument("<watchId>", "Watch id.")
|
|
5450
|
+
.option("--json", "Print a JSON envelope.")
|
|
5451
|
+
.action(async (watchId, options) => {
|
|
5452
|
+
await handleAsyncAction("engagement watch pause", options, () => requestOxygen(`/api/cli/linkedin/engagement/watches/${encodeURIComponent(watchId)}`, {
|
|
5453
|
+
method: "PATCH",
|
|
5454
|
+
body: { action: "pause" },
|
|
5455
|
+
}));
|
|
5456
|
+
}))
|
|
5457
|
+
.addCommand(new Command("resume")
|
|
5458
|
+
.description("Resume a paused watch.")
|
|
5459
|
+
.argument("<watchId>", "Watch id.")
|
|
5460
|
+
.option("--json", "Print a JSON envelope.")
|
|
5461
|
+
.action(async (watchId, options) => {
|
|
5462
|
+
await handleAsyncAction("engagement watch resume", options, () => requestOxygen(`/api/cli/linkedin/engagement/watches/${encodeURIComponent(watchId)}`, {
|
|
5463
|
+
method: "PATCH",
|
|
5464
|
+
body: { action: "resume" },
|
|
5465
|
+
}));
|
|
5466
|
+
}))
|
|
5467
|
+
.addCommand(new Command("delete")
|
|
5468
|
+
.description("Delete a watch (its harvested table + rows are kept).")
|
|
5469
|
+
.argument("<watchId>", "Watch id.")
|
|
5470
|
+
.option("--json", "Print a JSON envelope.")
|
|
5471
|
+
.action(async (watchId, options) => {
|
|
5472
|
+
await handleAsyncAction("engagement watch delete", options, () => requestOxygen(`/api/cli/linkedin/engagement/watches/${encodeURIComponent(watchId)}`, {
|
|
5473
|
+
method: "DELETE",
|
|
5474
|
+
}));
|
|
5475
|
+
}))));
|
|
5047
5476
|
program.addCommand(new Command("linkedin")
|
|
5048
5477
|
.description("LinkedIn read-into-workspace ingestion controls (Goal-3): the unified status of the connections / engagement / message-history drips and each account's remaining ingest read budget.")
|
|
5049
5478
|
.addCommand(new Command("ingestion")
|
|
@@ -5337,12 +5766,14 @@ export function createProgram() {
|
|
|
5337
5766
|
.requiredOption("--text <message>", "Reply text to send.")
|
|
5338
5767
|
.option("--channel <channel>", "Inbox channel: linkedin (default), whatsapp (warm reply), or email.")
|
|
5339
5768
|
.option("--approved", "Approve and send the message. Without this flag, returns a preview only.")
|
|
5340
|
-
.option("--draft-id <id>", "
|
|
5769
|
+
.option("--draft-id <id>", "When approving an AI reply-agent draft (email or LinkedIn), its id (marks it sent on success). Use the matching --channel.")
|
|
5770
|
+
.option("--attach <url>", "LinkedIn only: attach a file by public URL (image/document). Repeatable, up to 5.", collectRepeatable, [])
|
|
5341
5771
|
.option("--json", "Print a JSON envelope.")
|
|
5342
5772
|
.action(async (conversation, options) => {
|
|
5343
5773
|
await handleAsyncAction("inbox send", options, () => {
|
|
5344
5774
|
const channel = readOption(options.channel);
|
|
5345
5775
|
const draftId = readOption(options.draftId);
|
|
5776
|
+
const attachments = (options.attach ?? []).map((url) => url.trim()).filter(Boolean).map((url) => ({ url }));
|
|
5346
5777
|
return requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/send`, {
|
|
5347
5778
|
method: "POST",
|
|
5348
5779
|
body: {
|
|
@@ -5350,6 +5781,7 @@ export function createProgram() {
|
|
|
5350
5781
|
...(channel ? { channel } : {}),
|
|
5351
5782
|
...(options.approved ? { approved: true } : {}),
|
|
5352
5783
|
...(draftId ? { draft_id: draftId } : {}),
|
|
5784
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
5353
5785
|
},
|
|
5354
5786
|
});
|
|
5355
5787
|
});
|
|
@@ -5510,32 +5942,51 @@ export function createProgram() {
|
|
|
5510
5942
|
.argument("<name>", "Display name.")
|
|
5511
5943
|
.option("--color <hex>", "Hex color (e.g. #22c55e).")
|
|
5512
5944
|
.option("--bucket <bucket>", "primary (default) or others.")
|
|
5945
|
+
.option("--ai-settable", "Let the AI reply classifier assign this label.")
|
|
5946
|
+
.option("--description <text>", "What the label means / when it applies (used by the AI classifier).")
|
|
5513
5947
|
.option("--json", "Print a JSON envelope.")
|
|
5514
5948
|
.action(async (name, options) => {
|
|
5515
5949
|
await handleAsyncAction("inbox label create", options, () => {
|
|
5516
5950
|
const color = readOption(options.color);
|
|
5517
5951
|
const bucket = readOption(options.bucket);
|
|
5952
|
+
const description = readOption(options.description);
|
|
5518
5953
|
return requestOxygen("/api/cli/inbox/labels", {
|
|
5519
5954
|
method: "POST",
|
|
5520
|
-
body: {
|
|
5955
|
+
body: {
|
|
5956
|
+
name,
|
|
5957
|
+
...(color ? { color } : {}),
|
|
5958
|
+
...(bucket ? { bucket } : {}),
|
|
5959
|
+
...(options.aiSettable ? { ai_settable: true } : {}),
|
|
5960
|
+
...(description ? { description } : {}),
|
|
5961
|
+
},
|
|
5521
5962
|
});
|
|
5522
5963
|
});
|
|
5523
5964
|
}))
|
|
5524
5965
|
.addCommand(new Command("update")
|
|
5525
|
-
.description("Update a status label's name, color, or
|
|
5966
|
+
.description("Update a status label's name, color, bucket, description, or (custom labels only) ai-settable flag.")
|
|
5526
5967
|
.argument("<key>", "The label key.")
|
|
5527
5968
|
.option("--name <name>", "New display name.")
|
|
5528
5969
|
.option("--color <hex>", "New hex color.")
|
|
5529
5970
|
.option("--bucket <bucket>", "New bucket (primary or others).")
|
|
5971
|
+
.option("--ai-settable", "Let the AI classifier assign this label (custom labels only).")
|
|
5972
|
+
.option("--no-ai-settable", "Stop the AI classifier assigning this label (custom labels only).")
|
|
5973
|
+
.option("--description <text>", "What the label means / when it applies (used by the AI classifier).")
|
|
5530
5974
|
.option("--json", "Print a JSON envelope.")
|
|
5531
5975
|
.action(async (key, options) => {
|
|
5532
5976
|
await handleAsyncAction("inbox label update", options, () => {
|
|
5533
5977
|
const name = readOption(options.name);
|
|
5534
5978
|
const color = readOption(options.color);
|
|
5535
5979
|
const bucket = readOption(options.bucket);
|
|
5980
|
+
const description = readOption(options.description);
|
|
5536
5981
|
return requestOxygen(`/api/cli/inbox/labels/${encodeURIComponent(key)}`, {
|
|
5537
5982
|
method: "PATCH",
|
|
5538
|
-
body: {
|
|
5983
|
+
body: {
|
|
5984
|
+
...(name ? { name } : {}),
|
|
5985
|
+
...(color ? { color } : {}),
|
|
5986
|
+
...(bucket ? { bucket } : {}),
|
|
5987
|
+
...(options.aiSettable !== undefined ? { ai_settable: options.aiSettable } : {}),
|
|
5988
|
+
...(description ? { description } : {}),
|
|
5989
|
+
},
|
|
5539
5990
|
});
|
|
5540
5991
|
});
|
|
5541
5992
|
}))
|
|
@@ -5549,9 +6000,10 @@ export function createProgram() {
|
|
|
5549
6000
|
.addCommand(new Command("drafts")
|
|
5550
6001
|
.description("The AI reply-agent draft queue (the approve-before-send review queue).")
|
|
5551
6002
|
.addCommand(new Command("list")
|
|
5552
|
-
.description("List drafts awaiting review (queued + edited by default).")
|
|
6003
|
+
.description("List drafts awaiting review (queued + edited by default), across email + LinkedIn.")
|
|
5553
6004
|
.option("--status <keys>", "Comma-separated draft statuses (queued,edited,sent,rejected).")
|
|
5554
|
-
.option("--
|
|
6005
|
+
.option("--channel <channel>", "Filter to one channel: email or linkedin.")
|
|
6006
|
+
.option("--sequence-id <id>", "Filter to one campaign (sequence) id (email drafts only).")
|
|
5555
6007
|
.option("--limit <n>", "Maximum drafts to return.")
|
|
5556
6008
|
.option("--json", "Print a JSON envelope.")
|
|
5557
6009
|
.action(async (options) => {
|
|
@@ -5560,6 +6012,9 @@ export function createProgram() {
|
|
|
5560
6012
|
const status = readOption(options.status);
|
|
5561
6013
|
if (status)
|
|
5562
6014
|
params.set("status", status);
|
|
6015
|
+
const channel = readOption(options.channel);
|
|
6016
|
+
if (channel)
|
|
6017
|
+
params.set("channel", channel);
|
|
5563
6018
|
const sequenceId = readOption(options.sequenceId);
|
|
5564
6019
|
if (sequenceId)
|
|
5565
6020
|
params.set("sequence_id", sequenceId);
|
|
@@ -5613,6 +6068,7 @@ export function createProgram() {
|
|
|
5613
6068
|
.option("--signature <text>", "Signature appended to drafts.")
|
|
5614
6069
|
.option("--target-statuses <keys>", "Comma-separated statuses to draft for (empty = all draftable).")
|
|
5615
6070
|
.option("--target-sequence-ids <ids>", "Comma-separated campaign ids to draft for (empty = all).")
|
|
6071
|
+
.option("--channels <list>", "Comma-separated channels to draft for: a non-empty subset of email, linkedin (default email). LinkedIn drafts are short + signature-free.")
|
|
5616
6072
|
.option("--max-drafts-per-day <n>", "Per-day draft cap.")
|
|
5617
6073
|
.option("--json", "Print a JSON envelope.")
|
|
5618
6074
|
.action(async (options) => {
|
|
@@ -5640,6 +6096,9 @@ export function createProgram() {
|
|
|
5640
6096
|
if (options.targetSequenceIds !== undefined) {
|
|
5641
6097
|
body.target_sequence_ids = options.targetSequenceIds.split(",").map((s) => s.trim()).filter(Boolean);
|
|
5642
6098
|
}
|
|
6099
|
+
if (options.channels !== undefined) {
|
|
6100
|
+
body.channels = options.channels.split(",").map((c) => c.trim()).filter(Boolean);
|
|
6101
|
+
}
|
|
5643
6102
|
if (options.maxDraftsPerDay !== undefined)
|
|
5644
6103
|
body.max_drafts_per_day = Number(options.maxDraftsPerDay);
|
|
5645
6104
|
return requestOxygen("/api/cli/inbox/reply-agent", { method: "POST", body });
|
|
@@ -5775,6 +6234,12 @@ export function createProgram() {
|
|
|
5775
6234
|
.option("--max-live-sends <n>", "Send ceiling for the email track (positive integer). Required to start an email sequence live.")
|
|
5776
6235
|
.option("--max-emails-per-mailbox-per-day <n>", "Per-mailbox daily email cap (positive integer) applied across the sending pool.")
|
|
5777
6236
|
.option("--send-window-file <path>", "Path to a JSON file with the sequence-level email send window: { timezone, days?, start, end, timezone_mode?, recipient_timezone_column? }.")
|
|
6237
|
+
.option("--max-emails-per-day <n>", "Sequence-wide daily live-send fleet cap (positive integer) across every sender/mailbox.")
|
|
6238
|
+
.option("--max-new-enrollments-per-day <n>", "Daily drip cap on NEW first-touch leads the planner starts (positive integer).")
|
|
6239
|
+
.option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
|
|
6240
|
+
.option("--schedule-template <name>", "Name of a saved schedule template (oxygen schedules) backing the sending window.")
|
|
6241
|
+
.option("--esp-matching <mode>", "Native-email ESP matching: 'off' (default) rotates mailboxes freely; 'prefer' biases toward a mailbox on the recipient's own provider; 'strict' requires a same-provider mailbox and defers the send when none exists.")
|
|
6242
|
+
.option("--no-stop-on-bounce", "Keep a lead's enrollment running after a hard bounce (default: stop it). The bounce is still recorded as an email_bounced signal either way.")
|
|
5778
6243
|
.option("--json", "Print a JSON envelope.")
|
|
5779
6244
|
.action(async (options) => {
|
|
5780
6245
|
await handleAsyncAction("sequences create", options, () => {
|
|
@@ -5823,6 +6288,12 @@ export function createProgram() {
|
|
|
5823
6288
|
.option("--max-live-sends <n>", "Send ceiling for the email track (positive integer). Required to start an email sequence live.")
|
|
5824
6289
|
.option("--max-emails-per-mailbox-per-day <n>", "Per-mailbox daily email cap (positive integer) applied across the sending pool.")
|
|
5825
6290
|
.option("--send-window-file <path>", "Path to a JSON file with the sequence-level email send window: { timezone, days?, start, end, timezone_mode?, recipient_timezone_column? }.")
|
|
6291
|
+
.option("--max-emails-per-day <n>", "Sequence-wide daily live-send fleet cap (positive integer) across every sender/mailbox.")
|
|
6292
|
+
.option("--max-new-enrollments-per-day <n>", "Daily drip cap on NEW first-touch leads the planner starts (positive integer).")
|
|
6293
|
+
.option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
|
|
6294
|
+
.option("--schedule-template <name>", "Name of a saved schedule template (oxygen schedules) backing the sending window.")
|
|
6295
|
+
.option("--esp-matching <mode>", "Native-email ESP matching: 'off' (default) rotates mailboxes freely; 'prefer' biases toward a mailbox on the recipient's own provider; 'strict' requires a same-provider mailbox and defers the send when none exists.")
|
|
6296
|
+
.option("--no-stop-on-bounce", "Keep a lead's enrollment running after a hard bounce (default: stop it). The bounce is still recorded as an email_bounced signal either way.")
|
|
5826
6297
|
.option("--json", "Print a JSON envelope.")
|
|
5827
6298
|
.action(async (sequence, options) => {
|
|
5828
6299
|
await handleAsyncAction("sequences update", options, () => {
|
|
@@ -5858,7 +6329,7 @@ export function createProgram() {
|
|
|
5858
6329
|
body.email = email;
|
|
5859
6330
|
}
|
|
5860
6331
|
if (Object.keys(body).length === 0) {
|
|
5861
|
-
throw new Error("Provide at least one field to update (--name, --steps-file, --channels, --senders, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day,
|
|
6332
|
+
throw new Error("Provide at least one field to update (--name, --steps-file, --channels, --senders, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day, --send-window-file, or --no-stop-on-bounce).");
|
|
5862
6333
|
}
|
|
5863
6334
|
return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`, {
|
|
5864
6335
|
method: "PATCH",
|
|
@@ -5874,11 +6345,12 @@ export function createProgram() {
|
|
|
5874
6345
|
await handleAsyncAction("sequences get", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`));
|
|
5875
6346
|
}))
|
|
5876
6347
|
.addCommand(new Command("enroll")
|
|
5877
|
-
.description("Enroll leads into a sequence from a JSON file of { leads: [...] }. When the sequence is bound to a source table, a lead's table_row_id auto-snapshots that row's columns (incl. AI/tool outputs) into row_values for {{column}} copy — explicit row_values win. Idempotent per table row. The org do-not-contact list is always enforced; --exclude-contacted and --suppress-list add further opt-in skips (reported under skipped_by_reason).")
|
|
6348
|
+
.description("Enroll leads into a sequence from a JSON file of { leads: [...] }. When the sequence is bound to a source table, a lead's table_row_id auto-snapshots that row's columns (incl. AI/tool outputs) into row_values for {{column}} copy — explicit row_values win. Idempotent per table row. The org do-not-contact list is always enforced; --exclude-contacted and --suppress-list add further opt-in skips (reported under skipped_by_reason). Leads already owned by a sender account (from an earlier real send) are routed back to that same account; a lead owned by a sender NOT on this sequence is skipped (bound_to_other_sender) unless --ignore-sender-bindings.")
|
|
5878
6349
|
.argument("<sequence>", "Sequence id or slug.")
|
|
5879
6350
|
.requiredOption("--leads-file <path>", "Path to a JSON file: { \"leads\": [{ lead_provider_id, lead_name, table_row_id, row_values }] }.")
|
|
5880
6351
|
.option("--exclude-contacted", "Also skip leads any OTHER active sequence is already contacting (cross-campaign exclusion). Off by default.")
|
|
5881
6352
|
.option("--suppress-list <ids>", "Per-call do-not-enroll lead provider ids dropped for this enroll only: a comma-separated list, or @<path> to a file of ids (comma/whitespace separated).")
|
|
6353
|
+
.option("--ignore-sender-bindings", "Enroll leads even when they are already owned by a sender account outside this sequence's pool (overrides the one-person-one-sender guarantee). Off by default.")
|
|
5882
6354
|
.option("--json", "Print a JSON envelope.")
|
|
5883
6355
|
.action(async (sequence, options) => {
|
|
5884
6356
|
await handleAsyncAction("sequences enroll", options, () => {
|
|
@@ -5893,6 +6365,7 @@ export function createProgram() {
|
|
|
5893
6365
|
leads: parsed.leads ?? [],
|
|
5894
6366
|
...(options.excludeContacted ? { exclude_contacted: true } : {}),
|
|
5895
6367
|
...(suppressList.length > 0 ? { suppress_list: suppressList } : {}),
|
|
6368
|
+
...(options.ignoreSenderBindings ? { ignore_sender_bindings: true } : {}),
|
|
5896
6369
|
},
|
|
5897
6370
|
});
|
|
5898
6371
|
});
|
|
@@ -5984,12 +6457,50 @@ export function createProgram() {
|
|
|
5984
6457
|
await handleAsyncAction("sequences stats", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/stats`));
|
|
5985
6458
|
}))
|
|
5986
6459
|
.addCommand(new Command("variants")
|
|
5987
|
-
.description("
|
|
6460
|
+
.description("A/B scoreboard for a sequence: per-step/per-variant sent, replied, reply rate, bounced, credits, plus the reversible auto-winner state (which variants are paused + evidence). Flags run the reply-metric auto-winner or manually override/reset a variant's pause.")
|
|
5988
6461
|
.argument("<sequence>", "Sequence id or slug.")
|
|
6462
|
+
.option("--auto-optimize", "Run the reply-metric auto-winner now: pause the statistically-losing variant(s) of any step carrying an auto_optimize config, once every variant clears its thresholds. Reversible.")
|
|
6463
|
+
.option("--pause <variant>", "Manually pause one variant (requires --step). Overrides the auto-winner.")
|
|
6464
|
+
.option("--activate <variant>", "Manually reactivate one paused variant (requires --step).")
|
|
6465
|
+
.option("--reset [variant]", "Clear an auto/manual decision: one variant (with --step), a whole step (--step only), or the entire sequence (no value). Reactivates the affected variants.")
|
|
6466
|
+
.option("--step <stepId>", "Step id to scope --pause/--activate/--reset to.")
|
|
5989
6467
|
.option("--json", "Print a JSON envelope.")
|
|
5990
6468
|
.action(async (sequence, options) => {
|
|
5991
6469
|
await handleSequenceVariantsAction(sequence, options);
|
|
5992
6470
|
})));
|
|
6471
|
+
program.addCommand(new Command("schedules")
|
|
6472
|
+
.description("Named, reusable sending-schedule templates (timezone + weekdays + from/to). Reference one from a sequence via --schedule-template so a single 'US business hours' window backs many sequences.")
|
|
6473
|
+
.addCommand(new Command("list")
|
|
6474
|
+
.description("List the org's saved schedule templates.")
|
|
6475
|
+
.option("--json", "Print a JSON envelope.")
|
|
6476
|
+
.action(async (options) => {
|
|
6477
|
+
await handleAsyncAction("schedules list", options, () => requestOxygen("/api/cli/schedules"));
|
|
6478
|
+
}))
|
|
6479
|
+
.addCommand(new Command("create")
|
|
6480
|
+
.description("Create (or update by name) a schedule template. Partial windows get sensible defaults.")
|
|
6481
|
+
.requiredOption("--name <name>", "Unique template name (e.g. 'US business hours').")
|
|
6482
|
+
.requiredOption("--timezone <tz>", "IANA timezone (e.g. America/New_York).")
|
|
6483
|
+
.option("--start <HH:MM>", "Inclusive daily start time.", "09:00")
|
|
6484
|
+
.option("--end <HH:MM>", "Exclusive daily end time.", "17:00")
|
|
6485
|
+
.option("--days <csv>", "Allowed ISO weekdays, 1=Mon..7=Sun (e.g. 1,2,3,4,5). Omit → weekdays default.")
|
|
6486
|
+
.option("--json", "Print a JSON envelope.")
|
|
6487
|
+
.action(async (options) => {
|
|
6488
|
+
await handleAsyncAction("schedules create", options, () => {
|
|
6489
|
+
const days = readCsvOption(options.days).map((d) => Number(d)).filter((d) => Number.isInteger(d));
|
|
6490
|
+
return requestOxygen("/api/cli/schedules", {
|
|
6491
|
+
method: "POST",
|
|
6492
|
+
body: {
|
|
6493
|
+
name: readOption(options.name),
|
|
6494
|
+
definition: {
|
|
6495
|
+
timezone: readOption(options.timezone),
|
|
6496
|
+
...(readOption(options.start) ? { start: readOption(options.start) } : {}),
|
|
6497
|
+
...(readOption(options.end) ? { end: readOption(options.end) } : {}),
|
|
6498
|
+
...(days.length > 0 ? { days } : {}),
|
|
6499
|
+
},
|
|
6500
|
+
},
|
|
6501
|
+
});
|
|
6502
|
+
});
|
|
6503
|
+
})));
|
|
5993
6504
|
program.addCommand(new Command("suppressions")
|
|
5994
6505
|
.description("Org do-not-contact list (people/multichannel): lead provider ids the sequencer enroller always skips at plan time. list | add | remove. Consumes 0 credits.")
|
|
5995
6506
|
.addCommand(new Command("list")
|
|
@@ -6063,6 +6574,11 @@ export function createProgram() {
|
|
|
6063
6574
|
.requiredOption("--subject <text>", "Subject line.")
|
|
6064
6575
|
.option("--body <text>", "Plain-text body (sent verbatim, no templating).")
|
|
6065
6576
|
.option("--body-file <path>", "Read the body from a file instead of --body.")
|
|
6577
|
+
.option("--html <html>", "Optional HTML body — sends multipart/alternative with --body as the plain-text part.")
|
|
6578
|
+
.option("--html-file <path>", "Read the HTML body from a file instead of --html.")
|
|
6579
|
+
.option("--display-name <name>", "From display name for this send (overrides the mailbox's stored display name).")
|
|
6580
|
+
.option("--cc <list>", "Comma-separated Cc addresses.")
|
|
6581
|
+
.option("--bcc <list>", "Comma-separated Bcc addresses.")
|
|
6066
6582
|
.option("--from <mailbox>", "Sending mailbox (address or id). Defaults to the pool rotation.")
|
|
6067
6583
|
.option("--approved", "Approve and send. Without this flag, returns a preview only.")
|
|
6068
6584
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -6072,12 +6588,19 @@ export function createProgram() {
|
|
|
6072
6588
|
if (!bodyText || !bodyText.trim()) {
|
|
6073
6589
|
throw new Error("Provide --body or --body-file.");
|
|
6074
6590
|
}
|
|
6591
|
+
const htmlBody = readOption(options.html) ?? (options.htmlFile ? readFileSync(resolve(options.htmlFile), "utf8") : undefined);
|
|
6592
|
+
const cc = readCsvOption(options.cc);
|
|
6593
|
+
const bcc = readCsvOption(options.bcc);
|
|
6075
6594
|
return requestOxygen("/api/cli/email/send", {
|
|
6076
6595
|
method: "POST",
|
|
6077
6596
|
body: {
|
|
6078
6597
|
to: readOption(options.to),
|
|
6079
6598
|
subject: readOption(options.subject),
|
|
6080
6599
|
body: bodyText,
|
|
6600
|
+
...(htmlBody && htmlBody.trim() ? { html: htmlBody } : {}),
|
|
6601
|
+
...(readOption(options.displayName) ? { display_name: readOption(options.displayName) } : {}),
|
|
6602
|
+
...(cc.length > 0 ? { cc } : {}),
|
|
6603
|
+
...(bcc.length > 0 ? { bcc } : {}),
|
|
6081
6604
|
...(readOption(options.from) ? { from: readOption(options.from) } : {}),
|
|
6082
6605
|
...(options.approved ? { approved: true } : {}),
|
|
6083
6606
|
},
|
|
@@ -6106,6 +6629,12 @@ export function createProgram() {
|
|
|
6106
6629
|
.option("--json", "Print a JSON envelope.")
|
|
6107
6630
|
.action(async (mailbox, options) => {
|
|
6108
6631
|
await handleAsyncAction("mailboxes get", options, () => requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}`));
|
|
6632
|
+
}))
|
|
6633
|
+
.addCommand(new Command("health")
|
|
6634
|
+
.description("Fleet email-health: the sending pool rolled up by external deliverability reputation (healthy/degraded/critical/unknown), per-mailbox scores, connected health providers, and DIRECTIONAL recommendations. Pure read — 0 credits; never pauses a mailbox.")
|
|
6635
|
+
.option("--json", "Print a JSON envelope.")
|
|
6636
|
+
.action(async (options) => {
|
|
6637
|
+
await handleAsyncAction("mailboxes health", options, () => requestOxygen("/api/cli/mailboxes/health"));
|
|
6109
6638
|
}))
|
|
6110
6639
|
.addCommand(new Command("import")
|
|
6111
6640
|
.description("Register (or refresh) sending mailboxes in bulk. Provide --file (a JSON file: { \"mailboxes\": [{ email_address, provider, workspace_external_id? }] }) or --from zapmail to pull the connected Zapmail workspace's mailbox list. Upsert is keyed by address, so re-importing never duplicates a mailbox.")
|
|
@@ -6175,6 +6704,67 @@ export function createProgram() {
|
|
|
6175
6704
|
});
|
|
6176
6705
|
});
|
|
6177
6706
|
})))
|
|
6707
|
+
.addCommand(new Command("ramp")
|
|
6708
|
+
.description("Set or clear a single mailbox's per-mailbox warm-up ramp override (start/step/cap), replacing the default age-ramp tiers on that inbox without touching the rest of the pool.")
|
|
6709
|
+
.addCommand(new Command("set")
|
|
6710
|
+
.description("Set one mailbox's warm-up ramp: --start-per-day (day-0 ceiling), --increase-every-days (>=1), --step (added each period), --cap (ceiling). Pass --clear to remove the override and restore the default tiers. Consumes no Oxygen credits. <mailbox> accepts a mailbox id or email address.")
|
|
6711
|
+
.argument("<mailbox>", "Mailbox id or email address.")
|
|
6712
|
+
.option("--start-per-day <n>", "Day-0 send ceiling (non-negative whole number).")
|
|
6713
|
+
.option("--increase-every-days <n>", "Days between each step up (whole number >= 1).")
|
|
6714
|
+
.option("--step <n>", "Sends added to the ceiling each period (non-negative whole number).")
|
|
6715
|
+
.option("--cap <n>", "The ramp's ceiling; once reached the ramp stops climbing (non-negative whole number).")
|
|
6716
|
+
.option("--clear", "Remove the ramp override and restore the default warm-up tiers.")
|
|
6717
|
+
.option("--json", "Print a JSON envelope.")
|
|
6718
|
+
.action(async (mailbox, options) => {
|
|
6719
|
+
await handleAsyncAction("mailboxes ramp set", options, () => {
|
|
6720
|
+
if (options.clear) {
|
|
6721
|
+
return requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}/ramp`, {
|
|
6722
|
+
method: "PATCH",
|
|
6723
|
+
body: { ramp_config: null },
|
|
6724
|
+
});
|
|
6725
|
+
}
|
|
6726
|
+
const startPerDay = readPositiveInteger(options.startPerDay);
|
|
6727
|
+
const increaseEveryDays = readPositiveInteger(options.increaseEveryDays);
|
|
6728
|
+
const step = readPositiveInteger(options.step);
|
|
6729
|
+
const cap = readPositiveInteger(options.cap);
|
|
6730
|
+
if (startPerDay === undefined || increaseEveryDays === undefined || step === undefined || cap === undefined) {
|
|
6731
|
+
throw new Error("Provide --start-per-day, --increase-every-days, --step, and --cap (or --clear to remove the override).");
|
|
6732
|
+
}
|
|
6733
|
+
return requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}/ramp`, {
|
|
6734
|
+
method: "PATCH",
|
|
6735
|
+
body: { ramp_config: { start_per_day: startPerDay, increase_every_days: increaseEveryDays, step, cap } },
|
|
6736
|
+
});
|
|
6737
|
+
});
|
|
6738
|
+
})))
|
|
6739
|
+
.addCommand(new Command("update")
|
|
6740
|
+
.description("Update a sending mailbox's rich-content profile: the From display name and/or the HTML signature appended to its sends. Pass an empty string ('') to clear a field. Consumes no Oxygen credits. <mailbox> accepts a mailbox id or email address.")
|
|
6741
|
+
.argument("<mailbox>", "Mailbox id or email address.")
|
|
6742
|
+
.option("--display-name <name>", "From display name (e.g. \"Ada from Acme\"). Pass '' to clear it.")
|
|
6743
|
+
.option("--signature-html <html>", "HTML signature appended to every send from this mailbox. Pass '' to clear it.")
|
|
6744
|
+
.option("--signature-file <path>", "Read the HTML signature from a file instead of --signature-html.")
|
|
6745
|
+
.option("--json", "Print a JSON envelope.")
|
|
6746
|
+
.action(async (mailbox, options) => {
|
|
6747
|
+
await handleAsyncAction("mailboxes update", options, () => {
|
|
6748
|
+
// Detect PRESENCE (options.x !== undefined), not truthiness, so passing
|
|
6749
|
+
// an empty string clears the field rather than being dropped.
|
|
6750
|
+
const patch = {};
|
|
6751
|
+
if (options.displayName !== undefined)
|
|
6752
|
+
patch.display_name = options.displayName;
|
|
6753
|
+
if (options.signatureHtml !== undefined) {
|
|
6754
|
+
patch.signature_html = options.signatureHtml;
|
|
6755
|
+
}
|
|
6756
|
+
else if (options.signatureFile) {
|
|
6757
|
+
patch.signature_html = readFileSync(resolve(options.signatureFile), "utf8");
|
|
6758
|
+
}
|
|
6759
|
+
if (Object.keys(patch).length === 0) {
|
|
6760
|
+
throw new Error("Provide --display-name and/or --signature-html (or --signature-file).");
|
|
6761
|
+
}
|
|
6762
|
+
return requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}`, {
|
|
6763
|
+
method: "PATCH",
|
|
6764
|
+
body: patch,
|
|
6765
|
+
});
|
|
6766
|
+
});
|
|
6767
|
+
}))
|
|
6178
6768
|
.addCommand(new Command("connect-oauth")
|
|
6179
6769
|
.description("Provision Zapmail Custom OAuth across the mailbox pool: registers Oxygen's own OAuth app with Zapmail, which authorizes it across the Workspaces so native send needs no per-Workspace delegation. BYOK — Zapmail bills your account, 0 Oxygen credits. Pass --status <export_id> to poll a run.")
|
|
6180
6770
|
.option("--provider <provider>", "Mailbox provider to provision: google or microsoft.")
|
|
@@ -6207,6 +6797,12 @@ export function createProgram() {
|
|
|
6207
6797
|
},
|
|
6208
6798
|
});
|
|
6209
6799
|
});
|
|
6800
|
+
}))
|
|
6801
|
+
.addCommand(new Command("oauth-health")
|
|
6802
|
+
.description("Show the delegation-stuck sending mailboxes: Zapmail-linked google inboxes still on auth_mode=delegation with no OAuth grant, each with its Custom OAuth budget (attempts in the last 7 days, remaining re-export posts before Zapmail's 3-per-mailbox-per-7-day cap, last error, and next safe retry time). Read-only — 0 Oxygen credits.")
|
|
6803
|
+
.option("--json", "Print a JSON envelope.")
|
|
6804
|
+
.action(async (options) => {
|
|
6805
|
+
await handleAsyncAction("mailboxes oauth-health", options, () => requestOxygen("/api/cli/mailboxes/oauth-health"));
|
|
6210
6806
|
}))
|
|
6211
6807
|
.addCommand(new Command("warmup")
|
|
6212
6808
|
.description("Mailbox warmup via Instantly (BYOK — Instantly bills your account, 0 Oxygen credits).")
|
|
@@ -6250,6 +6846,96 @@ export function createProgram() {
|
|
|
6250
6846
|
const suffix = params.toString();
|
|
6251
6847
|
return requestOxygen(`/api/cli/mailboxes/warmup/status${suffix ? `?${suffix}` : ""}`);
|
|
6252
6848
|
});
|
|
6849
|
+
})))
|
|
6850
|
+
.addCommand(new Command("order")
|
|
6851
|
+
.description("Order NEW sending mailboxes (domain + inboxes + warmup) on the OXYGEN-managed Zapmail wallet, billed to Oxygen credits. Without --approved returns a priced preview + quote_id. FAILS CLOSED (409) until managed ordering is enabled.")
|
|
6852
|
+
.requiredOption("--provider <provider>", "Mailbox provider: google or microsoft.")
|
|
6853
|
+
.requiredOption("--domains <domains>", "Comma-separated sending domains (e.g. send.acme.com,mail.acme.io).")
|
|
6854
|
+
.option("--mailboxes-per-domain <n>", "Mailboxes to provision per domain (positive whole number). Defaults to 1.")
|
|
6855
|
+
.option("--workspace <id>", "Optional Zapmail workspace key to provision into.")
|
|
6856
|
+
.option("--approved", "Place the order (requires --quote from a fresh preview).")
|
|
6857
|
+
.option("--quote <id>", "The quote_id from a fresh preview (required with --approved).")
|
|
6858
|
+
.option("--json", "Print a JSON envelope.")
|
|
6859
|
+
.action(async (options) => {
|
|
6860
|
+
await handleAsyncAction("mailboxes order", options, () => {
|
|
6861
|
+
const domains = readOption(options.domains)?.split(",").map((d) => d.trim()).filter(Boolean) ?? [];
|
|
6862
|
+
const perDomain = readOption(options.mailboxesPerDomain);
|
|
6863
|
+
const workspace = readOption(options.workspace);
|
|
6864
|
+
const quote = readOption(options.quote);
|
|
6865
|
+
return requestOxygen("/api/cli/mailboxes/order", {
|
|
6866
|
+
method: "POST",
|
|
6867
|
+
body: {
|
|
6868
|
+
provider: readOption(options.provider),
|
|
6869
|
+
domains,
|
|
6870
|
+
...(perDomain ? { mailboxes_per_domain: Number(perDomain) } : {}),
|
|
6871
|
+
...(workspace ? { workspace_id: workspace } : {}),
|
|
6872
|
+
...(options.approved ? { approved: true } : {}),
|
|
6873
|
+
...(quote ? { quote_id: quote } : {}),
|
|
6874
|
+
},
|
|
6875
|
+
});
|
|
6876
|
+
});
|
|
6877
|
+
}))
|
|
6878
|
+
.addCommand(new Command("orders")
|
|
6879
|
+
.description("List the org's managed mailbox provisioning orders (newest first).")
|
|
6880
|
+
.option("--status <status>", "Comma-separated status filter (e.g. provisioning,warming).")
|
|
6881
|
+
.option("--json", "Print a JSON envelope.")
|
|
6882
|
+
.action(async (options) => {
|
|
6883
|
+
await handleAsyncAction("mailboxes orders", options, () => {
|
|
6884
|
+
const params = new URLSearchParams();
|
|
6885
|
+
const status = readOption(options.status);
|
|
6886
|
+
if (status)
|
|
6887
|
+
params.set("status", status);
|
|
6888
|
+
const suffix = params.toString();
|
|
6889
|
+
return requestOxygen(`/api/cli/mailboxes/orders${suffix ? `?${suffix}` : ""}`);
|
|
6890
|
+
});
|
|
6891
|
+
}))
|
|
6892
|
+
.addCommand(new Command("order-status")
|
|
6893
|
+
.description("Show one managed mailbox order's status (provider, domains, status, quote, held credits, Zapmail refs).")
|
|
6894
|
+
.argument("<order>", "Mailbox order id.")
|
|
6895
|
+
.option("--json", "Print a JSON envelope.")
|
|
6896
|
+
.action(async (order, options) => {
|
|
6897
|
+
await handleAsyncAction("mailboxes order-status", options, () => requestOxygen(`/api/cli/mailboxes/orders/${encodeURIComponent(order)}`));
|
|
6898
|
+
})));
|
|
6899
|
+
program.addCommand(new Command("deliverability")
|
|
6900
|
+
.description("External email deliverability: fleet reputation health and DIRECTIONAL inbox-placement (spam) tests. Placement tests are approval-gated paid runs (managed = 2 credits, BYOK = 0); fail closed with 409 when no health provider is connected.")
|
|
6901
|
+
.addCommand(new Command("placement-test")
|
|
6902
|
+
.description("Directional inbox-placement (spam) tests: run, list, and get. Results estimate inbox-vs-spam landing from seed inboxes.")
|
|
6903
|
+
.addCommand(new Command("run")
|
|
6904
|
+
.description("Start a placement test for one sending mailbox. Without --approved this returns a PREVIEW (billing mode + credits_required). Re-run with --approved and --max-credits >= credits_required to send.")
|
|
6905
|
+
.argument("<mailbox>", "Sending mailbox address to test (e.g. ada@send.acme.com).")
|
|
6906
|
+
.option("--subject <subject>", "Optional subject for the seed message.")
|
|
6907
|
+
.option("--body <text>", "Optional plain-text body for the seed message.")
|
|
6908
|
+
.option("--approved", "Actually run the test (otherwise a preview is returned).")
|
|
6909
|
+
.option("--max-credits <n>", "Credit cap the caller accepts (must be >= credits_required for managed runs).")
|
|
6910
|
+
.option("--json", "Print a JSON envelope.")
|
|
6911
|
+
.action(async (mailbox, options) => {
|
|
6912
|
+
await handleAsyncAction("deliverability placement-test run", options, () => requestOxygen("/api/cli/deliverability/placement-tests", {
|
|
6913
|
+
method: "POST",
|
|
6914
|
+
body: {
|
|
6915
|
+
mailbox,
|
|
6916
|
+
...(readOption(options.subject) ? { subject: readOption(options.subject) } : {}),
|
|
6917
|
+
...(readOption(options.body) ? { body_text: readOption(options.body) } : {}),
|
|
6918
|
+
...(options.approved ? { approved: true } : {}),
|
|
6919
|
+
...(readOption(options.maxCredits) ? { max_credits: Number(readOption(options.maxCredits)) } : {}),
|
|
6920
|
+
},
|
|
6921
|
+
}));
|
|
6922
|
+
}))
|
|
6923
|
+
.addCommand(new Command("list")
|
|
6924
|
+
.description("List recent directional inbox-placement tests with status, billing mode, seed set, score, and credits used.")
|
|
6925
|
+
.option("--limit <n>", "Max rows (default 50, cap 200).")
|
|
6926
|
+
.option("--json", "Print a JSON envelope.")
|
|
6927
|
+
.action(async (options) => {
|
|
6928
|
+
await handleAsyncAction("deliverability placement-test list", options, () => {
|
|
6929
|
+
const limit = readOption(options.limit);
|
|
6930
|
+
return requestOxygen(`/api/cli/deliverability/placement-tests${limit ? `?limit=${encodeURIComponent(limit)}` : ""}`);
|
|
6931
|
+
});
|
|
6932
|
+
}))
|
|
6933
|
+
.addCommand(new Command("get")
|
|
6934
|
+
.description("Get one directional inbox-placement test by id — status, seed inboxes, per-provider results, and score.")
|
|
6935
|
+
.argument("<id>", "Placement test id.")
|
|
6936
|
+
.option("--json", "Print a JSON envelope.")
|
|
6937
|
+
.action(async (id, options) => {
|
|
6938
|
+
await handleAsyncAction("deliverability placement-test get", options, () => requestOxygen(`/api/cli/deliverability/placement-tests/${encodeURIComponent(id)}`));
|
|
6253
6939
|
}))));
|
|
6254
6940
|
program.addCommand(new Command("domains")
|
|
6255
6941
|
.description("Cold-email domain management on the org's own Cloudflare account (BYOK): sync zones, inspect age/warmup/DNS health, check availability and pricing, and buy domains. Purchases bill your Cloudflare payment method, never Oxygen credits.")
|
|
@@ -6290,11 +6976,70 @@ export function createProgram() {
|
|
|
6290
6976
|
await handleAsyncAction("domains get", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}`));
|
|
6291
6977
|
}))
|
|
6292
6978
|
.addCommand(new Command("dns")
|
|
6293
|
-
.description("Run a live read-only DNS health check (SPF, DKIM, DMARC, MX) against the domain's Cloudflare zone and persist the result.")
|
|
6979
|
+
.description("Run a live read-only DNS health check (SPF, DKIM, DMARC, MX) against the domain's Cloudflare zone and persist the result. Add --deep for content-level checks (SPF DNS-lookup budget, DMARC enforcement, MX provider) plus an external DNS-over-HTTPS nameserver-authority check. Use `dns plan`/`dns apply` to fix records.")
|
|
6980
|
+
.argument("[domain]", "Domain name, such as acme.com.")
|
|
6981
|
+
.option("--deep", "Add content-level DNS analysis (SPF lookup budget, DMARC enforcement, MX provider) and an external DoH nameserver-authority check.")
|
|
6982
|
+
.option("--json", "Print a JSON envelope.")
|
|
6983
|
+
.action(async (domain, options) => {
|
|
6984
|
+
if (!domain) {
|
|
6985
|
+
throw new Error("Provide a domain (oxygen domains dns <domain>) or use a subcommand: `dns plan` / `dns apply`.");
|
|
6986
|
+
}
|
|
6987
|
+
const suffix = options.deep ? "?deep=true" : "";
|
|
6988
|
+
await handleAsyncAction("domains dns", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/dns${suffix}`));
|
|
6989
|
+
})
|
|
6990
|
+
.addCommand(new Command("plan")
|
|
6991
|
+
.description("Preview the cold-email DNS records a domain should hold for its mailbox provider (SPF, per-provider DKIM, DMARC, MX, optional tracking) as a create/update/keep/conflict diff plus a plan_hash. FREE — nothing is written.")
|
|
6294
6992
|
.argument("<domain>", "Domain name, such as acme.com.")
|
|
6993
|
+
.requiredOption("--provider <provider>", "Mailbox provider: google or microsoft (determines SPF/MX/DKIM).")
|
|
6994
|
+
.option("--dmarc-policy <policy>", "DMARC policy: none (default), quarantine, or reject.")
|
|
6995
|
+
.option("--dmarc-rua <mailbox>", "DMARC aggregate-report mailbox (rua=mailto:...).")
|
|
6996
|
+
.option("--tracking-host <host>", "Open/click tracking CNAME host (only planned when tracking is configured).")
|
|
6997
|
+
.option("--no-auto-dkim", "Skip auto-deriving Microsoft DKIM selector1/selector2 CNAMEs (default: auto-derive them for microsoft when no DKIM is supplied).")
|
|
6295
6998
|
.option("--json", "Print a JSON envelope.")
|
|
6296
6999
|
.action(async (domain, options) => {
|
|
6297
|
-
await handleAsyncAction("domains dns", options, () =>
|
|
7000
|
+
await handleAsyncAction("domains dns plan", options, () => runDomainsDnsPlan(domain, options));
|
|
7001
|
+
}))
|
|
7002
|
+
.addCommand(new Command("apply")
|
|
7003
|
+
.description("Write the planned cold-email DNS records into the domain's Cloudflare zone. Requires a fresh plan_hash from `dns plan`. No Oxygen credits (Cloudflare does not charge for DNS writes). Without --approved, re-previews the plan.")
|
|
7004
|
+
.argument("<domain>", "Domain name, such as acme.com.")
|
|
7005
|
+
.requiredOption("--provider <provider>", "Mailbox provider: google or microsoft (must match the plan).")
|
|
7006
|
+
.option("--approved", "Write the records. Without this flag, returns a plan preview only.")
|
|
7007
|
+
.option("--plan <hash>", "plan_hash from a fresh `dns plan` (required with --approved).")
|
|
7008
|
+
.option("--dmarc-policy <policy>", "DMARC policy: none (default), quarantine, or reject.")
|
|
7009
|
+
.option("--dmarc-rua <mailbox>", "DMARC aggregate-report mailbox (rua=mailto:...).")
|
|
7010
|
+
.option("--tracking-host <host>", "Open/click tracking CNAME host (only applied when tracking is configured).")
|
|
7011
|
+
.option("--no-auto-dkim", "Skip auto-deriving Microsoft DKIM (must match the value used in `dns plan` so the plan_hash matches).")
|
|
7012
|
+
.option("--json", "Print a JSON envelope.")
|
|
7013
|
+
.action(async (domain, options) => {
|
|
7014
|
+
await handleAsyncAction("domains dns apply", options, () => runDomainsDnsApply(domain, options));
|
|
7015
|
+
})))
|
|
7016
|
+
.addCommand(new Command("tracking")
|
|
7017
|
+
.description("Open/click tracking domain for native cold email: provision the tracking CNAME on the domain's Cloudflare zone and check its verification status. 0 Oxygen credits.")
|
|
7018
|
+
.addCommand(new Command("setup")
|
|
7019
|
+
.description("Provision the open/click tracking CNAME (<track-host> CNAME <tracking target>) on the domain's Cloudflare zone. Without --approve, previews the exact record. GATE: needs EMAIL_TRACKING_CNAME_TARGET configured + the tracking hostname attached on Vercel; send-time injection stays off until EMAIL_TRACKING_SECRET is set.")
|
|
7020
|
+
.argument("<domain>", "Sending domain, such as acme.com.")
|
|
7021
|
+
.option("--host <host>", "Tracking subdomain (default track.<domain>); must be a subdomain of the domain.")
|
|
7022
|
+
.option("--approve", "Write the CNAME. Without this flag, returns a preview only.")
|
|
7023
|
+
.option("--json", "Print a JSON envelope.")
|
|
7024
|
+
.action(async (domain, options) => {
|
|
7025
|
+
await handleAsyncAction("domains tracking setup", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/tracking`, {
|
|
7026
|
+
method: "POST",
|
|
7027
|
+
body: { ...(options.host ? { host: options.host } : {}), ...(options.approve ? { approved: true } : {}) },
|
|
7028
|
+
}));
|
|
7029
|
+
}))
|
|
7030
|
+
.addCommand(new Command("status")
|
|
7031
|
+
.description("Show the open/click tracking status for a domain (configured host, verification status, CNAME target, whether the tracking secret is set). Reads the cache — 0 Oxygen credits.")
|
|
7032
|
+
.argument("<domain>", "Sending domain, such as acme.com.")
|
|
7033
|
+
.option("--json", "Print a JSON envelope.")
|
|
7034
|
+
.action(async (domain, options) => {
|
|
7035
|
+
await handleAsyncAction("domains tracking status", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/tracking`));
|
|
7036
|
+
})))
|
|
7037
|
+
.addCommand(new Command("add")
|
|
7038
|
+
.description("Onboard a domain registered elsewhere by creating a Cloudflare zone for it, so its DNS can be managed here. Returns the nameservers to set at your registrar. Idempotent.")
|
|
7039
|
+
.argument("<domain>", "Apex domain to add, such as acme.com.")
|
|
7040
|
+
.option("--json", "Print a JSON envelope.")
|
|
7041
|
+
.action(async (domain, options) => {
|
|
7042
|
+
await handleAsyncAction("domains add", options, () => requestOxygen("/api/cli/domains/add", { method: "POST", body: { domain } }));
|
|
6298
7043
|
}))
|
|
6299
7044
|
.addCommand(new Command("search")
|
|
6300
7045
|
.description("Search Cloudflare Registrar for available domains with registration/renewal pricing. Free — nothing is purchased.")
|
|
@@ -8605,6 +9350,44 @@ function domainsBuyRerunCommand(domains, quoteId, options, data) {
|
|
|
8605
9350
|
];
|
|
8606
9351
|
return `${resolveCliBinaryName()} domains buy ${domains.join(" ")} ${flags.join(" ")}`;
|
|
8607
9352
|
}
|
|
9353
|
+
function dnsPlanBody(options) {
|
|
9354
|
+
const provider = readOption(options.provider);
|
|
9355
|
+
if (!provider) {
|
|
9356
|
+
throw new Error("--provider <google|microsoft> is required so the correct SPF/MX/DKIM records can be planned.");
|
|
9357
|
+
}
|
|
9358
|
+
return {
|
|
9359
|
+
provider,
|
|
9360
|
+
...(readOption(options.dmarcPolicy) ? { dmarc_policy: readOption(options.dmarcPolicy) } : {}),
|
|
9361
|
+
...(readOption(options.dmarcRua) ? { dmarc_rua: readOption(options.dmarcRua) } : {}),
|
|
9362
|
+
...(readOption(options.trackingHost) ? { tracking_host: readOption(options.trackingHost) } : {}),
|
|
9363
|
+
...(options.autoDkim === false ? { auto_dkim: false } : {}),
|
|
9364
|
+
};
|
|
9365
|
+
}
|
|
9366
|
+
// Preview the cold-email DNS plan for a domain (free — no writes). Appends a
|
|
9367
|
+
// copy-paste rerun command carrying the plan_hash, mirroring the buy quote flow.
|
|
9368
|
+
async function runDomainsDnsPlan(domain, options) {
|
|
9369
|
+
const data = await requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/dns/plan`, { method: "POST", body: dnsPlanBody(options) });
|
|
9370
|
+
const planHash = readRecordString(data, "plan_hash");
|
|
9371
|
+
const provider = readOption(options.provider);
|
|
9372
|
+
return planHash && provider
|
|
9373
|
+
? { ...data, rerun_command: `${resolveCliBinaryName()} domains dns apply ${domain} --provider ${provider} --approved --plan ${planHash}` }
|
|
9374
|
+
: data;
|
|
9375
|
+
}
|
|
9376
|
+
// Apply an approved DNS plan (writes records). Requires --plan <hash> with
|
|
9377
|
+
// --approved, mirroring `domains buy --quote`.
|
|
9378
|
+
async function runDomainsDnsApply(domain, options) {
|
|
9379
|
+
const planHash = readOption(options.plan);
|
|
9380
|
+
if (options.approved && !planHash) {
|
|
9381
|
+
throw new Error("--plan <hash> is required with --approved. Run `domains dns plan` first to get the plan and its hash.");
|
|
9382
|
+
}
|
|
9383
|
+
return requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/dns/apply`, {
|
|
9384
|
+
method: "POST",
|
|
9385
|
+
body: {
|
|
9386
|
+
...dnsPlanBody(options),
|
|
9387
|
+
...(options.approved ? { approved: true, plan_hash: planHash } : {}),
|
|
9388
|
+
},
|
|
9389
|
+
});
|
|
9390
|
+
}
|
|
8608
9391
|
// Staff import of pre-existing managed-account domains. Exactly one of an explicit
|
|
8609
9392
|
// list or --all; without --yes the server returns a preview and the CLI appends a
|
|
8610
9393
|
// copy-paste rerun command to execute it.
|
|
@@ -10290,11 +11073,32 @@ async function handleSequenceSignalAction(sequence, options) {
|
|
|
10290
11073
|
}
|
|
10291
11074
|
async function handleSequenceVariantsAction(sequence, options) {
|
|
10292
11075
|
try {
|
|
10293
|
-
const
|
|
11076
|
+
const path = `/api/cli/sequences/${encodeURIComponent(sequence)}/variants`;
|
|
11077
|
+
let body = null;
|
|
11078
|
+
if (options.autoOptimize)
|
|
11079
|
+
body = { action: "auto_optimize" };
|
|
11080
|
+
else if (options.pause !== undefined)
|
|
11081
|
+
body = { action: "pause", step_id: options.step, variant: options.pause };
|
|
11082
|
+
else if (options.activate !== undefined)
|
|
11083
|
+
body = { action: "activate", step_id: options.step, variant: options.activate };
|
|
11084
|
+
else if (options.reset !== undefined)
|
|
11085
|
+
body = {
|
|
11086
|
+
action: "reset",
|
|
11087
|
+
...(options.step ? { step_id: options.step } : {}),
|
|
11088
|
+
...(typeof options.reset === "string" ? { variant: options.reset } : {}),
|
|
11089
|
+
};
|
|
11090
|
+
const data = body
|
|
11091
|
+
? await requestOxygen(path, { method: "POST", body })
|
|
11092
|
+
: await requestOxygen(path);
|
|
10294
11093
|
if (options.json) {
|
|
10295
11094
|
writeJson(success("sequences variants", data));
|
|
10296
11095
|
return;
|
|
10297
11096
|
}
|
|
11097
|
+
if (body) {
|
|
11098
|
+
const link = data.deepLink ?? data.web_url;
|
|
11099
|
+
process.stdout.write(`${data.note ?? "Done."}${link ? ` ${link}` : ""}\n`);
|
|
11100
|
+
return;
|
|
11101
|
+
}
|
|
10298
11102
|
process.stdout.write(formatSequenceVariants(data));
|
|
10299
11103
|
}
|
|
10300
11104
|
catch (error) {
|
|
@@ -10640,6 +11444,33 @@ function ansi(enabled) {
|
|
|
10640
11444
|
function readOption(value) {
|
|
10641
11445
|
return value?.trim() ? value.trim() : null;
|
|
10642
11446
|
}
|
|
11447
|
+
// Commander collector for a repeatable option (e.g. --attach <url> --attach <url>):
|
|
11448
|
+
// accumulates each value into an array.
|
|
11449
|
+
function collectRepeatable(value, previous) {
|
|
11450
|
+
return [...previous, value];
|
|
11451
|
+
}
|
|
11452
|
+
// Shared query string for `posts comments` / `posts reactions` (post social_id +
|
|
11453
|
+
// optional comment scope, pagination, sort, and account selection).
|
|
11454
|
+
function buildPostEngagementQuery(options) {
|
|
11455
|
+
const params = new URLSearchParams();
|
|
11456
|
+
params.set("post", readOption(options.post) ?? "");
|
|
11457
|
+
const commentId = readOption(options.commentId);
|
|
11458
|
+
if (commentId)
|
|
11459
|
+
params.set("comment_id", commentId);
|
|
11460
|
+
const sortBy = readOption(options.sortBy);
|
|
11461
|
+
if (sortBy)
|
|
11462
|
+
params.set("sort_by", sortBy);
|
|
11463
|
+
const cursor = readOption(options.cursor);
|
|
11464
|
+
if (cursor)
|
|
11465
|
+
params.set("cursor", cursor);
|
|
11466
|
+
const limit = readOption(options.limit);
|
|
11467
|
+
if (limit)
|
|
11468
|
+
params.set("limit", limit);
|
|
11469
|
+
const account = readOption(options.account);
|
|
11470
|
+
if (account)
|
|
11471
|
+
params.set("account", account);
|
|
11472
|
+
return params.toString();
|
|
11473
|
+
}
|
|
10643
11474
|
// Assemble the POST body for `oxygen feedback`. Reads the local chat transcript
|
|
10644
11475
|
// (unless --no-transcript) and attaches a non-sensitive environment snapshot so
|
|
10645
11476
|
// the Oxygen team can triage. The transcript read happens here, inside the
|
|
@@ -10690,11 +11521,15 @@ function buildSupportTicketBody(options) {
|
|
|
10690
11521
|
body.severity = readOption(options.severity);
|
|
10691
11522
|
if (readOption(options.category))
|
|
10692
11523
|
body.category = readOption(options.category);
|
|
11524
|
+
if (readOption(options.source))
|
|
11525
|
+
body.source = readOption(options.source);
|
|
10693
11526
|
const context = {};
|
|
10694
11527
|
if (readOption(options.operation))
|
|
10695
11528
|
context.operation = readOption(options.operation);
|
|
10696
11529
|
if (readOption(options.errorCode))
|
|
10697
11530
|
context.error_code = readOption(options.errorCode);
|
|
11531
|
+
if (readOption(options.slackPermalink))
|
|
11532
|
+
context.slack_permalink = readOption(options.slackPermalink);
|
|
10698
11533
|
const runIds = readCsvOption(options.runIds);
|
|
10699
11534
|
const tableIds = readCsvOption(options.tableIds);
|
|
10700
11535
|
const deepLinks = readCsvOption(options.deepLinks);
|
|
@@ -10719,6 +11554,116 @@ function withSupportListQuery(path, options) {
|
|
|
10719
11554
|
const query = params.toString();
|
|
10720
11555
|
return query ? `${path}?${query}` : path;
|
|
10721
11556
|
}
|
|
11557
|
+
// Workflow text flags (--verify-notes/--plan/--draft) bypass readOption: an
|
|
11558
|
+
// explicitly-passed empty string clears the field server-side, while an absent
|
|
11559
|
+
// flag leaves it untouched, so "" must survive to the request body.
|
|
11560
|
+
function buildSupportAdminWorkflowBody(options) {
|
|
11561
|
+
const body = {};
|
|
11562
|
+
const verifyStatus = readOption(options.verifyStatus);
|
|
11563
|
+
if (verifyStatus)
|
|
11564
|
+
body.verify_status = verifyStatus;
|
|
11565
|
+
if (options.verifyNotes !== undefined)
|
|
11566
|
+
body.verify_notes = options.verifyNotes;
|
|
11567
|
+
const planStatus = readOption(options.planStatus);
|
|
11568
|
+
if (planStatus)
|
|
11569
|
+
body.plan_status = planStatus;
|
|
11570
|
+
if (options.plan !== undefined)
|
|
11571
|
+
body.plan = options.plan;
|
|
11572
|
+
const draftStatus = readOption(options.draftStatus);
|
|
11573
|
+
if (draftStatus)
|
|
11574
|
+
body.draft_status = draftStatus;
|
|
11575
|
+
if (options.draft !== undefined)
|
|
11576
|
+
body.draft_reply = options.draft;
|
|
11577
|
+
if (!Object.keys(body).length) {
|
|
11578
|
+
throw new OxygenError("invalid_request", "Pass at least one of --verify-status, --verify-notes, --plan-status, --plan, --draft-status, --draft.", { exitCode: 1 });
|
|
11579
|
+
}
|
|
11580
|
+
return body;
|
|
11581
|
+
}
|
|
11582
|
+
function buildSupportAdminUpdateBody(options) {
|
|
11583
|
+
if (options.assign !== undefined && options.unassign) {
|
|
11584
|
+
throw new OxygenError("invalid_request", "Pass either --assign <email> or --unassign, not both.", {
|
|
11585
|
+
exitCode: 1,
|
|
11586
|
+
});
|
|
11587
|
+
}
|
|
11588
|
+
const body = {};
|
|
11589
|
+
const status = readOption(options.status);
|
|
11590
|
+
if (status)
|
|
11591
|
+
body.status = status;
|
|
11592
|
+
const assign = readOption(options.assign);
|
|
11593
|
+
if (assign)
|
|
11594
|
+
body.assignee_email = assign;
|
|
11595
|
+
if (options.unassign)
|
|
11596
|
+
body.assignee_email = null;
|
|
11597
|
+
const note = readOption(options.note);
|
|
11598
|
+
if (note)
|
|
11599
|
+
body.note = note;
|
|
11600
|
+
if (!Object.keys(body).length) {
|
|
11601
|
+
throw new OxygenError("invalid_request", "Pass at least one of --status, --assign, --unassign, --note.", { exitCode: 1 });
|
|
11602
|
+
}
|
|
11603
|
+
return body;
|
|
11604
|
+
}
|
|
11605
|
+
async function handleSupportAdminWorkflowAction(ticketId, options) {
|
|
11606
|
+
try {
|
|
11607
|
+
const body = buildSupportAdminWorkflowBody(options);
|
|
11608
|
+
const data = await requestOxygen(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}/workflow`, { method: "POST", body });
|
|
11609
|
+
if (options.json) {
|
|
11610
|
+
writeJson(success("support admin workflow", data));
|
|
11611
|
+
return;
|
|
11612
|
+
}
|
|
11613
|
+
process.stdout.write(formatSupportAdminWorkflowTicket(data));
|
|
11614
|
+
}
|
|
11615
|
+
catch (error) {
|
|
11616
|
+
const failure = toFailure("support admin workflow", error);
|
|
11617
|
+
writeJson(failure);
|
|
11618
|
+
process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
|
|
11619
|
+
}
|
|
11620
|
+
}
|
|
11621
|
+
async function handleSupportAdminUpdateAction(ticketId, options) {
|
|
11622
|
+
try {
|
|
11623
|
+
const body = buildSupportAdminUpdateBody(options);
|
|
11624
|
+
const data = await requestOxygen(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}/update`, { method: "POST", body });
|
|
11625
|
+
if (options.json) {
|
|
11626
|
+
writeJson(success("support admin update", data));
|
|
11627
|
+
return;
|
|
11628
|
+
}
|
|
11629
|
+
process.stdout.write(formatSupportAdminUpdateTicket(data));
|
|
11630
|
+
}
|
|
11631
|
+
catch (error) {
|
|
11632
|
+
const failure = toFailure("support admin update", error);
|
|
11633
|
+
writeJson(failure);
|
|
11634
|
+
process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
|
|
11635
|
+
}
|
|
11636
|
+
}
|
|
11637
|
+
function formatSupportAdminWorkflowStep(styles, label, status, artifactLabel, artifact) {
|
|
11638
|
+
const artifactNote = artifact?.trim() ? `${artifactLabel} set` : `no ${artifactLabel}`;
|
|
11639
|
+
return ` ${label.padEnd(6)} ${status ?? "pending"} ${styles.dim(`(${artifactNote})`)}`;
|
|
11640
|
+
}
|
|
11641
|
+
function formatSupportAdminWorkflowTicket(data) {
|
|
11642
|
+
const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
|
|
11643
|
+
const ticket = data.ticket ?? {};
|
|
11644
|
+
const workflow = ticket.workflow ?? {};
|
|
11645
|
+
const lines = [
|
|
11646
|
+
`${styles.bold("Workflow updated")}${ticket.subject ? styles.dim(` — ${ticket.subject}`) : ""}`,
|
|
11647
|
+
formatSupportAdminWorkflowStep(styles, "verify", workflow.verify_status, "notes", workflow.verify_notes),
|
|
11648
|
+
formatSupportAdminWorkflowStep(styles, "plan", workflow.plan_status, "plan", workflow.plan),
|
|
11649
|
+
formatSupportAdminWorkflowStep(styles, "draft", workflow.draft_status, "draft reply", workflow.draft_reply),
|
|
11650
|
+
];
|
|
11651
|
+
if (ticket.admin_web_url)
|
|
11652
|
+
lines.push(`View ticket: ${ticket.admin_web_url}`);
|
|
11653
|
+
return `${lines.join("\n")}\n`;
|
|
11654
|
+
}
|
|
11655
|
+
function formatSupportAdminUpdateTicket(data) {
|
|
11656
|
+
const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
|
|
11657
|
+
const ticket = data.ticket ?? {};
|
|
11658
|
+
const lines = [
|
|
11659
|
+
`${styles.bold("Ticket updated")}${ticket.subject ? styles.dim(` — ${ticket.subject}`) : ""}`,
|
|
11660
|
+
` Status: ${ticket.status ?? "?"}`,
|
|
11661
|
+
` Assignee: ${ticket.assignee_email ?? "(unassigned)"}`,
|
|
11662
|
+
];
|
|
11663
|
+
if (ticket.admin_web_url)
|
|
11664
|
+
lines.push(`View ticket: ${ticket.admin_web_url}`);
|
|
11665
|
+
return `${lines.join("\n")}\n`;
|
|
11666
|
+
}
|
|
10722
11667
|
function readCsvOption(value) {
|
|
10723
11668
|
const option = readOption(value);
|
|
10724
11669
|
if (!option)
|
|
@@ -10728,6 +11673,79 @@ function readCsvOption(value) {
|
|
|
10728
11673
|
.map((entry) => entry.trim())
|
|
10729
11674
|
.filter(Boolean);
|
|
10730
11675
|
}
|
|
11676
|
+
// Assemble the directory listing profile payload shared by `directory update`
|
|
11677
|
+
// and `admin directory enable`. --profile-json provides the base object (and
|
|
11678
|
+
// the only way to null-clear fields); explicit flags override it.
|
|
11679
|
+
async function buildDirectoryProfileBody(options) {
|
|
11680
|
+
const profile = options.profileJson
|
|
11681
|
+
? parseJsonObject(options.profileJson)
|
|
11682
|
+
: {};
|
|
11683
|
+
const stringFlags = [
|
|
11684
|
+
[options.name, "name"],
|
|
11685
|
+
[options.slug, "slug"],
|
|
11686
|
+
[options.logoUrl, "logo_url"],
|
|
11687
|
+
[options.tagline, "tagline"],
|
|
11688
|
+
[options.description, "description"],
|
|
11689
|
+
[options.website, "website_url"],
|
|
11690
|
+
[options.companyLinkedin, "linkedin_company_url"],
|
|
11691
|
+
[options.bookingUrl, "booking_url"],
|
|
11692
|
+
[options.contactEmail, "contact_email"],
|
|
11693
|
+
];
|
|
11694
|
+
for (const [value, key] of stringFlags) {
|
|
11695
|
+
const normalized = readOption(value);
|
|
11696
|
+
if (normalized)
|
|
11697
|
+
profile[key] = normalized;
|
|
11698
|
+
}
|
|
11699
|
+
if (!profile.description) {
|
|
11700
|
+
const path = readOption(options.descriptionFile);
|
|
11701
|
+
if (path) {
|
|
11702
|
+
const fs = await import("node:fs/promises");
|
|
11703
|
+
profile.description = await fs.readFile(path, "utf8");
|
|
11704
|
+
}
|
|
11705
|
+
}
|
|
11706
|
+
if (readOption(options.foundersJson)) {
|
|
11707
|
+
const founders = parseJsonValue(options.foundersJson ?? "[]", "founders-json");
|
|
11708
|
+
if (!Array.isArray(founders)) {
|
|
11709
|
+
throw new OxygenError("invalid_request", "--founders-json must be a JSON array of {name, title, linkedin_url} objects.", { exitCode: 1 });
|
|
11710
|
+
}
|
|
11711
|
+
profile.founders = founders;
|
|
11712
|
+
}
|
|
11713
|
+
const csvFlags = [
|
|
11714
|
+
[options.services, "services"],
|
|
11715
|
+
[options.regions, "regions"],
|
|
11716
|
+
];
|
|
11717
|
+
for (const [value, key] of csvFlags) {
|
|
11718
|
+
if (readOption(value))
|
|
11719
|
+
profile[key] = readCsvOption(value);
|
|
11720
|
+
}
|
|
11721
|
+
return profile;
|
|
11722
|
+
}
|
|
11723
|
+
function directoryLogoMimeType(extension) {
|
|
11724
|
+
switch (extension.toLowerCase()) {
|
|
11725
|
+
case ".png":
|
|
11726
|
+
return "image/png";
|
|
11727
|
+
case ".jpg":
|
|
11728
|
+
case ".jpeg":
|
|
11729
|
+
return "image/jpeg";
|
|
11730
|
+
case ".webp":
|
|
11731
|
+
return "image/webp";
|
|
11732
|
+
case ".svg":
|
|
11733
|
+
return "image/svg+xml";
|
|
11734
|
+
default:
|
|
11735
|
+
throw new OxygenError("invalid_request", "--logo-file must be a .png, .jpg, .jpeg, .webp, or .svg image.", { exitCode: 1 });
|
|
11736
|
+
}
|
|
11737
|
+
}
|
|
11738
|
+
async function uploadDirectoryLogo(logoPath) {
|
|
11739
|
+
const fs = await import("node:fs/promises");
|
|
11740
|
+
const path = await import("node:path");
|
|
11741
|
+
const buffer = await fs.readFile(logoPath);
|
|
11742
|
+
const form = new FormData();
|
|
11743
|
+
form.append("file", new File([buffer], path.basename(logoPath), { type: directoryLogoMimeType(path.extname(logoPath)) }));
|
|
11744
|
+
return await requestOxygen("/api/cli/directory/listing/logo", {
|
|
11745
|
+
method: "POST",
|
|
11746
|
+
formData: form,
|
|
11747
|
+
});
|
|
11748
|
+
}
|
|
10731
11749
|
// Assemble the optional campaign email binding from the --email-* flags. The
|
|
10732
11750
|
// content spec (--email-definition-file) is the author-provided email sequence
|
|
10733
11751
|
// that the API compiles to an Instantly campaign on start; provider/connection
|
|
@@ -10981,16 +11999,22 @@ options) {
|
|
|
10981
11999
|
else if (options.warmupRestart) {
|
|
10982
12000
|
warmup = { enabled: true };
|
|
10983
12001
|
}
|
|
12002
|
+
// Opinionated-safety knobs (P3): the warm-up preset curve + randomised daily caps.
|
|
12003
|
+
const warmupPreset = readOption(options.warmupPreset);
|
|
12004
|
+
const hasPreset = warmupPreset !== null && warmupPreset !== undefined;
|
|
12005
|
+
const hasRandomize = options.randomizeCaps !== undefined;
|
|
10984
12006
|
const hasLimits = Object.keys(limits).length > 0;
|
|
10985
12007
|
const hasWorkingHours = Object.keys(workingHours).length > 0;
|
|
10986
12008
|
const hasWarmup = warmup !== undefined;
|
|
10987
|
-
if (!hasLimits && !hasWorkingHours && !hasWarmup) {
|
|
10988
|
-
throw new OxygenError("invalid_request", "Pass at least one limit flag (e.g. --invites-per-day), --timezone,
|
|
12009
|
+
if (!hasLimits && !hasWorkingHours && !hasWarmup && !hasPreset && !hasRandomize) {
|
|
12010
|
+
throw new OxygenError("invalid_request", "Pass at least one limit flag (e.g. --invites-per-day), --timezone, a warm-up flag (--warmup-restart / --warmup-disable / --warmup-start-date), --warmup-preset, or --randomize-caps.", { exitCode: 1 });
|
|
10989
12011
|
}
|
|
10990
12012
|
return {
|
|
10991
12013
|
...(hasLimits ? { limits } : {}),
|
|
10992
12014
|
...(hasWorkingHours ? { working_hours: workingHours } : {}),
|
|
10993
12015
|
...(hasWarmup ? { warmup } : {}),
|
|
12016
|
+
...(hasPreset ? { warmup_preset: warmupPreset } : {}),
|
|
12017
|
+
...(hasRandomize ? { randomize_daily_caps: options.randomizeCaps } : {}),
|
|
10994
12018
|
};
|
|
10995
12019
|
}
|
|
10996
12020
|
function readPositiveInt(value) {
|
|
@@ -11068,6 +12092,27 @@ function readSequenceSettings(options) {
|
|
|
11068
12092
|
if (phoneColumnKey) {
|
|
11069
12093
|
settings.phone_column_key = phoneColumnKey;
|
|
11070
12094
|
}
|
|
12095
|
+
// stop_on_bounce defaults ON server-side; commander's `--no-stop-on-bounce`
|
|
12096
|
+
// sets this to false, so only send the key when the user explicitly opts OUT.
|
|
12097
|
+
// Leaving it absent keeps the safe default and a settings PATCH clean.
|
|
12098
|
+
if (options.stopOnBounce === false) {
|
|
12099
|
+
settings.stop_on_bounce = false;
|
|
12100
|
+
}
|
|
12101
|
+
const maxEmailsPerDay = readPositiveInteger(options.maxEmailsPerDay);
|
|
12102
|
+
if (maxEmailsPerDay !== undefined)
|
|
12103
|
+
settings.max_emails_per_day = maxEmailsPerDay;
|
|
12104
|
+
const maxNewEnrollmentsPerDay = readPositiveInteger(options.maxNewEnrollmentsPerDay);
|
|
12105
|
+
if (maxNewEnrollmentsPerDay !== undefined)
|
|
12106
|
+
settings.max_new_enrollments_per_day = maxNewEnrollmentsPerDay;
|
|
12107
|
+
const prioritization = readOption(options.sequencePrioritization);
|
|
12108
|
+
if (prioritization)
|
|
12109
|
+
settings.sequence_prioritization = prioritization;
|
|
12110
|
+
const scheduleTemplate = readOption(options.scheduleTemplate);
|
|
12111
|
+
if (scheduleTemplate)
|
|
12112
|
+
settings.schedule_template = scheduleTemplate;
|
|
12113
|
+
const espMatching = readOption(options.espMatching);
|
|
12114
|
+
if (espMatching)
|
|
12115
|
+
settings.esp_matching = espMatching;
|
|
11071
12116
|
return Object.keys(settings).length > 0 ? settings : undefined;
|
|
11072
12117
|
}
|
|
11073
12118
|
function muteTokenEcho() {
|