@oxygen-agent/cli 1.246.0 → 1.263.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.js +1076 -32
- 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";
|
|
@@ -308,6 +308,7 @@ function normalizeDeleteRowIds(values) {
|
|
|
308
308
|
}
|
|
309
309
|
return rowIds;
|
|
310
310
|
}
|
|
311
|
+
const CUSTOM_HTTP_MANIFEST_DOCS_URL = "https://oxygen-agent.com/docs/providers/custom-http";
|
|
311
312
|
function readCustomIntegrationManifest(options) {
|
|
312
313
|
const manifestPath = readOption(options.manifest);
|
|
313
314
|
const manifestJson = readOption(options.manifestJson);
|
|
@@ -321,21 +322,38 @@ function readCustomIntegrationManifest(options) {
|
|
|
321
322
|
exitCode: 1,
|
|
322
323
|
});
|
|
323
324
|
}
|
|
324
|
-
|
|
325
|
+
if (manifestJson)
|
|
326
|
+
return parseJsonObject(manifestJson);
|
|
327
|
+
const filePath = resolve(manifestPath ?? "");
|
|
328
|
+
let content;
|
|
329
|
+
try {
|
|
330
|
+
content = readFileSync(filePath, "utf8");
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
const notFound = error?.code === "ENOENT";
|
|
334
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
335
|
+
throw new OxygenError("invalid_request", notFound
|
|
336
|
+
? `Manifest file not found: ${filePath}. Pass --manifest <path.json> or --manifest-json '<json>'. See ${CUSTOM_HTTP_MANIFEST_DOCS_URL}`
|
|
337
|
+
: `Could not read manifest file ${filePath}: ${reason}. See ${CUSTOM_HTTP_MANIFEST_DOCS_URL}`, { details: { path: filePath }, exitCode: 1 });
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
return parseJsonObject(content);
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
if (error instanceof OxygenError) {
|
|
344
|
+
throw new OxygenError(error.code, `Manifest file ${filePath} is invalid: ${error.message} See ${CUSTOM_HTTP_MANIFEST_DOCS_URL}`, {
|
|
345
|
+
details: { path: filePath, ...(isRecord(error.details) ? error.details : {}) },
|
|
346
|
+
exitCode: error.exitCode,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
325
351
|
}
|
|
326
352
|
const CUSTOM_INTEGRATION_SECRET_KEYS = ["API_KEY", "TOKEN", "PASSWORD", "SECRET"];
|
|
327
353
|
function readCustomIntegrationSecret(options) {
|
|
328
|
-
const apiKey = readOption(options.apiKey);
|
|
329
354
|
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
355
|
if (!secretsFile) {
|
|
338
|
-
throw new OxygenError("invalid_request", "Pass --
|
|
356
|
+
throw new OxygenError("invalid_request", "Pass --secrets-file.", {
|
|
339
357
|
exitCode: 1,
|
|
340
358
|
});
|
|
341
359
|
}
|
|
@@ -627,6 +645,7 @@ function buildCrmRelationshipUpsertBody(object, rowId, options) {
|
|
|
627
645
|
...(readOption(options.targetObject) ? { object: readOption(options.targetObject) } : {}),
|
|
628
646
|
row_id: options.targetRowId,
|
|
629
647
|
},
|
|
648
|
+
...(readOption(options.role) ? { role: readOption(options.role) } : {}),
|
|
630
649
|
mode: resolveCrmSetupMode(options),
|
|
631
650
|
};
|
|
632
651
|
}
|
|
@@ -1158,11 +1177,13 @@ export function createProgram() {
|
|
|
1158
1177
|
.option("--body <body>", "What you were doing, what happened, and what you tried.")
|
|
1159
1178
|
.option("--severity <severity>", "low | normal | high. Defaults to normal.")
|
|
1160
1179
|
.option("--category <category>", "Optional category label.")
|
|
1180
|
+
.option("--source <source>", "Where the ticket originated. Only slack is supported today.")
|
|
1161
1181
|
.option("--operation <operation>", "The operation/tool that failed, e.g. oxygen_columns_run.")
|
|
1162
1182
|
.option("--error-code <code>", "The error envelope code you received.")
|
|
1163
1183
|
.option("--run-ids <ids>", "Comma-separated run ids to attach.")
|
|
1164
1184
|
.option("--table-ids <ids>", "Comma-separated table ids to attach.")
|
|
1165
1185
|
.option("--deep-links <urls>", "Comma-separated https://oxygen-agent.com/... links.")
|
|
1186
|
+
.option("--slack-permalink <url>", "Slack message permalink (https://*.slack.com/...) to attach as context.")
|
|
1166
1187
|
.option("--json", "Print a JSON envelope.")
|
|
1167
1188
|
.action(async (options) => {
|
|
1168
1189
|
await handleAsyncAction("support ticket create", options, () => requestOxygen("/api/cli/support/tickets", {
|
|
@@ -1244,6 +1265,30 @@ export function createProgram() {
|
|
|
1244
1265
|
method: "POST",
|
|
1245
1266
|
body: { resolution: readOption(options.resolution) },
|
|
1246
1267
|
}));
|
|
1268
|
+
}))
|
|
1269
|
+
.addCommand(new Command("workflow")
|
|
1270
|
+
.description("Track verify/plan/draft triage progress on a support ticket (staff only).")
|
|
1271
|
+
.argument("<ticketId>", "Ticket UUID.")
|
|
1272
|
+
.option("--verify-status <status>", "Verify step: pending | in_progress | done | blocked | skipped.")
|
|
1273
|
+
.option("--verify-notes <text>", "Verification notes. Pass an empty string to clear.")
|
|
1274
|
+
.option("--plan-status <status>", "Plan step: pending | in_progress | done | blocked | skipped.")
|
|
1275
|
+
.option("--plan <text>", "Remediation plan. Pass an empty string to clear.")
|
|
1276
|
+
.option("--draft-status <status>", "Draft step: pending | in_progress | done | blocked | skipped.")
|
|
1277
|
+
.option("--draft <text>", "Draft reply to the opener. Pass an empty string to clear.")
|
|
1278
|
+
.option("--json", "Print a JSON envelope.")
|
|
1279
|
+
.action(async (ticketId, options) => {
|
|
1280
|
+
await handleSupportAdminWorkflowAction(ticketId, options);
|
|
1281
|
+
}))
|
|
1282
|
+
.addCommand(new Command("update")
|
|
1283
|
+
.description("Change a support ticket's status or assignee, with an optional staff note (staff only).")
|
|
1284
|
+
.argument("<ticketId>", "Ticket UUID.")
|
|
1285
|
+
.option("--status <status>", "open | triaging | waiting_on_user | closed. Use the resolve command to resolve.")
|
|
1286
|
+
.option("--assign <email>", "Assign the ticket to a staff email.")
|
|
1287
|
+
.option("--unassign", "Clear the ticket assignment.")
|
|
1288
|
+
.option("--note <text>", "Append a staff message to the ticket thread.")
|
|
1289
|
+
.option("--json", "Print a JSON envelope.")
|
|
1290
|
+
.action(async (ticketId, options) => {
|
|
1291
|
+
await handleSupportAdminUpdateAction(ticketId, options);
|
|
1247
1292
|
})));
|
|
1248
1293
|
program
|
|
1249
1294
|
.command("feedback")
|
|
@@ -1610,6 +1655,7 @@ export function createProgram() {
|
|
|
1610
1655
|
.requiredOption("--relationship <slug>", "Relationship slug from the source object, such as team or company.")
|
|
1611
1656
|
.option("--target-object <object>", "Target CRM object slug. Optional when the relationship has one target.")
|
|
1612
1657
|
.requiredOption("--target-row-id <row_id>", "Target CRM record row id.")
|
|
1658
|
+
.option("--role <role>", "Buying-committee role for a deals.stakeholders link: champion, economic_buyer, decision_maker, influencer, or end_user.")
|
|
1613
1659
|
.option("--dry-run", "Preview the relationship write without changing edges.")
|
|
1614
1660
|
.option("--live", "Apply the relationship write. Default is dry-run.")
|
|
1615
1661
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -3222,9 +3268,11 @@ export function createProgram() {
|
|
|
3222
3268
|
.requiredOption("--table <table>", "Table id or slug.")
|
|
3223
3269
|
.option("--status <status>", "Filter by active, queued, running, paused, completed, completed_with_errors, failed, canceling, or canceled. Defaults to active.")
|
|
3224
3270
|
.option("--limit <n>", "Maximum runs to return. Defaults to 20.")
|
|
3271
|
+
.option("--format <format>", "Format run history as json, jsonl, csv, or table (same serializer as `tables export`). Omit to keep the default envelope output.")
|
|
3272
|
+
.option("--output <path>", "Write the formatted run history to a file instead of embedding it in the response.")
|
|
3225
3273
|
.option("--json", "Print a JSON envelope.")
|
|
3226
3274
|
.action(async (options) => {
|
|
3227
|
-
await handleAsyncAction("table-runs list", options, () =>
|
|
3275
|
+
await handleAsyncAction("table-runs list", options, () => listTableRuns(options));
|
|
3228
3276
|
}))
|
|
3229
3277
|
.addCommand(new Command("get")
|
|
3230
3278
|
.description("Get one durable table action run.")
|
|
@@ -3835,8 +3883,8 @@ export function createProgram() {
|
|
|
3835
3883
|
await handleAsyncAction("billing balance", options, () => requestOxygen("/api/cli/billing/balance"));
|
|
3836
3884
|
}))
|
|
3837
3885
|
.addCommand(new Command("usage")
|
|
3838
|
-
.description("Show credit ledger events
|
|
3839
|
-
.option("--meter <meter>", "credits or
|
|
3886
|
+
.description("Show credit ledger events, automation action usage, or implied LinkedIn seat COGS plus a RECOMMENDED per-account price preview (provider_seats).")
|
|
3887
|
+
.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.")
|
|
3840
3888
|
.option("--days <n>", "Lookback window in days. Defaults to 30.")
|
|
3841
3889
|
.option("--from <iso>", "Only include ledger events at or after this ISO timestamp.")
|
|
3842
3890
|
.option("--to <iso>", "Only include ledger events at or before this ISO timestamp.")
|
|
@@ -3983,6 +4031,116 @@ export function createProgram() {
|
|
|
3983
4031
|
method: "DELETE",
|
|
3984
4032
|
}));
|
|
3985
4033
|
}));
|
|
4034
|
+
program.addCommand(new Command("egress")
|
|
4035
|
+
.description("Managed mailbox-egress IP pool: status, inventory, dedicated-IP add-on, and rotation.")
|
|
4036
|
+
.addCommand(new Command("status")
|
|
4037
|
+
.description("Show egress mode, this org's open mailbox->IP assignments, and IP health counts. Read-only, 0 credits.")
|
|
4038
|
+
.option("--json", "Print a JSON envelope.")
|
|
4039
|
+
.action(async (options) => {
|
|
4040
|
+
await handleAsyncAction("egress status", options, () => requestOxygen("/api/cli/egress"));
|
|
4041
|
+
}))
|
|
4042
|
+
.addCommand(new Command("ips")
|
|
4043
|
+
.description("List the egress IPs this org may send through (shared pool for this env + any dedicated IP). Read-only.")
|
|
4044
|
+
.option("--json", "Print a JSON envelope.")
|
|
4045
|
+
.action(async (options) => {
|
|
4046
|
+
await handleAsyncAction("egress ips", options, () => requestOxygen("/api/cli/egress?view=ips"));
|
|
4047
|
+
}))
|
|
4048
|
+
.addCommand(new Command("dedicated")
|
|
4049
|
+
.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.")
|
|
4050
|
+
.addCommand(new Command("request")
|
|
4051
|
+
.description("Preview the dedicated-egress-IP add-on ($400/mo). PREVIEW ONLY — never charges; checkout is human via Stripe.")
|
|
4052
|
+
.option("--json", "Print a JSON envelope.")
|
|
4053
|
+
.action(async (options) => {
|
|
4054
|
+
await handleAsyncAction("egress dedicated request", options, () => requestOxygen("/api/cli/egress/dedicated", { method: "POST", body: {} }));
|
|
4055
|
+
}))
|
|
4056
|
+
.addCommand(new Command("status")
|
|
4057
|
+
.description("Show this org's dedicated-egress-IP add-on state (status, quantity, activation, price). Read-only, 0 credits.")
|
|
4058
|
+
.option("--json", "Print a JSON envelope.")
|
|
4059
|
+
.action(async (options) => {
|
|
4060
|
+
await handleAsyncAction("egress dedicated status", options, () => requestOxygen("/api/cli/egress/dedicated"));
|
|
4061
|
+
})))
|
|
4062
|
+
.addCommand(new Command("rotate")
|
|
4063
|
+
.description("Rotate a mailbox's egress IP. Approval-gated: without --approve it previews and makes no change.")
|
|
4064
|
+
.requiredOption("--mailbox <id>", "Mailbox id to rotate.")
|
|
4065
|
+
.option("--vendor <vendor>", "Egress vendor (pingproxies|decodo). Defaults to the current IP's vendor.")
|
|
4066
|
+
.option("--approve", "Execute the rotation (paid vendor action). Omit for a no-side-effect preview.")
|
|
4067
|
+
.option("--json", "Print a JSON envelope.")
|
|
4068
|
+
.action(async (options) => {
|
|
4069
|
+
await handleAsyncAction("egress rotate", options, () => requestOxygen("/api/cli/egress/rotate", {
|
|
4070
|
+
method: "POST",
|
|
4071
|
+
body: {
|
|
4072
|
+
mailbox_id: readOption(options.mailbox),
|
|
4073
|
+
...(readOption(options.vendor) ? { vendor: readOption(options.vendor) } : {}),
|
|
4074
|
+
...(options.approve ? { approve: true } : {}),
|
|
4075
|
+
},
|
|
4076
|
+
}));
|
|
4077
|
+
})));
|
|
4078
|
+
program
|
|
4079
|
+
.command("directory")
|
|
4080
|
+
.description("Manage this organization's public OXYGEN agency directory listing.")
|
|
4081
|
+
.addCommand(new Command("get")
|
|
4082
|
+
.description("Read the listing, its publish state, and its public/settings deep-links.")
|
|
4083
|
+
.option("--json", "Print a JSON envelope.")
|
|
4084
|
+
.action(async (options) => {
|
|
4085
|
+
await handleAsyncAction("directory get", options, () => requestOxygen("/api/cli/directory/listing"));
|
|
4086
|
+
}))
|
|
4087
|
+
.addCommand(new Command("update")
|
|
4088
|
+
.description("Update listing profile fields. Only invited organizations can edit.")
|
|
4089
|
+
.option("--name <text>", "Agency display name.")
|
|
4090
|
+
.option("--slug <slug>", "Public URL slug (lowercase letters, digits, dashes).")
|
|
4091
|
+
.option("--logo-url <url>", "Logo image URL.")
|
|
4092
|
+
.option("--tagline <text>", "One-line pitch shown on the directory card.")
|
|
4093
|
+
.option("--description <text>", "Full profile description (plain text; blank lines separate paragraphs).")
|
|
4094
|
+
.option("--description-file <path>", "Path to a plain-text file with the description.")
|
|
4095
|
+
.option("--website <url>", "Company website URL.")
|
|
4096
|
+
.option("--company-linkedin <url>", "Company LinkedIn page URL.")
|
|
4097
|
+
.option("--founders-json <json>", "JSON array of founding-team members: [{\"name\", \"title\", \"linkedin_url\"}].")
|
|
4098
|
+
.option("--booking-url <url>", "Book-a-call URL (Cal.com, Calendly, ...).")
|
|
4099
|
+
.option("--contact-email <email>", "Public contact email.")
|
|
4100
|
+
.option("--logo-file <path>", "Upload a local png/jpeg/webp/svg logo instead of passing --logo-url.")
|
|
4101
|
+
.option("--services <csv>", `Comma-separated services from the curated set: ${AGENCY_DIRECTORY_SERVICES.join(", ")}.`)
|
|
4102
|
+
.option("--regions <csv>", `Comma-separated regions from the curated set: ${AGENCY_DIRECTORY_REGIONS.join(", ")}.`)
|
|
4103
|
+
.option("--profile-json <json>", "JSON object with any listing fields; flags override it. Use null values to clear fields.")
|
|
4104
|
+
.option("--json", "Print a JSON envelope.")
|
|
4105
|
+
.action(async (options) => {
|
|
4106
|
+
await handleAsyncAction("directory update", options, async () => {
|
|
4107
|
+
const logoPath = readOption(options.logoFile);
|
|
4108
|
+
const uploaded = logoPath ? await uploadDirectoryLogo(logoPath) : null;
|
|
4109
|
+
const profile = await buildDirectoryProfileBody(options);
|
|
4110
|
+
if (Object.keys(profile).length === 0 && uploaded)
|
|
4111
|
+
return uploaded;
|
|
4112
|
+
return requestOxygen("/api/cli/directory/listing/update", {
|
|
4113
|
+
method: "POST",
|
|
4114
|
+
body: { profile },
|
|
4115
|
+
});
|
|
4116
|
+
});
|
|
4117
|
+
}))
|
|
4118
|
+
.addCommand(new Command("publish")
|
|
4119
|
+
.description("Publish the listing to the public directory at https://oxygen-agent.com/agencies.")
|
|
4120
|
+
.option("--json", "Print a JSON envelope.")
|
|
4121
|
+
.action(async (options) => {
|
|
4122
|
+
await handleAsyncAction("directory publish", options, () => requestOxygen("/api/cli/directory/listing/publish", { method: "POST", body: {} }));
|
|
4123
|
+
}))
|
|
4124
|
+
.addCommand(new Command("unpublish")
|
|
4125
|
+
.description("Remove the listing from the public directory (keeps the draft editable).")
|
|
4126
|
+
.option("--json", "Print a JSON envelope.")
|
|
4127
|
+
.action(async (options) => {
|
|
4128
|
+
await handleAsyncAction("directory unpublish", options, () => requestOxygen("/api/cli/directory/listing/unpublish", { method: "POST", body: {} }));
|
|
4129
|
+
}))
|
|
4130
|
+
.addCommand(new Command("apply")
|
|
4131
|
+
.description("Apply to be listed in the public agency directory (reviewed by the OXYGEN team).")
|
|
4132
|
+
.option("--message <text>", "What you do, who you serve, and how you use OXYGEN for client work.")
|
|
4133
|
+
.option("--website <url>", "Your agency website URL.")
|
|
4134
|
+
.option("--json", "Print a JSON envelope.")
|
|
4135
|
+
.action(async (options) => {
|
|
4136
|
+
await handleAsyncAction("directory apply", options, () => requestOxygen("/api/cli/directory/apply", {
|
|
4137
|
+
method: "POST",
|
|
4138
|
+
body: {
|
|
4139
|
+
...(readOption(options.message) ? { message: readOption(options.message) } : {}),
|
|
4140
|
+
...(readOption(options.website) ? { website: readOption(options.website) } : {}),
|
|
4141
|
+
},
|
|
4142
|
+
}));
|
|
4143
|
+
}));
|
|
3986
4144
|
program
|
|
3987
4145
|
.command("admin")
|
|
3988
4146
|
.description("Staff-only commands.")
|
|
@@ -4004,7 +4162,55 @@ export function createProgram() {
|
|
|
4004
4162
|
const suffix = params.toString() ? `?${params.toString()}` : "";
|
|
4005
4163
|
return requestOxygen(`/api/cli/admin/costs${suffix}`);
|
|
4006
4164
|
});
|
|
4007
|
-
}))
|
|
4165
|
+
}))
|
|
4166
|
+
.addCommand(new Command("directory")
|
|
4167
|
+
.description("Curate the public agency directory (invite, prefill, delist). Staff only.")
|
|
4168
|
+
.addCommand(new Command("list")
|
|
4169
|
+
.description("List every agency directory listing across all organizations.")
|
|
4170
|
+
.option("--json", "Print a JSON envelope.")
|
|
4171
|
+
.action(async (options) => {
|
|
4172
|
+
await handleAsyncAction("admin directory list", options, () => requestOxygen("/api/cli/admin/directory"));
|
|
4173
|
+
}))
|
|
4174
|
+
.addCommand(new Command("enable")
|
|
4175
|
+
.description("Invite an organization to the directory, optionally prefilling its profile.")
|
|
4176
|
+
.argument("<organization>", "Organization id, slug, or Clerk org id.")
|
|
4177
|
+
.option("--name <text>", "Agency display name (defaults to the organization name).")
|
|
4178
|
+
.option("--slug <slug>", "Public URL slug (defaults to a slugified name).")
|
|
4179
|
+
.option("--logo-url <url>", "Logo image URL.")
|
|
4180
|
+
.option("--tagline <text>", "One-line pitch shown on the directory card.")
|
|
4181
|
+
.option("--description <text>", "Full profile description (plain text; blank lines separate paragraphs).")
|
|
4182
|
+
.option("--description-file <path>", "Path to a plain-text file with the description.")
|
|
4183
|
+
.option("--website <url>", "Company website URL.")
|
|
4184
|
+
.option("--company-linkedin <url>", "Company LinkedIn page URL.")
|
|
4185
|
+
.option("--founders-json <json>", "JSON array of founding-team members: [{\"name\", \"title\", \"linkedin_url\"}].")
|
|
4186
|
+
.option("--booking-url <url>", "Book-a-call URL (Cal.com, Calendly, ...).")
|
|
4187
|
+
.option("--contact-email <email>", "Public contact email.")
|
|
4188
|
+
.option("--services <csv>", `Comma-separated services from the curated set: ${AGENCY_DIRECTORY_SERVICES.join(", ")}.`)
|
|
4189
|
+
.option("--regions <csv>", `Comma-separated regions from the curated set: ${AGENCY_DIRECTORY_REGIONS.join(", ")}.`)
|
|
4190
|
+
.option("--featured", "Pin the listing to the top of the public directory.")
|
|
4191
|
+
.option("--profile-json <json>", "JSON object with any listing fields; flags override it.")
|
|
4192
|
+
.option("--json", "Print a JSON envelope.")
|
|
4193
|
+
.action(async (organization, options) => {
|
|
4194
|
+
await handleAsyncAction("admin directory enable", options, async () => {
|
|
4195
|
+
const profile = await buildDirectoryProfileBody(options);
|
|
4196
|
+
if (options.featured)
|
|
4197
|
+
profile.featured = true;
|
|
4198
|
+
return requestOxygen("/api/cli/admin/directory/enable", {
|
|
4199
|
+
method: "POST",
|
|
4200
|
+
body: { organization, profile },
|
|
4201
|
+
});
|
|
4202
|
+
});
|
|
4203
|
+
}))
|
|
4204
|
+
.addCommand(new Command("delist")
|
|
4205
|
+
.description("Remove an organization's listing from the public directory (kill switch).")
|
|
4206
|
+
.argument("<organization>", "Organization id, slug, or Clerk org id.")
|
|
4207
|
+
.option("--json", "Print a JSON envelope.")
|
|
4208
|
+
.action(async (organization, options) => {
|
|
4209
|
+
await handleAsyncAction("admin directory delist", options, () => requestOxygen("/api/cli/admin/directory/delist", {
|
|
4210
|
+
method: "POST",
|
|
4211
|
+
body: { organization },
|
|
4212
|
+
}));
|
|
4213
|
+
})));
|
|
4008
4214
|
program
|
|
4009
4215
|
.command("signup-leads")
|
|
4010
4216
|
.description("Signup lead delivery queue commands.")
|
|
@@ -4275,7 +4481,6 @@ export function createProgram() {
|
|
|
4275
4481
|
.addCommand(new Command("connect")
|
|
4276
4482
|
.description("Attach the credential (bearer token, API key, or basic password) for an applied custom HTTP integration.")
|
|
4277
4483
|
.argument("<slug>", "Custom integration slug from the applied manifest.")
|
|
4278
|
-
.option("--api-key <value>", "Credential value. Prefer --secrets-file to keep it out of shell history.")
|
|
4279
4484
|
.option("--secrets-file <path>", "Path to a .env-style file holding the credential (a single entry, or one named API_KEY/TOKEN/PASSWORD/SECRET).")
|
|
4280
4485
|
.option("--json", "Print a JSON envelope.")
|
|
4281
4486
|
.action(async (slug, options) => {
|
|
@@ -4765,20 +4970,29 @@ export function createProgram() {
|
|
|
4765
4970
|
const suffix = params.toString();
|
|
4766
4971
|
return requestOxygen(`/api/cli/senders${suffix ? `?${suffix}` : ""}`);
|
|
4767
4972
|
});
|
|
4973
|
+
}))
|
|
4974
|
+
.addCommand(new Command("checkpoints")
|
|
4975
|
+
.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>`.")
|
|
4976
|
+
.option("--json", "Print a JSON envelope.")
|
|
4977
|
+
.action(async (options) => {
|
|
4978
|
+
await handleAsyncAction("senders checkpoints", options, () => requestOxygen("/api/cli/senders/checkpoints"));
|
|
4768
4979
|
}))
|
|
4769
4980
|
.addCommand(new Command("connect")
|
|
4770
|
-
.description("Get a Unipile hosted-auth URL to connect a new LinkedIn account (or reconnect with --reconnect). Open
|
|
4981
|
+
.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.")
|
|
4771
4982
|
.option("--reconnect <connection_id>", "Reconnect an existing connection instead of creating a new one. Accepts a connection id.")
|
|
4772
4983
|
.option("--sales-nav", "Request Classic + Sales Navigator access during Unipile hosted authentication.")
|
|
4984
|
+
.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.")
|
|
4773
4985
|
.option("--json", "Print a JSON envelope.")
|
|
4774
4986
|
.action(async (options) => {
|
|
4775
4987
|
await handleAsyncAction("senders connect", options, () => {
|
|
4776
4988
|
const reconnect = readOption(options.reconnect);
|
|
4989
|
+
const count = readPositiveInt(options.count);
|
|
4777
4990
|
return requestOxygen("/api/cli/senders/connect", {
|
|
4778
4991
|
method: "POST",
|
|
4779
4992
|
body: {
|
|
4780
4993
|
...(reconnect ? { reconnect_connection_id: reconnect } : {}),
|
|
4781
4994
|
...(options.salesNav ? { sales_nav: true } : {}),
|
|
4995
|
+
...(count ? { count } : {}),
|
|
4782
4996
|
},
|
|
4783
4997
|
});
|
|
4784
4998
|
});
|
|
@@ -4877,6 +5091,9 @@ export function createProgram() {
|
|
|
4877
5091
|
.option("--warmup-restart", "Start (or restart) the warm-up ramp now — gradually raises this account's invite + message caps to full over ~2 weeks.")
|
|
4878
5092
|
.option("--warmup-disable", "Turn off warm-up for this account (treat it as already warm and use its full configured caps).")
|
|
4879
5093
|
.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.")
|
|
5094
|
+
.option("--warmup-preset <preset>", "Warm-up ramp curve: conservative (~3 weeks), standard (~2 weeks, default), or fast (~1 week for an aged account).")
|
|
5095
|
+
.option("--randomize-caps", "Randomise this account's daily send caps to 80-100% of configured so volume looks human day to day.")
|
|
5096
|
+
.option("--no-randomize-caps", "Turn off randomised daily caps (use the exact configured caps every day).")
|
|
4880
5097
|
.option("--json", "Print a JSON envelope.")
|
|
4881
5098
|
.action(async (id, options) => {
|
|
4882
5099
|
await handleAsyncAction("senders limits set", options, () => {
|
|
@@ -4995,6 +5212,77 @@ export function createProgram() {
|
|
|
4995
5212
|
return requestOxygen(`/api/cli/linkedin/viewers/status${suffix ? `?${suffix}` : ""}`);
|
|
4996
5213
|
});
|
|
4997
5214
|
})));
|
|
5215
|
+
program.addCommand(new Command("posts")
|
|
5216
|
+
.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.")
|
|
5217
|
+
.addCommand(new Command("get")
|
|
5218
|
+
.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.")
|
|
5219
|
+
.requiredOption("--post <id>", "Numeric activity id, activity URL, or composite social_id.")
|
|
5220
|
+
.option("--account <ref>", "Sender account to read through (sender id, connection id, or Unipile account id). Omit for the org default.")
|
|
5221
|
+
.option("--json", "Print a JSON envelope.")
|
|
5222
|
+
.action(async (options) => {
|
|
5223
|
+
await handleAsyncAction("posts get", options, () => {
|
|
5224
|
+
const params = new URLSearchParams();
|
|
5225
|
+
params.set("post", readOption(options.post) ?? "");
|
|
5226
|
+
const account = readOption(options.account);
|
|
5227
|
+
if (account)
|
|
5228
|
+
params.set("account", account);
|
|
5229
|
+
return requestOxygen(`/api/cli/linkedin/posts?${params.toString()}`);
|
|
5230
|
+
});
|
|
5231
|
+
}))
|
|
5232
|
+
.addCommand(new Command("comments")
|
|
5233
|
+
.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.")
|
|
5234
|
+
.requiredOption("--post <social_id>", "Composite post social_id from `posts get`.")
|
|
5235
|
+
.option("--comment-id <id>", "List replies to this comment instead of top-level comments.")
|
|
5236
|
+
.option("--sort-by <sort>", "Comment sort order (provider-defined, e.g. recent|relevant).")
|
|
5237
|
+
.option("--cursor <cursor>", "Pagination cursor from a previous page.")
|
|
5238
|
+
.option("--limit <n>", "Max comments to return this page.")
|
|
5239
|
+
.option("--account <ref>", "Sender account to read through. Omit for the org default.")
|
|
5240
|
+
.option("--json", "Print a JSON envelope.")
|
|
5241
|
+
.action(async (options) => {
|
|
5242
|
+
await handleAsyncAction("posts comments", options, () => requestOxygen(`/api/cli/linkedin/posts/comments?${buildPostEngagementQuery(options)}`));
|
|
5243
|
+
}))
|
|
5244
|
+
.addCommand(new Command("reactions")
|
|
5245
|
+
.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.")
|
|
5246
|
+
.requiredOption("--post <social_id>", "Composite post social_id from `posts get`.")
|
|
5247
|
+
.option("--comment-id <id>", "List reactions to this comment instead of the post.")
|
|
5248
|
+
.option("--cursor <cursor>", "Pagination cursor from a previous page.")
|
|
5249
|
+
.option("--limit <n>", "Max reactions to return this page.")
|
|
5250
|
+
.option("--account <ref>", "Sender account to read through. Omit for the org default.")
|
|
5251
|
+
.option("--json", "Print a JSON envelope.")
|
|
5252
|
+
.action(async (options) => {
|
|
5253
|
+
await handleAsyncAction("posts reactions", options, () => requestOxygen(`/api/cli/linkedin/posts/reactions?${buildPostEngagementQuery(options)}`));
|
|
5254
|
+
}))
|
|
5255
|
+
.addCommand(new Command("create")
|
|
5256
|
+
.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.)")
|
|
5257
|
+
.option("--text <text>", "Post body text.")
|
|
5258
|
+
.option("--text-file <path>", "Read the post body from a file (alternative to --text).")
|
|
5259
|
+
.option("--account <ref>", "Sender account to post from. Omit for the org default.")
|
|
5260
|
+
.option("--as-organization <id>", "Publish as a company page the account administers (advanced; may require raw-route support).")
|
|
5261
|
+
.option("--external-link <url>", "Attach an external link to the post.")
|
|
5262
|
+
.option("--approved", "Publish for real. Without this flag, prints a preview only.")
|
|
5263
|
+
.option("--json", "Print a JSON envelope.")
|
|
5264
|
+
.action(async (options) => {
|
|
5265
|
+
await handleAsyncAction("posts create", options, () => {
|
|
5266
|
+
const inlineText = readOption(options.text);
|
|
5267
|
+
const textFile = readOption(options.textFile);
|
|
5268
|
+
const text = inlineText ?? (textFile ? readFileSync(resolve(textFile), "utf8") : undefined);
|
|
5269
|
+
if (!text || !text.trim())
|
|
5270
|
+
throw new Error("--text or --text-file is required to publish a post.");
|
|
5271
|
+
const account = readOption(options.account);
|
|
5272
|
+
const asOrganization = readOption(options.asOrganization);
|
|
5273
|
+
const externalLink = readOption(options.externalLink);
|
|
5274
|
+
return requestOxygen("/api/cli/linkedin/posts", {
|
|
5275
|
+
method: "POST",
|
|
5276
|
+
body: {
|
|
5277
|
+
text,
|
|
5278
|
+
...(account ? { account } : {}),
|
|
5279
|
+
...(asOrganization ? { as_organization: asOrganization } : {}),
|
|
5280
|
+
...(externalLink ? { external_link: externalLink } : {}),
|
|
5281
|
+
...(options.approved ? { approved: true } : {}),
|
|
5282
|
+
},
|
|
5283
|
+
});
|
|
5284
|
+
});
|
|
5285
|
+
})));
|
|
4998
5286
|
program.addCommand(new Command("engagement")
|
|
4999
5287
|
.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.")
|
|
5000
5288
|
.addCommand(new Command("harvest")
|
|
@@ -5112,7 +5400,100 @@ export function createProgram() {
|
|
|
5112
5400
|
},
|
|
5113
5401
|
});
|
|
5114
5402
|
});
|
|
5115
|
-
}))
|
|
5403
|
+
}))
|
|
5404
|
+
.addCommand(new Command("watch")
|
|
5405
|
+
.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.")
|
|
5406
|
+
.addCommand(new Command("create")
|
|
5407
|
+
.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.")
|
|
5408
|
+
.requiredOption("--kind <kind>", "post | profile_viewers.")
|
|
5409
|
+
.requiredOption("--account <ref>", "Reading LinkedIn sender (sender id, connection id, or Unipile account id).")
|
|
5410
|
+
.option("--post <social_id>", "Composite post social_id from `oxygen posts get` (required for --kind post).")
|
|
5411
|
+
.option("--post-url <url>", "Public LinkedIn post URL (required for a cookieless post watch).")
|
|
5412
|
+
.option("--source <source>", "cookieless | unipile. Defaults to cookieless for post, unipile for profile_viewers.")
|
|
5413
|
+
.option("--table <id_or_slug>", "Existing table to land harvested people into. Omit to create one.")
|
|
5414
|
+
.option("--sequence <id_or_slug>", "Sequence to auto-enroll harvested people into (required with --auto-enroll).")
|
|
5415
|
+
.option("--auto-enroll", "Auto-enroll newly harvested people into --sequence under the standing approval.")
|
|
5416
|
+
.option("--max-credits-per-cycle <credits>", "Standing per-cycle managed-spend cap (required for --auto-enroll and cookieless).")
|
|
5417
|
+
.option("--max-enrolls-per-day <n>", "Cap auto-enrollments per day.")
|
|
5418
|
+
.option("--recurrence <recurrence>", "once | every_6h | daily | weekly (default once).")
|
|
5419
|
+
.option("--json", "Print a JSON envelope.")
|
|
5420
|
+
.action(async (options) => {
|
|
5421
|
+
await handleAsyncAction("engagement watch create", options, () => {
|
|
5422
|
+
const kind = readOption(options.kind);
|
|
5423
|
+
const account = readOption(options.account);
|
|
5424
|
+
const post = readOption(options.post);
|
|
5425
|
+
const postUrl = readOption(options.postUrl);
|
|
5426
|
+
const source = readOption(options.source);
|
|
5427
|
+
const table = readOption(options.table);
|
|
5428
|
+
const sequence = readOption(options.sequence);
|
|
5429
|
+
const maxCreditsPerCycle = readPositiveNumber(options.maxCreditsPerCycle);
|
|
5430
|
+
const maxEnrollsPerDay = readPositiveNumber(options.maxEnrollsPerDay);
|
|
5431
|
+
const recurrence = readOption(options.recurrence);
|
|
5432
|
+
return requestOxygen("/api/cli/linkedin/engagement/watches", {
|
|
5433
|
+
method: "POST",
|
|
5434
|
+
body: {
|
|
5435
|
+
...(kind ? { kind } : {}),
|
|
5436
|
+
...(account ? { account } : {}),
|
|
5437
|
+
...(post ? { post } : {}),
|
|
5438
|
+
...(postUrl ? { post_url: postUrl } : {}),
|
|
5439
|
+
...(source ? { source } : {}),
|
|
5440
|
+
...(table ? { table } : {}),
|
|
5441
|
+
...(sequence ? { sequence } : {}),
|
|
5442
|
+
...(options.autoEnroll ? { auto_enroll: true } : {}),
|
|
5443
|
+
...(maxCreditsPerCycle !== undefined ? { max_credits_per_cycle: maxCreditsPerCycle } : {}),
|
|
5444
|
+
...(maxEnrollsPerDay !== undefined ? { max_enrolls_per_day: maxEnrollsPerDay } : {}),
|
|
5445
|
+
...(recurrence ? { recurrence } : {}),
|
|
5446
|
+
},
|
|
5447
|
+
});
|
|
5448
|
+
});
|
|
5449
|
+
}))
|
|
5450
|
+
.addCommand(new Command("list")
|
|
5451
|
+
.description("List the org's engagement watches (status, kind, sender, sequence, per-day enroll count) with a table deep-link.")
|
|
5452
|
+
.option("--status <status>", "Filter by status: active | paused | exhausted | completed.")
|
|
5453
|
+
.option("--kind <kind>", "Filter by kind.")
|
|
5454
|
+
.option("--json", "Print a JSON envelope.")
|
|
5455
|
+
.action(async (options) => {
|
|
5456
|
+
await handleAsyncAction("engagement watch list", options, () => {
|
|
5457
|
+
const status = readOption(options.status);
|
|
5458
|
+
const kind = readOption(options.kind);
|
|
5459
|
+
const params = new URLSearchParams();
|
|
5460
|
+
if (status)
|
|
5461
|
+
params.set("status", status);
|
|
5462
|
+
if (kind)
|
|
5463
|
+
params.set("kind", kind);
|
|
5464
|
+
const suffix = params.toString();
|
|
5465
|
+
return requestOxygen(`/api/cli/linkedin/engagement/watches${suffix ? `?${suffix}` : ""}`);
|
|
5466
|
+
});
|
|
5467
|
+
}))
|
|
5468
|
+
.addCommand(new Command("pause")
|
|
5469
|
+
.description("Pause a watch (stop materializing its harvest + auto-enrolling).")
|
|
5470
|
+
.argument("<watchId>", "Watch id.")
|
|
5471
|
+
.option("--json", "Print a JSON envelope.")
|
|
5472
|
+
.action(async (watchId, options) => {
|
|
5473
|
+
await handleAsyncAction("engagement watch pause", options, () => requestOxygen(`/api/cli/linkedin/engagement/watches/${encodeURIComponent(watchId)}`, {
|
|
5474
|
+
method: "PATCH",
|
|
5475
|
+
body: { action: "pause" },
|
|
5476
|
+
}));
|
|
5477
|
+
}))
|
|
5478
|
+
.addCommand(new Command("resume")
|
|
5479
|
+
.description("Resume a paused watch.")
|
|
5480
|
+
.argument("<watchId>", "Watch id.")
|
|
5481
|
+
.option("--json", "Print a JSON envelope.")
|
|
5482
|
+
.action(async (watchId, options) => {
|
|
5483
|
+
await handleAsyncAction("engagement watch resume", options, () => requestOxygen(`/api/cli/linkedin/engagement/watches/${encodeURIComponent(watchId)}`, {
|
|
5484
|
+
method: "PATCH",
|
|
5485
|
+
body: { action: "resume" },
|
|
5486
|
+
}));
|
|
5487
|
+
}))
|
|
5488
|
+
.addCommand(new Command("delete")
|
|
5489
|
+
.description("Delete a watch (its harvested table + rows are kept).")
|
|
5490
|
+
.argument("<watchId>", "Watch id.")
|
|
5491
|
+
.option("--json", "Print a JSON envelope.")
|
|
5492
|
+
.action(async (watchId, options) => {
|
|
5493
|
+
await handleAsyncAction("engagement watch delete", options, () => requestOxygen(`/api/cli/linkedin/engagement/watches/${encodeURIComponent(watchId)}`, {
|
|
5494
|
+
method: "DELETE",
|
|
5495
|
+
}));
|
|
5496
|
+
}))));
|
|
5116
5497
|
program.addCommand(new Command("linkedin")
|
|
5117
5498
|
.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.")
|
|
5118
5499
|
.addCommand(new Command("ingestion")
|
|
@@ -5406,12 +5787,14 @@ export function createProgram() {
|
|
|
5406
5787
|
.requiredOption("--text <message>", "Reply text to send.")
|
|
5407
5788
|
.option("--channel <channel>", "Inbox channel: linkedin (default), whatsapp (warm reply), or email.")
|
|
5408
5789
|
.option("--approved", "Approve and send the message. Without this flag, returns a preview only.")
|
|
5409
|
-
.option("--draft-id <id>", "
|
|
5790
|
+
.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.")
|
|
5791
|
+
.option("--attach <url>", "LinkedIn only: attach a file by public URL (image/document). Repeatable, up to 5.", collectRepeatable, [])
|
|
5410
5792
|
.option("--json", "Print a JSON envelope.")
|
|
5411
5793
|
.action(async (conversation, options) => {
|
|
5412
5794
|
await handleAsyncAction("inbox send", options, () => {
|
|
5413
5795
|
const channel = readOption(options.channel);
|
|
5414
5796
|
const draftId = readOption(options.draftId);
|
|
5797
|
+
const attachments = (options.attach ?? []).map((url) => url.trim()).filter(Boolean).map((url) => ({ url }));
|
|
5415
5798
|
return requestOxygen(`/api/cli/inbox/${encodeURIComponent(conversation)}/send`, {
|
|
5416
5799
|
method: "POST",
|
|
5417
5800
|
body: {
|
|
@@ -5419,6 +5802,7 @@ export function createProgram() {
|
|
|
5419
5802
|
...(channel ? { channel } : {}),
|
|
5420
5803
|
...(options.approved ? { approved: true } : {}),
|
|
5421
5804
|
...(draftId ? { draft_id: draftId } : {}),
|
|
5805
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
5422
5806
|
},
|
|
5423
5807
|
});
|
|
5424
5808
|
});
|
|
@@ -5579,32 +5963,51 @@ export function createProgram() {
|
|
|
5579
5963
|
.argument("<name>", "Display name.")
|
|
5580
5964
|
.option("--color <hex>", "Hex color (e.g. #22c55e).")
|
|
5581
5965
|
.option("--bucket <bucket>", "primary (default) or others.")
|
|
5966
|
+
.option("--ai-settable", "Let the AI reply classifier assign this label.")
|
|
5967
|
+
.option("--description <text>", "What the label means / when it applies (used by the AI classifier).")
|
|
5582
5968
|
.option("--json", "Print a JSON envelope.")
|
|
5583
5969
|
.action(async (name, options) => {
|
|
5584
5970
|
await handleAsyncAction("inbox label create", options, () => {
|
|
5585
5971
|
const color = readOption(options.color);
|
|
5586
5972
|
const bucket = readOption(options.bucket);
|
|
5973
|
+
const description = readOption(options.description);
|
|
5587
5974
|
return requestOxygen("/api/cli/inbox/labels", {
|
|
5588
5975
|
method: "POST",
|
|
5589
|
-
body: {
|
|
5976
|
+
body: {
|
|
5977
|
+
name,
|
|
5978
|
+
...(color ? { color } : {}),
|
|
5979
|
+
...(bucket ? { bucket } : {}),
|
|
5980
|
+
...(options.aiSettable ? { ai_settable: true } : {}),
|
|
5981
|
+
...(description ? { description } : {}),
|
|
5982
|
+
},
|
|
5590
5983
|
});
|
|
5591
5984
|
});
|
|
5592
5985
|
}))
|
|
5593
5986
|
.addCommand(new Command("update")
|
|
5594
|
-
.description("Update a status label's name, color, or
|
|
5987
|
+
.description("Update a status label's name, color, bucket, description, or (custom labels only) ai-settable flag.")
|
|
5595
5988
|
.argument("<key>", "The label key.")
|
|
5596
5989
|
.option("--name <name>", "New display name.")
|
|
5597
5990
|
.option("--color <hex>", "New hex color.")
|
|
5598
5991
|
.option("--bucket <bucket>", "New bucket (primary or others).")
|
|
5992
|
+
.option("--ai-settable", "Let the AI classifier assign this label (custom labels only).")
|
|
5993
|
+
.option("--no-ai-settable", "Stop the AI classifier assigning this label (custom labels only).")
|
|
5994
|
+
.option("--description <text>", "What the label means / when it applies (used by the AI classifier).")
|
|
5599
5995
|
.option("--json", "Print a JSON envelope.")
|
|
5600
5996
|
.action(async (key, options) => {
|
|
5601
5997
|
await handleAsyncAction("inbox label update", options, () => {
|
|
5602
5998
|
const name = readOption(options.name);
|
|
5603
5999
|
const color = readOption(options.color);
|
|
5604
6000
|
const bucket = readOption(options.bucket);
|
|
6001
|
+
const description = readOption(options.description);
|
|
5605
6002
|
return requestOxygen(`/api/cli/inbox/labels/${encodeURIComponent(key)}`, {
|
|
5606
6003
|
method: "PATCH",
|
|
5607
|
-
body: {
|
|
6004
|
+
body: {
|
|
6005
|
+
...(name ? { name } : {}),
|
|
6006
|
+
...(color ? { color } : {}),
|
|
6007
|
+
...(bucket ? { bucket } : {}),
|
|
6008
|
+
...(options.aiSettable !== undefined ? { ai_settable: options.aiSettable } : {}),
|
|
6009
|
+
...(description ? { description } : {}),
|
|
6010
|
+
},
|
|
5608
6011
|
});
|
|
5609
6012
|
});
|
|
5610
6013
|
}))
|
|
@@ -5618,9 +6021,10 @@ export function createProgram() {
|
|
|
5618
6021
|
.addCommand(new Command("drafts")
|
|
5619
6022
|
.description("The AI reply-agent draft queue (the approve-before-send review queue).")
|
|
5620
6023
|
.addCommand(new Command("list")
|
|
5621
|
-
.description("List drafts awaiting review (queued + edited by default).")
|
|
6024
|
+
.description("List drafts awaiting review (queued + edited by default), across email + LinkedIn.")
|
|
5622
6025
|
.option("--status <keys>", "Comma-separated draft statuses (queued,edited,sent,rejected).")
|
|
5623
|
-
.option("--
|
|
6026
|
+
.option("--channel <channel>", "Filter to one channel: email or linkedin.")
|
|
6027
|
+
.option("--sequence-id <id>", "Filter to one campaign (sequence) id (email drafts only).")
|
|
5624
6028
|
.option("--limit <n>", "Maximum drafts to return.")
|
|
5625
6029
|
.option("--json", "Print a JSON envelope.")
|
|
5626
6030
|
.action(async (options) => {
|
|
@@ -5629,6 +6033,9 @@ export function createProgram() {
|
|
|
5629
6033
|
const status = readOption(options.status);
|
|
5630
6034
|
if (status)
|
|
5631
6035
|
params.set("status", status);
|
|
6036
|
+
const channel = readOption(options.channel);
|
|
6037
|
+
if (channel)
|
|
6038
|
+
params.set("channel", channel);
|
|
5632
6039
|
const sequenceId = readOption(options.sequenceId);
|
|
5633
6040
|
if (sequenceId)
|
|
5634
6041
|
params.set("sequence_id", sequenceId);
|
|
@@ -5682,6 +6089,7 @@ export function createProgram() {
|
|
|
5682
6089
|
.option("--signature <text>", "Signature appended to drafts.")
|
|
5683
6090
|
.option("--target-statuses <keys>", "Comma-separated statuses to draft for (empty = all draftable).")
|
|
5684
6091
|
.option("--target-sequence-ids <ids>", "Comma-separated campaign ids to draft for (empty = all).")
|
|
6092
|
+
.option("--channels <list>", "Comma-separated channels to draft for: a non-empty subset of email, linkedin (default email). LinkedIn drafts are short + signature-free.")
|
|
5685
6093
|
.option("--max-drafts-per-day <n>", "Per-day draft cap.")
|
|
5686
6094
|
.option("--json", "Print a JSON envelope.")
|
|
5687
6095
|
.action(async (options) => {
|
|
@@ -5709,6 +6117,9 @@ export function createProgram() {
|
|
|
5709
6117
|
if (options.targetSequenceIds !== undefined) {
|
|
5710
6118
|
body.target_sequence_ids = options.targetSequenceIds.split(",").map((s) => s.trim()).filter(Boolean);
|
|
5711
6119
|
}
|
|
6120
|
+
if (options.channels !== undefined) {
|
|
6121
|
+
body.channels = options.channels.split(",").map((c) => c.trim()).filter(Boolean);
|
|
6122
|
+
}
|
|
5712
6123
|
if (options.maxDraftsPerDay !== undefined)
|
|
5713
6124
|
body.max_drafts_per_day = Number(options.maxDraftsPerDay);
|
|
5714
6125
|
return requestOxygen("/api/cli/inbox/reply-agent", { method: "POST", body });
|
|
@@ -5844,6 +6255,12 @@ export function createProgram() {
|
|
|
5844
6255
|
.option("--max-live-sends <n>", "Send ceiling for the email track (positive integer). Required to start an email sequence live.")
|
|
5845
6256
|
.option("--max-emails-per-mailbox-per-day <n>", "Per-mailbox daily email cap (positive integer) applied across the sending pool.")
|
|
5846
6257
|
.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? }.")
|
|
6258
|
+
.option("--max-emails-per-day <n>", "Sequence-wide daily live-send fleet cap (positive integer) across every sender/mailbox.")
|
|
6259
|
+
.option("--max-new-enrollments-per-day <n>", "Daily drip cap on NEW first-touch leads the planner starts (positive integer).")
|
|
6260
|
+
.option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
|
|
6261
|
+
.option("--schedule-template <name>", "Name of a saved schedule template (oxygen schedules) backing the sending window.")
|
|
6262
|
+
.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.")
|
|
6263
|
+
.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.")
|
|
5847
6264
|
.option("--json", "Print a JSON envelope.")
|
|
5848
6265
|
.action(async (options) => {
|
|
5849
6266
|
await handleAsyncAction("sequences create", options, () => {
|
|
@@ -5892,6 +6309,12 @@ export function createProgram() {
|
|
|
5892
6309
|
.option("--max-live-sends <n>", "Send ceiling for the email track (positive integer). Required to start an email sequence live.")
|
|
5893
6310
|
.option("--max-emails-per-mailbox-per-day <n>", "Per-mailbox daily email cap (positive integer) applied across the sending pool.")
|
|
5894
6311
|
.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? }.")
|
|
6312
|
+
.option("--max-emails-per-day <n>", "Sequence-wide daily live-send fleet cap (positive integer) across every sender/mailbox.")
|
|
6313
|
+
.option("--max-new-enrollments-per-day <n>", "Daily drip cap on NEW first-touch leads the planner starts (positive integer).")
|
|
6314
|
+
.option("--sequence-prioritization <mode>", "Under a tight daily budget, serve 'followups' or 'new_leads' first.")
|
|
6315
|
+
.option("--schedule-template <name>", "Name of a saved schedule template (oxygen schedules) backing the sending window.")
|
|
6316
|
+
.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.")
|
|
6317
|
+
.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.")
|
|
5895
6318
|
.option("--json", "Print a JSON envelope.")
|
|
5896
6319
|
.action(async (sequence, options) => {
|
|
5897
6320
|
await handleAsyncAction("sequences update", options, () => {
|
|
@@ -5927,7 +6350,7 @@ export function createProgram() {
|
|
|
5927
6350
|
body.email = email;
|
|
5928
6351
|
}
|
|
5929
6352
|
if (Object.keys(body).length === 0) {
|
|
5930
|
-
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,
|
|
6353
|
+
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).");
|
|
5931
6354
|
}
|
|
5932
6355
|
return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`, {
|
|
5933
6356
|
method: "PATCH",
|
|
@@ -5943,11 +6366,12 @@ export function createProgram() {
|
|
|
5943
6366
|
await handleAsyncAction("sequences get", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`));
|
|
5944
6367
|
}))
|
|
5945
6368
|
.addCommand(new Command("enroll")
|
|
5946
|
-
.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).")
|
|
6369
|
+
.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.")
|
|
5947
6370
|
.argument("<sequence>", "Sequence id or slug.")
|
|
5948
6371
|
.requiredOption("--leads-file <path>", "Path to a JSON file: { \"leads\": [{ lead_provider_id, lead_name, table_row_id, row_values }] }.")
|
|
5949
6372
|
.option("--exclude-contacted", "Also skip leads any OTHER active sequence is already contacting (cross-campaign exclusion). Off by default.")
|
|
5950
6373
|
.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).")
|
|
6374
|
+
.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.")
|
|
5951
6375
|
.option("--json", "Print a JSON envelope.")
|
|
5952
6376
|
.action(async (sequence, options) => {
|
|
5953
6377
|
await handleAsyncAction("sequences enroll", options, () => {
|
|
@@ -5962,6 +6386,7 @@ export function createProgram() {
|
|
|
5962
6386
|
leads: parsed.leads ?? [],
|
|
5963
6387
|
...(options.excludeContacted ? { exclude_contacted: true } : {}),
|
|
5964
6388
|
...(suppressList.length > 0 ? { suppress_list: suppressList } : {}),
|
|
6389
|
+
...(options.ignoreSenderBindings ? { ignore_sender_bindings: true } : {}),
|
|
5965
6390
|
},
|
|
5966
6391
|
});
|
|
5967
6392
|
});
|
|
@@ -6053,12 +6478,50 @@ export function createProgram() {
|
|
|
6053
6478
|
await handleAsyncAction("sequences stats", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/stats`));
|
|
6054
6479
|
}))
|
|
6055
6480
|
.addCommand(new Command("variants")
|
|
6056
|
-
.description("
|
|
6481
|
+
.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.")
|
|
6057
6482
|
.argument("<sequence>", "Sequence id or slug.")
|
|
6483
|
+
.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.")
|
|
6484
|
+
.option("--pause <variant>", "Manually pause one variant (requires --step). Overrides the auto-winner.")
|
|
6485
|
+
.option("--activate <variant>", "Manually reactivate one paused variant (requires --step).")
|
|
6486
|
+
.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.")
|
|
6487
|
+
.option("--step <stepId>", "Step id to scope --pause/--activate/--reset to.")
|
|
6058
6488
|
.option("--json", "Print a JSON envelope.")
|
|
6059
6489
|
.action(async (sequence, options) => {
|
|
6060
6490
|
await handleSequenceVariantsAction(sequence, options);
|
|
6061
6491
|
})));
|
|
6492
|
+
program.addCommand(new Command("schedules")
|
|
6493
|
+
.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.")
|
|
6494
|
+
.addCommand(new Command("list")
|
|
6495
|
+
.description("List the org's saved schedule templates.")
|
|
6496
|
+
.option("--json", "Print a JSON envelope.")
|
|
6497
|
+
.action(async (options) => {
|
|
6498
|
+
await handleAsyncAction("schedules list", options, () => requestOxygen("/api/cli/schedules"));
|
|
6499
|
+
}))
|
|
6500
|
+
.addCommand(new Command("create")
|
|
6501
|
+
.description("Create (or update by name) a schedule template. Partial windows get sensible defaults.")
|
|
6502
|
+
.requiredOption("--name <name>", "Unique template name (e.g. 'US business hours').")
|
|
6503
|
+
.requiredOption("--timezone <tz>", "IANA timezone (e.g. America/New_York).")
|
|
6504
|
+
.option("--start <HH:MM>", "Inclusive daily start time.", "09:00")
|
|
6505
|
+
.option("--end <HH:MM>", "Exclusive daily end time.", "17:00")
|
|
6506
|
+
.option("--days <csv>", "Allowed ISO weekdays, 1=Mon..7=Sun (e.g. 1,2,3,4,5). Omit → weekdays default.")
|
|
6507
|
+
.option("--json", "Print a JSON envelope.")
|
|
6508
|
+
.action(async (options) => {
|
|
6509
|
+
await handleAsyncAction("schedules create", options, () => {
|
|
6510
|
+
const days = readCsvOption(options.days).map((d) => Number(d)).filter((d) => Number.isInteger(d));
|
|
6511
|
+
return requestOxygen("/api/cli/schedules", {
|
|
6512
|
+
method: "POST",
|
|
6513
|
+
body: {
|
|
6514
|
+
name: readOption(options.name),
|
|
6515
|
+
definition: {
|
|
6516
|
+
timezone: readOption(options.timezone),
|
|
6517
|
+
...(readOption(options.start) ? { start: readOption(options.start) } : {}),
|
|
6518
|
+
...(readOption(options.end) ? { end: readOption(options.end) } : {}),
|
|
6519
|
+
...(days.length > 0 ? { days } : {}),
|
|
6520
|
+
},
|
|
6521
|
+
},
|
|
6522
|
+
});
|
|
6523
|
+
});
|
|
6524
|
+
})));
|
|
6062
6525
|
program.addCommand(new Command("suppressions")
|
|
6063
6526
|
.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.")
|
|
6064
6527
|
.addCommand(new Command("list")
|
|
@@ -6132,6 +6595,11 @@ export function createProgram() {
|
|
|
6132
6595
|
.requiredOption("--subject <text>", "Subject line.")
|
|
6133
6596
|
.option("--body <text>", "Plain-text body (sent verbatim, no templating).")
|
|
6134
6597
|
.option("--body-file <path>", "Read the body from a file instead of --body.")
|
|
6598
|
+
.option("--html <html>", "Optional HTML body — sends multipart/alternative with --body as the plain-text part.")
|
|
6599
|
+
.option("--html-file <path>", "Read the HTML body from a file instead of --html.")
|
|
6600
|
+
.option("--display-name <name>", "From display name for this send (overrides the mailbox's stored display name).")
|
|
6601
|
+
.option("--cc <list>", "Comma-separated Cc addresses.")
|
|
6602
|
+
.option("--bcc <list>", "Comma-separated Bcc addresses.")
|
|
6135
6603
|
.option("--from <mailbox>", "Sending mailbox (address or id). Defaults to the pool rotation.")
|
|
6136
6604
|
.option("--approved", "Approve and send. Without this flag, returns a preview only.")
|
|
6137
6605
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -6141,12 +6609,19 @@ export function createProgram() {
|
|
|
6141
6609
|
if (!bodyText || !bodyText.trim()) {
|
|
6142
6610
|
throw new Error("Provide --body or --body-file.");
|
|
6143
6611
|
}
|
|
6612
|
+
const htmlBody = readOption(options.html) ?? (options.htmlFile ? readFileSync(resolve(options.htmlFile), "utf8") : undefined);
|
|
6613
|
+
const cc = readCsvOption(options.cc);
|
|
6614
|
+
const bcc = readCsvOption(options.bcc);
|
|
6144
6615
|
return requestOxygen("/api/cli/email/send", {
|
|
6145
6616
|
method: "POST",
|
|
6146
6617
|
body: {
|
|
6147
6618
|
to: readOption(options.to),
|
|
6148
6619
|
subject: readOption(options.subject),
|
|
6149
6620
|
body: bodyText,
|
|
6621
|
+
...(htmlBody && htmlBody.trim() ? { html: htmlBody } : {}),
|
|
6622
|
+
...(readOption(options.displayName) ? { display_name: readOption(options.displayName) } : {}),
|
|
6623
|
+
...(cc.length > 0 ? { cc } : {}),
|
|
6624
|
+
...(bcc.length > 0 ? { bcc } : {}),
|
|
6150
6625
|
...(readOption(options.from) ? { from: readOption(options.from) } : {}),
|
|
6151
6626
|
...(options.approved ? { approved: true } : {}),
|
|
6152
6627
|
},
|
|
@@ -6175,6 +6650,12 @@ export function createProgram() {
|
|
|
6175
6650
|
.option("--json", "Print a JSON envelope.")
|
|
6176
6651
|
.action(async (mailbox, options) => {
|
|
6177
6652
|
await handleAsyncAction("mailboxes get", options, () => requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}`));
|
|
6653
|
+
}))
|
|
6654
|
+
.addCommand(new Command("health")
|
|
6655
|
+
.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.")
|
|
6656
|
+
.option("--json", "Print a JSON envelope.")
|
|
6657
|
+
.action(async (options) => {
|
|
6658
|
+
await handleAsyncAction("mailboxes health", options, () => requestOxygen("/api/cli/mailboxes/health"));
|
|
6178
6659
|
}))
|
|
6179
6660
|
.addCommand(new Command("import")
|
|
6180
6661
|
.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.")
|
|
@@ -6244,6 +6725,67 @@ export function createProgram() {
|
|
|
6244
6725
|
});
|
|
6245
6726
|
});
|
|
6246
6727
|
})))
|
|
6728
|
+
.addCommand(new Command("ramp")
|
|
6729
|
+
.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.")
|
|
6730
|
+
.addCommand(new Command("set")
|
|
6731
|
+
.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.")
|
|
6732
|
+
.argument("<mailbox>", "Mailbox id or email address.")
|
|
6733
|
+
.option("--start-per-day <n>", "Day-0 send ceiling (non-negative whole number).")
|
|
6734
|
+
.option("--increase-every-days <n>", "Days between each step up (whole number >= 1).")
|
|
6735
|
+
.option("--step <n>", "Sends added to the ceiling each period (non-negative whole number).")
|
|
6736
|
+
.option("--cap <n>", "The ramp's ceiling; once reached the ramp stops climbing (non-negative whole number).")
|
|
6737
|
+
.option("--clear", "Remove the ramp override and restore the default warm-up tiers.")
|
|
6738
|
+
.option("--json", "Print a JSON envelope.")
|
|
6739
|
+
.action(async (mailbox, options) => {
|
|
6740
|
+
await handleAsyncAction("mailboxes ramp set", options, () => {
|
|
6741
|
+
if (options.clear) {
|
|
6742
|
+
return requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}/ramp`, {
|
|
6743
|
+
method: "PATCH",
|
|
6744
|
+
body: { ramp_config: null },
|
|
6745
|
+
});
|
|
6746
|
+
}
|
|
6747
|
+
const startPerDay = readPositiveInteger(options.startPerDay);
|
|
6748
|
+
const increaseEveryDays = readPositiveInteger(options.increaseEveryDays);
|
|
6749
|
+
const step = readPositiveInteger(options.step);
|
|
6750
|
+
const cap = readPositiveInteger(options.cap);
|
|
6751
|
+
if (startPerDay === undefined || increaseEveryDays === undefined || step === undefined || cap === undefined) {
|
|
6752
|
+
throw new Error("Provide --start-per-day, --increase-every-days, --step, and --cap (or --clear to remove the override).");
|
|
6753
|
+
}
|
|
6754
|
+
return requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}/ramp`, {
|
|
6755
|
+
method: "PATCH",
|
|
6756
|
+
body: { ramp_config: { start_per_day: startPerDay, increase_every_days: increaseEveryDays, step, cap } },
|
|
6757
|
+
});
|
|
6758
|
+
});
|
|
6759
|
+
})))
|
|
6760
|
+
.addCommand(new Command("update")
|
|
6761
|
+
.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.")
|
|
6762
|
+
.argument("<mailbox>", "Mailbox id or email address.")
|
|
6763
|
+
.option("--display-name <name>", "From display name (e.g. \"Ada from Acme\"). Pass '' to clear it.")
|
|
6764
|
+
.option("--signature-html <html>", "HTML signature appended to every send from this mailbox. Pass '' to clear it.")
|
|
6765
|
+
.option("--signature-file <path>", "Read the HTML signature from a file instead of --signature-html.")
|
|
6766
|
+
.option("--json", "Print a JSON envelope.")
|
|
6767
|
+
.action(async (mailbox, options) => {
|
|
6768
|
+
await handleAsyncAction("mailboxes update", options, () => {
|
|
6769
|
+
// Detect PRESENCE (options.x !== undefined), not truthiness, so passing
|
|
6770
|
+
// an empty string clears the field rather than being dropped.
|
|
6771
|
+
const patch = {};
|
|
6772
|
+
if (options.displayName !== undefined)
|
|
6773
|
+
patch.display_name = options.displayName;
|
|
6774
|
+
if (options.signatureHtml !== undefined) {
|
|
6775
|
+
patch.signature_html = options.signatureHtml;
|
|
6776
|
+
}
|
|
6777
|
+
else if (options.signatureFile) {
|
|
6778
|
+
patch.signature_html = readFileSync(resolve(options.signatureFile), "utf8");
|
|
6779
|
+
}
|
|
6780
|
+
if (Object.keys(patch).length === 0) {
|
|
6781
|
+
throw new Error("Provide --display-name and/or --signature-html (or --signature-file).");
|
|
6782
|
+
}
|
|
6783
|
+
return requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}`, {
|
|
6784
|
+
method: "PATCH",
|
|
6785
|
+
body: patch,
|
|
6786
|
+
});
|
|
6787
|
+
});
|
|
6788
|
+
}))
|
|
6247
6789
|
.addCommand(new Command("connect-oauth")
|
|
6248
6790
|
.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.")
|
|
6249
6791
|
.option("--provider <provider>", "Mailbox provider to provision: google or microsoft.")
|
|
@@ -6276,6 +6818,12 @@ export function createProgram() {
|
|
|
6276
6818
|
},
|
|
6277
6819
|
});
|
|
6278
6820
|
});
|
|
6821
|
+
}))
|
|
6822
|
+
.addCommand(new Command("oauth-health")
|
|
6823
|
+
.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.")
|
|
6824
|
+
.option("--json", "Print a JSON envelope.")
|
|
6825
|
+
.action(async (options) => {
|
|
6826
|
+
await handleAsyncAction("mailboxes oauth-health", options, () => requestOxygen("/api/cli/mailboxes/oauth-health"));
|
|
6279
6827
|
}))
|
|
6280
6828
|
.addCommand(new Command("warmup")
|
|
6281
6829
|
.description("Mailbox warmup via Instantly (BYOK — Instantly bills your account, 0 Oxygen credits).")
|
|
@@ -6319,6 +6867,96 @@ export function createProgram() {
|
|
|
6319
6867
|
const suffix = params.toString();
|
|
6320
6868
|
return requestOxygen(`/api/cli/mailboxes/warmup/status${suffix ? `?${suffix}` : ""}`);
|
|
6321
6869
|
});
|
|
6870
|
+
})))
|
|
6871
|
+
.addCommand(new Command("order")
|
|
6872
|
+
.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.")
|
|
6873
|
+
.requiredOption("--provider <provider>", "Mailbox provider: google or microsoft.")
|
|
6874
|
+
.requiredOption("--domains <domains>", "Comma-separated sending domains (e.g. send.acme.com,mail.acme.io).")
|
|
6875
|
+
.option("--mailboxes-per-domain <n>", "Mailboxes to provision per domain (positive whole number). Defaults to 1.")
|
|
6876
|
+
.option("--workspace <id>", "Optional Zapmail workspace key to provision into.")
|
|
6877
|
+
.option("--approved", "Place the order (requires --quote from a fresh preview).")
|
|
6878
|
+
.option("--quote <id>", "The quote_id from a fresh preview (required with --approved).")
|
|
6879
|
+
.option("--json", "Print a JSON envelope.")
|
|
6880
|
+
.action(async (options) => {
|
|
6881
|
+
await handleAsyncAction("mailboxes order", options, () => {
|
|
6882
|
+
const domains = readOption(options.domains)?.split(",").map((d) => d.trim()).filter(Boolean) ?? [];
|
|
6883
|
+
const perDomain = readOption(options.mailboxesPerDomain);
|
|
6884
|
+
const workspace = readOption(options.workspace);
|
|
6885
|
+
const quote = readOption(options.quote);
|
|
6886
|
+
return requestOxygen("/api/cli/mailboxes/order", {
|
|
6887
|
+
method: "POST",
|
|
6888
|
+
body: {
|
|
6889
|
+
provider: readOption(options.provider),
|
|
6890
|
+
domains,
|
|
6891
|
+
...(perDomain ? { mailboxes_per_domain: Number(perDomain) } : {}),
|
|
6892
|
+
...(workspace ? { workspace_id: workspace } : {}),
|
|
6893
|
+
...(options.approved ? { approved: true } : {}),
|
|
6894
|
+
...(quote ? { quote_id: quote } : {}),
|
|
6895
|
+
},
|
|
6896
|
+
});
|
|
6897
|
+
});
|
|
6898
|
+
}))
|
|
6899
|
+
.addCommand(new Command("orders")
|
|
6900
|
+
.description("List the org's managed mailbox provisioning orders (newest first).")
|
|
6901
|
+
.option("--status <status>", "Comma-separated status filter (e.g. provisioning,warming).")
|
|
6902
|
+
.option("--json", "Print a JSON envelope.")
|
|
6903
|
+
.action(async (options) => {
|
|
6904
|
+
await handleAsyncAction("mailboxes orders", options, () => {
|
|
6905
|
+
const params = new URLSearchParams();
|
|
6906
|
+
const status = readOption(options.status);
|
|
6907
|
+
if (status)
|
|
6908
|
+
params.set("status", status);
|
|
6909
|
+
const suffix = params.toString();
|
|
6910
|
+
return requestOxygen(`/api/cli/mailboxes/orders${suffix ? `?${suffix}` : ""}`);
|
|
6911
|
+
});
|
|
6912
|
+
}))
|
|
6913
|
+
.addCommand(new Command("order-status")
|
|
6914
|
+
.description("Show one managed mailbox order's status (provider, domains, status, quote, held credits, Zapmail refs).")
|
|
6915
|
+
.argument("<order>", "Mailbox order id.")
|
|
6916
|
+
.option("--json", "Print a JSON envelope.")
|
|
6917
|
+
.action(async (order, options) => {
|
|
6918
|
+
await handleAsyncAction("mailboxes order-status", options, () => requestOxygen(`/api/cli/mailboxes/orders/${encodeURIComponent(order)}`));
|
|
6919
|
+
})));
|
|
6920
|
+
program.addCommand(new Command("deliverability")
|
|
6921
|
+
.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.")
|
|
6922
|
+
.addCommand(new Command("placement-test")
|
|
6923
|
+
.description("Directional inbox-placement (spam) tests: run, list, and get. Results estimate inbox-vs-spam landing from seed inboxes.")
|
|
6924
|
+
.addCommand(new Command("run")
|
|
6925
|
+
.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.")
|
|
6926
|
+
.argument("<mailbox>", "Sending mailbox address to test (e.g. ada@send.acme.com).")
|
|
6927
|
+
.option("--subject <subject>", "Optional subject for the seed message.")
|
|
6928
|
+
.option("--body <text>", "Optional plain-text body for the seed message.")
|
|
6929
|
+
.option("--approved", "Actually run the test (otherwise a preview is returned).")
|
|
6930
|
+
.option("--max-credits <n>", "Credit cap the caller accepts (must be >= credits_required for managed runs).")
|
|
6931
|
+
.option("--json", "Print a JSON envelope.")
|
|
6932
|
+
.action(async (mailbox, options) => {
|
|
6933
|
+
await handleAsyncAction("deliverability placement-test run", options, () => requestOxygen("/api/cli/deliverability/placement-tests", {
|
|
6934
|
+
method: "POST",
|
|
6935
|
+
body: {
|
|
6936
|
+
mailbox,
|
|
6937
|
+
...(readOption(options.subject) ? { subject: readOption(options.subject) } : {}),
|
|
6938
|
+
...(readOption(options.body) ? { body_text: readOption(options.body) } : {}),
|
|
6939
|
+
...(options.approved ? { approved: true } : {}),
|
|
6940
|
+
...(readOption(options.maxCredits) ? { max_credits: Number(readOption(options.maxCredits)) } : {}),
|
|
6941
|
+
},
|
|
6942
|
+
}));
|
|
6943
|
+
}))
|
|
6944
|
+
.addCommand(new Command("list")
|
|
6945
|
+
.description("List recent directional inbox-placement tests with status, billing mode, seed set, score, and credits used.")
|
|
6946
|
+
.option("--limit <n>", "Max rows (default 50, cap 200).")
|
|
6947
|
+
.option("--json", "Print a JSON envelope.")
|
|
6948
|
+
.action(async (options) => {
|
|
6949
|
+
await handleAsyncAction("deliverability placement-test list", options, () => {
|
|
6950
|
+
const limit = readOption(options.limit);
|
|
6951
|
+
return requestOxygen(`/api/cli/deliverability/placement-tests${limit ? `?limit=${encodeURIComponent(limit)}` : ""}`);
|
|
6952
|
+
});
|
|
6953
|
+
}))
|
|
6954
|
+
.addCommand(new Command("get")
|
|
6955
|
+
.description("Get one directional inbox-placement test by id — status, seed inboxes, per-provider results, and score.")
|
|
6956
|
+
.argument("<id>", "Placement test id.")
|
|
6957
|
+
.option("--json", "Print a JSON envelope.")
|
|
6958
|
+
.action(async (id, options) => {
|
|
6959
|
+
await handleAsyncAction("deliverability placement-test get", options, () => requestOxygen(`/api/cli/deliverability/placement-tests/${encodeURIComponent(id)}`));
|
|
6322
6960
|
}))));
|
|
6323
6961
|
program.addCommand(new Command("domains")
|
|
6324
6962
|
.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.")
|
|
@@ -6359,11 +6997,90 @@ export function createProgram() {
|
|
|
6359
6997
|
await handleAsyncAction("domains get", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}`));
|
|
6360
6998
|
}))
|
|
6361
6999
|
.addCommand(new Command("dns")
|
|
6362
|
-
.description("Run a live read-only DNS health check (SPF, DKIM, DMARC, MX) against the domain's Cloudflare zone and persist the result.")
|
|
7000
|
+
.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.")
|
|
7001
|
+
.argument("[domain]", "Domain name, such as acme.com.")
|
|
7002
|
+
.option("--deep", "Add content-level DNS analysis (SPF lookup budget, DMARC enforcement, MX provider) and an external DoH nameserver-authority check.")
|
|
7003
|
+
.option("--json", "Print a JSON envelope.")
|
|
7004
|
+
.action(async (domain, options) => {
|
|
7005
|
+
if (!domain) {
|
|
7006
|
+
throw new Error("Provide a domain (oxygen domains dns <domain>) or use a subcommand: `dns plan` / `dns apply`.");
|
|
7007
|
+
}
|
|
7008
|
+
const suffix = options.deep ? "?deep=true" : "";
|
|
7009
|
+
await handleAsyncAction("domains dns", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/dns${suffix}`));
|
|
7010
|
+
})
|
|
7011
|
+
.addCommand(new Command("plan")
|
|
7012
|
+
.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.")
|
|
7013
|
+
.argument("<domain>", "Domain name, such as acme.com.")
|
|
7014
|
+
.requiredOption("--provider <provider>", "Mailbox provider: google or microsoft (determines SPF/MX/DKIM).")
|
|
7015
|
+
.option("--dmarc-policy <policy>", "DMARC policy: none (default), quarantine, or reject.")
|
|
7016
|
+
.option("--dmarc-rua <mailbox>", "DMARC aggregate-report mailbox (rua=mailto:...).")
|
|
7017
|
+
.option("--tracking-host <host>", "Open/click tracking CNAME host (only planned when tracking is configured).")
|
|
7018
|
+
.option("--no-auto-dkim", "Skip auto-deriving Microsoft DKIM selector1/selector2 CNAMEs (default: auto-derive them for microsoft when no DKIM is supplied).")
|
|
7019
|
+
.option("--json", "Print a JSON envelope.")
|
|
7020
|
+
.action(async (domain, options) => {
|
|
7021
|
+
await handleAsyncAction("domains dns plan", options, () => runDomainsDnsPlan(domain, options));
|
|
7022
|
+
}))
|
|
7023
|
+
.addCommand(new Command("apply")
|
|
7024
|
+
.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.")
|
|
6363
7025
|
.argument("<domain>", "Domain name, such as acme.com.")
|
|
7026
|
+
.requiredOption("--provider <provider>", "Mailbox provider: google or microsoft (must match the plan).")
|
|
7027
|
+
.option("--approved", "Write the records. Without this flag, returns a plan preview only.")
|
|
7028
|
+
.option("--plan <hash>", "plan_hash from a fresh `dns plan` (required with --approved).")
|
|
7029
|
+
.option("--dmarc-policy <policy>", "DMARC policy: none (default), quarantine, or reject.")
|
|
7030
|
+
.option("--dmarc-rua <mailbox>", "DMARC aggregate-report mailbox (rua=mailto:...).")
|
|
7031
|
+
.option("--tracking-host <host>", "Open/click tracking CNAME host (only applied when tracking is configured).")
|
|
7032
|
+
.option("--no-auto-dkim", "Skip auto-deriving Microsoft DKIM (must match the value used in `dns plan` so the plan_hash matches).")
|
|
7033
|
+
.option("--json", "Print a JSON envelope.")
|
|
7034
|
+
.action(async (domain, options) => {
|
|
7035
|
+
await handleAsyncAction("domains dns apply", options, () => runDomainsDnsApply(domain, options));
|
|
7036
|
+
})))
|
|
7037
|
+
.addCommand(new Command("tracking")
|
|
7038
|
+
.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.")
|
|
7039
|
+
.addCommand(new Command("setup")
|
|
7040
|
+
.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.")
|
|
7041
|
+
.argument("<domain>", "Sending domain, such as acme.com.")
|
|
7042
|
+
.option("--host <host>", "Tracking subdomain (default track.<domain>); must be a subdomain of the domain.")
|
|
7043
|
+
.option("--approve", "Write the CNAME. Without this flag, returns a preview only.")
|
|
7044
|
+
.option("--json", "Print a JSON envelope.")
|
|
7045
|
+
.action(async (domain, options) => {
|
|
7046
|
+
await handleAsyncAction("domains tracking setup", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/tracking`, {
|
|
7047
|
+
method: "POST",
|
|
7048
|
+
body: { ...(options.host ? { host: options.host } : {}), ...(options.approve ? { approved: true } : {}) },
|
|
7049
|
+
}));
|
|
7050
|
+
}))
|
|
7051
|
+
.addCommand(new Command("status")
|
|
7052
|
+
.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.")
|
|
7053
|
+
.argument("<domain>", "Sending domain, such as acme.com.")
|
|
7054
|
+
.option("--json", "Print a JSON envelope.")
|
|
7055
|
+
.action(async (domain, options) => {
|
|
7056
|
+
await handleAsyncAction("domains tracking status", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/tracking`));
|
|
7057
|
+
})))
|
|
7058
|
+
.addCommand(new Command("postmaster")
|
|
7059
|
+
.description("Google Postmaster Tools onboarding for a sending domain: register it with Postmaster, publish the DNS verification TXT via its Cloudflare zone, and check verification status. FREE — 0 Oxygen credits.")
|
|
7060
|
+
.addCommand(new Command("onboard")
|
|
7061
|
+
.description("Register the domain with Google Postmaster Tools and publish the DNS verification TXT automatically via the domain's Cloudflare zone (or return the record for manual DNS); verification then completes automatically in the background. Without --approve, returns a preview. FREE — 0 Oxygen credits.")
|
|
7062
|
+
.argument("<domain>", "Sending domain, such as acme.com.")
|
|
7063
|
+
.option("--approve", "Perform the onboarding. Without this flag, returns a preview only.")
|
|
6364
7064
|
.option("--json", "Print a JSON envelope.")
|
|
6365
7065
|
.action(async (domain, options) => {
|
|
6366
|
-
await handleAsyncAction("domains
|
|
7066
|
+
await handleAsyncAction("domains postmaster onboard", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/postmaster`, {
|
|
7067
|
+
method: "POST",
|
|
7068
|
+
body: { ...(options.approve ? { approved: true } : {}) },
|
|
7069
|
+
}));
|
|
7070
|
+
}))
|
|
7071
|
+
.addCommand(new Command("status")
|
|
7072
|
+
.description("Show the Google Postmaster Tools onboarding status for a domain: registration, DNS verification state, and whether reputation data is available. Read-only — 0 Oxygen credits.")
|
|
7073
|
+
.argument("<domain>", "Sending domain, such as acme.com.")
|
|
7074
|
+
.option("--json", "Print a JSON envelope.")
|
|
7075
|
+
.action(async (domain, options) => {
|
|
7076
|
+
await handleAsyncAction("domains postmaster status", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/postmaster`));
|
|
7077
|
+
})))
|
|
7078
|
+
.addCommand(new Command("add")
|
|
7079
|
+
.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.")
|
|
7080
|
+
.argument("<domain>", "Apex domain to add, such as acme.com.")
|
|
7081
|
+
.option("--json", "Print a JSON envelope.")
|
|
7082
|
+
.action(async (domain, options) => {
|
|
7083
|
+
await handleAsyncAction("domains add", options, () => requestOxygen("/api/cli/domains/add", { method: "POST", body: { domain } }));
|
|
6367
7084
|
}))
|
|
6368
7085
|
.addCommand(new Command("search")
|
|
6369
7086
|
.description("Search Cloudflare Registrar for available domains with registration/renewal pricing. Free — nothing is purchased.")
|
|
@@ -8674,6 +9391,44 @@ function domainsBuyRerunCommand(domains, quoteId, options, data) {
|
|
|
8674
9391
|
];
|
|
8675
9392
|
return `${resolveCliBinaryName()} domains buy ${domains.join(" ")} ${flags.join(" ")}`;
|
|
8676
9393
|
}
|
|
9394
|
+
function dnsPlanBody(options) {
|
|
9395
|
+
const provider = readOption(options.provider);
|
|
9396
|
+
if (!provider) {
|
|
9397
|
+
throw new Error("--provider <google|microsoft> is required so the correct SPF/MX/DKIM records can be planned.");
|
|
9398
|
+
}
|
|
9399
|
+
return {
|
|
9400
|
+
provider,
|
|
9401
|
+
...(readOption(options.dmarcPolicy) ? { dmarc_policy: readOption(options.dmarcPolicy) } : {}),
|
|
9402
|
+
...(readOption(options.dmarcRua) ? { dmarc_rua: readOption(options.dmarcRua) } : {}),
|
|
9403
|
+
...(readOption(options.trackingHost) ? { tracking_host: readOption(options.trackingHost) } : {}),
|
|
9404
|
+
...(options.autoDkim === false ? { auto_dkim: false } : {}),
|
|
9405
|
+
};
|
|
9406
|
+
}
|
|
9407
|
+
// Preview the cold-email DNS plan for a domain (free — no writes). Appends a
|
|
9408
|
+
// copy-paste rerun command carrying the plan_hash, mirroring the buy quote flow.
|
|
9409
|
+
async function runDomainsDnsPlan(domain, options) {
|
|
9410
|
+
const data = await requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/dns/plan`, { method: "POST", body: dnsPlanBody(options) });
|
|
9411
|
+
const planHash = readRecordString(data, "plan_hash");
|
|
9412
|
+
const provider = readOption(options.provider);
|
|
9413
|
+
return planHash && provider
|
|
9414
|
+
? { ...data, rerun_command: `${resolveCliBinaryName()} domains dns apply ${domain} --provider ${provider} --approved --plan ${planHash}` }
|
|
9415
|
+
: data;
|
|
9416
|
+
}
|
|
9417
|
+
// Apply an approved DNS plan (writes records). Requires --plan <hash> with
|
|
9418
|
+
// --approved, mirroring `domains buy --quote`.
|
|
9419
|
+
async function runDomainsDnsApply(domain, options) {
|
|
9420
|
+
const planHash = readOption(options.plan);
|
|
9421
|
+
if (options.approved && !planHash) {
|
|
9422
|
+
throw new Error("--plan <hash> is required with --approved. Run `domains dns plan` first to get the plan and its hash.");
|
|
9423
|
+
}
|
|
9424
|
+
return requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/dns/apply`, {
|
|
9425
|
+
method: "POST",
|
|
9426
|
+
body: {
|
|
9427
|
+
...dnsPlanBody(options),
|
|
9428
|
+
...(options.approved ? { approved: true, plan_hash: planHash } : {}),
|
|
9429
|
+
},
|
|
9430
|
+
});
|
|
9431
|
+
}
|
|
8677
9432
|
// Staff import of pre-existing managed-account domains. Exactly one of an explicit
|
|
8678
9433
|
// list or --all; without --yes the server returns a preview and the CLI appends a
|
|
8679
9434
|
// copy-paste rerun command to execute it.
|
|
@@ -8789,6 +9544,33 @@ async function exportRows(table, options) {
|
|
|
8789
9544
|
...(formatted.rescuedCount > 0 ? { rescuedNumericCells: formatted.rescuedCount } : {}),
|
|
8790
9545
|
};
|
|
8791
9546
|
}
|
|
9547
|
+
// `table-runs list --format` routes run history through the same serializer
|
|
9548
|
+
// `tables export` uses, so csv/jsonl/table run exports behave exactly like row
|
|
9549
|
+
// exports (header = union of run keys; nested objects like counts are
|
|
9550
|
+
// JSON-stringified by escapeCsvField). Without --format/--output the command
|
|
9551
|
+
// keeps its original envelope response untouched.
|
|
9552
|
+
async function listTableRuns(options) {
|
|
9553
|
+
const rawFormat = readOption(options.format);
|
|
9554
|
+
const outputPath = readOption(options.output);
|
|
9555
|
+
if (!rawFormat && !outputPath)
|
|
9556
|
+
return requestOxygen(tableRunsListPath(options));
|
|
9557
|
+
// Validate the format before spending the API call.
|
|
9558
|
+
const format = normalizeExportRowsFormat(rawFormat ?? undefined);
|
|
9559
|
+
const result = await requestOxygen(tableRunsListPath(options));
|
|
9560
|
+
const runs = Array.isArray(result.runs) ? result.runs.filter(isRecord) : [];
|
|
9561
|
+
const formatted = formatRows(runs, format);
|
|
9562
|
+
if (outputPath)
|
|
9563
|
+
writeFileSync(outputPath, formatted.content);
|
|
9564
|
+
return {
|
|
9565
|
+
table: result.table ?? null,
|
|
9566
|
+
status: result.status ?? null,
|
|
9567
|
+
format,
|
|
9568
|
+
runCount: runs.length,
|
|
9569
|
+
output: outputPath ?? null,
|
|
9570
|
+
...(outputPath ? {} : { content: formatted.content }),
|
|
9571
|
+
...(typeof result.web_url === "string" ? { web_url: result.web_url } : {}),
|
|
9572
|
+
};
|
|
9573
|
+
}
|
|
8792
9574
|
const TABLE_BUNDLE_SCHEMA_VERSION = 1;
|
|
8793
9575
|
const TABLE_BUNDLE_MAX_PAGE_SIZE = 1000;
|
|
8794
9576
|
const TABLE_BUNDLE_DEFAULT_PAGE_SIZE = 500;
|
|
@@ -10359,11 +11141,32 @@ async function handleSequenceSignalAction(sequence, options) {
|
|
|
10359
11141
|
}
|
|
10360
11142
|
async function handleSequenceVariantsAction(sequence, options) {
|
|
10361
11143
|
try {
|
|
10362
|
-
const
|
|
11144
|
+
const path = `/api/cli/sequences/${encodeURIComponent(sequence)}/variants`;
|
|
11145
|
+
let body = null;
|
|
11146
|
+
if (options.autoOptimize)
|
|
11147
|
+
body = { action: "auto_optimize" };
|
|
11148
|
+
else if (options.pause !== undefined)
|
|
11149
|
+
body = { action: "pause", step_id: options.step, variant: options.pause };
|
|
11150
|
+
else if (options.activate !== undefined)
|
|
11151
|
+
body = { action: "activate", step_id: options.step, variant: options.activate };
|
|
11152
|
+
else if (options.reset !== undefined)
|
|
11153
|
+
body = {
|
|
11154
|
+
action: "reset",
|
|
11155
|
+
...(options.step ? { step_id: options.step } : {}),
|
|
11156
|
+
...(typeof options.reset === "string" ? { variant: options.reset } : {}),
|
|
11157
|
+
};
|
|
11158
|
+
const data = body
|
|
11159
|
+
? await requestOxygen(path, { method: "POST", body })
|
|
11160
|
+
: await requestOxygen(path);
|
|
10363
11161
|
if (options.json) {
|
|
10364
11162
|
writeJson(success("sequences variants", data));
|
|
10365
11163
|
return;
|
|
10366
11164
|
}
|
|
11165
|
+
if (body) {
|
|
11166
|
+
const link = data.deepLink ?? data.web_url;
|
|
11167
|
+
process.stdout.write(`${data.note ?? "Done."}${link ? ` ${link}` : ""}\n`);
|
|
11168
|
+
return;
|
|
11169
|
+
}
|
|
10367
11170
|
process.stdout.write(formatSequenceVariants(data));
|
|
10368
11171
|
}
|
|
10369
11172
|
catch (error) {
|
|
@@ -10709,6 +11512,33 @@ function ansi(enabled) {
|
|
|
10709
11512
|
function readOption(value) {
|
|
10710
11513
|
return value?.trim() ? value.trim() : null;
|
|
10711
11514
|
}
|
|
11515
|
+
// Commander collector for a repeatable option (e.g. --attach <url> --attach <url>):
|
|
11516
|
+
// accumulates each value into an array.
|
|
11517
|
+
function collectRepeatable(value, previous) {
|
|
11518
|
+
return [...previous, value];
|
|
11519
|
+
}
|
|
11520
|
+
// Shared query string for `posts comments` / `posts reactions` (post social_id +
|
|
11521
|
+
// optional comment scope, pagination, sort, and account selection).
|
|
11522
|
+
function buildPostEngagementQuery(options) {
|
|
11523
|
+
const params = new URLSearchParams();
|
|
11524
|
+
params.set("post", readOption(options.post) ?? "");
|
|
11525
|
+
const commentId = readOption(options.commentId);
|
|
11526
|
+
if (commentId)
|
|
11527
|
+
params.set("comment_id", commentId);
|
|
11528
|
+
const sortBy = readOption(options.sortBy);
|
|
11529
|
+
if (sortBy)
|
|
11530
|
+
params.set("sort_by", sortBy);
|
|
11531
|
+
const cursor = readOption(options.cursor);
|
|
11532
|
+
if (cursor)
|
|
11533
|
+
params.set("cursor", cursor);
|
|
11534
|
+
const limit = readOption(options.limit);
|
|
11535
|
+
if (limit)
|
|
11536
|
+
params.set("limit", limit);
|
|
11537
|
+
const account = readOption(options.account);
|
|
11538
|
+
if (account)
|
|
11539
|
+
params.set("account", account);
|
|
11540
|
+
return params.toString();
|
|
11541
|
+
}
|
|
10712
11542
|
// Assemble the POST body for `oxygen feedback`. Reads the local chat transcript
|
|
10713
11543
|
// (unless --no-transcript) and attaches a non-sensitive environment snapshot so
|
|
10714
11544
|
// the Oxygen team can triage. The transcript read happens here, inside the
|
|
@@ -10759,11 +11589,15 @@ function buildSupportTicketBody(options) {
|
|
|
10759
11589
|
body.severity = readOption(options.severity);
|
|
10760
11590
|
if (readOption(options.category))
|
|
10761
11591
|
body.category = readOption(options.category);
|
|
11592
|
+
if (readOption(options.source))
|
|
11593
|
+
body.source = readOption(options.source);
|
|
10762
11594
|
const context = {};
|
|
10763
11595
|
if (readOption(options.operation))
|
|
10764
11596
|
context.operation = readOption(options.operation);
|
|
10765
11597
|
if (readOption(options.errorCode))
|
|
10766
11598
|
context.error_code = readOption(options.errorCode);
|
|
11599
|
+
if (readOption(options.slackPermalink))
|
|
11600
|
+
context.slack_permalink = readOption(options.slackPermalink);
|
|
10767
11601
|
const runIds = readCsvOption(options.runIds);
|
|
10768
11602
|
const tableIds = readCsvOption(options.tableIds);
|
|
10769
11603
|
const deepLinks = readCsvOption(options.deepLinks);
|
|
@@ -10788,6 +11622,116 @@ function withSupportListQuery(path, options) {
|
|
|
10788
11622
|
const query = params.toString();
|
|
10789
11623
|
return query ? `${path}?${query}` : path;
|
|
10790
11624
|
}
|
|
11625
|
+
// Workflow text flags (--verify-notes/--plan/--draft) bypass readOption: an
|
|
11626
|
+
// explicitly-passed empty string clears the field server-side, while an absent
|
|
11627
|
+
// flag leaves it untouched, so "" must survive to the request body.
|
|
11628
|
+
function buildSupportAdminWorkflowBody(options) {
|
|
11629
|
+
const body = {};
|
|
11630
|
+
const verifyStatus = readOption(options.verifyStatus);
|
|
11631
|
+
if (verifyStatus)
|
|
11632
|
+
body.verify_status = verifyStatus;
|
|
11633
|
+
if (options.verifyNotes !== undefined)
|
|
11634
|
+
body.verify_notes = options.verifyNotes;
|
|
11635
|
+
const planStatus = readOption(options.planStatus);
|
|
11636
|
+
if (planStatus)
|
|
11637
|
+
body.plan_status = planStatus;
|
|
11638
|
+
if (options.plan !== undefined)
|
|
11639
|
+
body.plan = options.plan;
|
|
11640
|
+
const draftStatus = readOption(options.draftStatus);
|
|
11641
|
+
if (draftStatus)
|
|
11642
|
+
body.draft_status = draftStatus;
|
|
11643
|
+
if (options.draft !== undefined)
|
|
11644
|
+
body.draft_reply = options.draft;
|
|
11645
|
+
if (!Object.keys(body).length) {
|
|
11646
|
+
throw new OxygenError("invalid_request", "Pass at least one of --verify-status, --verify-notes, --plan-status, --plan, --draft-status, --draft.", { exitCode: 1 });
|
|
11647
|
+
}
|
|
11648
|
+
return body;
|
|
11649
|
+
}
|
|
11650
|
+
function buildSupportAdminUpdateBody(options) {
|
|
11651
|
+
if (options.assign !== undefined && options.unassign) {
|
|
11652
|
+
throw new OxygenError("invalid_request", "Pass either --assign <email> or --unassign, not both.", {
|
|
11653
|
+
exitCode: 1,
|
|
11654
|
+
});
|
|
11655
|
+
}
|
|
11656
|
+
const body = {};
|
|
11657
|
+
const status = readOption(options.status);
|
|
11658
|
+
if (status)
|
|
11659
|
+
body.status = status;
|
|
11660
|
+
const assign = readOption(options.assign);
|
|
11661
|
+
if (assign)
|
|
11662
|
+
body.assignee_email = assign;
|
|
11663
|
+
if (options.unassign)
|
|
11664
|
+
body.assignee_email = null;
|
|
11665
|
+
const note = readOption(options.note);
|
|
11666
|
+
if (note)
|
|
11667
|
+
body.note = note;
|
|
11668
|
+
if (!Object.keys(body).length) {
|
|
11669
|
+
throw new OxygenError("invalid_request", "Pass at least one of --status, --assign, --unassign, --note.", { exitCode: 1 });
|
|
11670
|
+
}
|
|
11671
|
+
return body;
|
|
11672
|
+
}
|
|
11673
|
+
async function handleSupportAdminWorkflowAction(ticketId, options) {
|
|
11674
|
+
try {
|
|
11675
|
+
const body = buildSupportAdminWorkflowBody(options);
|
|
11676
|
+
const data = await requestOxygen(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}/workflow`, { method: "POST", body });
|
|
11677
|
+
if (options.json) {
|
|
11678
|
+
writeJson(success("support admin workflow", data));
|
|
11679
|
+
return;
|
|
11680
|
+
}
|
|
11681
|
+
process.stdout.write(formatSupportAdminWorkflowTicket(data));
|
|
11682
|
+
}
|
|
11683
|
+
catch (error) {
|
|
11684
|
+
const failure = toFailure("support admin workflow", error);
|
|
11685
|
+
writeJson(failure);
|
|
11686
|
+
process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
|
|
11687
|
+
}
|
|
11688
|
+
}
|
|
11689
|
+
async function handleSupportAdminUpdateAction(ticketId, options) {
|
|
11690
|
+
try {
|
|
11691
|
+
const body = buildSupportAdminUpdateBody(options);
|
|
11692
|
+
const data = await requestOxygen(`/api/cli/admin/support/tickets/${encodeURIComponent(ticketId)}/update`, { method: "POST", body });
|
|
11693
|
+
if (options.json) {
|
|
11694
|
+
writeJson(success("support admin update", data));
|
|
11695
|
+
return;
|
|
11696
|
+
}
|
|
11697
|
+
process.stdout.write(formatSupportAdminUpdateTicket(data));
|
|
11698
|
+
}
|
|
11699
|
+
catch (error) {
|
|
11700
|
+
const failure = toFailure("support admin update", error);
|
|
11701
|
+
writeJson(failure);
|
|
11702
|
+
process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
|
|
11703
|
+
}
|
|
11704
|
+
}
|
|
11705
|
+
function formatSupportAdminWorkflowStep(styles, label, status, artifactLabel, artifact) {
|
|
11706
|
+
const artifactNote = artifact?.trim() ? `${artifactLabel} set` : `no ${artifactLabel}`;
|
|
11707
|
+
return ` ${label.padEnd(6)} ${status ?? "pending"} ${styles.dim(`(${artifactNote})`)}`;
|
|
11708
|
+
}
|
|
11709
|
+
function formatSupportAdminWorkflowTicket(data) {
|
|
11710
|
+
const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
|
|
11711
|
+
const ticket = data.ticket ?? {};
|
|
11712
|
+
const workflow = ticket.workflow ?? {};
|
|
11713
|
+
const lines = [
|
|
11714
|
+
`${styles.bold("Workflow updated")}${ticket.subject ? styles.dim(` — ${ticket.subject}`) : ""}`,
|
|
11715
|
+
formatSupportAdminWorkflowStep(styles, "verify", workflow.verify_status, "notes", workflow.verify_notes),
|
|
11716
|
+
formatSupportAdminWorkflowStep(styles, "plan", workflow.plan_status, "plan", workflow.plan),
|
|
11717
|
+
formatSupportAdminWorkflowStep(styles, "draft", workflow.draft_status, "draft reply", workflow.draft_reply),
|
|
11718
|
+
];
|
|
11719
|
+
if (ticket.admin_web_url)
|
|
11720
|
+
lines.push(`View ticket: ${ticket.admin_web_url}`);
|
|
11721
|
+
return `${lines.join("\n")}\n`;
|
|
11722
|
+
}
|
|
11723
|
+
function formatSupportAdminUpdateTicket(data) {
|
|
11724
|
+
const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
|
|
11725
|
+
const ticket = data.ticket ?? {};
|
|
11726
|
+
const lines = [
|
|
11727
|
+
`${styles.bold("Ticket updated")}${ticket.subject ? styles.dim(` — ${ticket.subject}`) : ""}`,
|
|
11728
|
+
` Status: ${ticket.status ?? "?"}`,
|
|
11729
|
+
` Assignee: ${ticket.assignee_email ?? "(unassigned)"}`,
|
|
11730
|
+
];
|
|
11731
|
+
if (ticket.admin_web_url)
|
|
11732
|
+
lines.push(`View ticket: ${ticket.admin_web_url}`);
|
|
11733
|
+
return `${lines.join("\n")}\n`;
|
|
11734
|
+
}
|
|
10791
11735
|
function readCsvOption(value) {
|
|
10792
11736
|
const option = readOption(value);
|
|
10793
11737
|
if (!option)
|
|
@@ -10797,6 +11741,79 @@ function readCsvOption(value) {
|
|
|
10797
11741
|
.map((entry) => entry.trim())
|
|
10798
11742
|
.filter(Boolean);
|
|
10799
11743
|
}
|
|
11744
|
+
// Assemble the directory listing profile payload shared by `directory update`
|
|
11745
|
+
// and `admin directory enable`. --profile-json provides the base object (and
|
|
11746
|
+
// the only way to null-clear fields); explicit flags override it.
|
|
11747
|
+
async function buildDirectoryProfileBody(options) {
|
|
11748
|
+
const profile = options.profileJson
|
|
11749
|
+
? parseJsonObject(options.profileJson)
|
|
11750
|
+
: {};
|
|
11751
|
+
const stringFlags = [
|
|
11752
|
+
[options.name, "name"],
|
|
11753
|
+
[options.slug, "slug"],
|
|
11754
|
+
[options.logoUrl, "logo_url"],
|
|
11755
|
+
[options.tagline, "tagline"],
|
|
11756
|
+
[options.description, "description"],
|
|
11757
|
+
[options.website, "website_url"],
|
|
11758
|
+
[options.companyLinkedin, "linkedin_company_url"],
|
|
11759
|
+
[options.bookingUrl, "booking_url"],
|
|
11760
|
+
[options.contactEmail, "contact_email"],
|
|
11761
|
+
];
|
|
11762
|
+
for (const [value, key] of stringFlags) {
|
|
11763
|
+
const normalized = readOption(value);
|
|
11764
|
+
if (normalized)
|
|
11765
|
+
profile[key] = normalized;
|
|
11766
|
+
}
|
|
11767
|
+
if (!profile.description) {
|
|
11768
|
+
const path = readOption(options.descriptionFile);
|
|
11769
|
+
if (path) {
|
|
11770
|
+
const fs = await import("node:fs/promises");
|
|
11771
|
+
profile.description = await fs.readFile(path, "utf8");
|
|
11772
|
+
}
|
|
11773
|
+
}
|
|
11774
|
+
if (readOption(options.foundersJson)) {
|
|
11775
|
+
const founders = parseJsonValue(options.foundersJson ?? "[]", "founders-json");
|
|
11776
|
+
if (!Array.isArray(founders)) {
|
|
11777
|
+
throw new OxygenError("invalid_request", "--founders-json must be a JSON array of {name, title, linkedin_url} objects.", { exitCode: 1 });
|
|
11778
|
+
}
|
|
11779
|
+
profile.founders = founders;
|
|
11780
|
+
}
|
|
11781
|
+
const csvFlags = [
|
|
11782
|
+
[options.services, "services"],
|
|
11783
|
+
[options.regions, "regions"],
|
|
11784
|
+
];
|
|
11785
|
+
for (const [value, key] of csvFlags) {
|
|
11786
|
+
if (readOption(value))
|
|
11787
|
+
profile[key] = readCsvOption(value);
|
|
11788
|
+
}
|
|
11789
|
+
return profile;
|
|
11790
|
+
}
|
|
11791
|
+
function directoryLogoMimeType(extension) {
|
|
11792
|
+
switch (extension.toLowerCase()) {
|
|
11793
|
+
case ".png":
|
|
11794
|
+
return "image/png";
|
|
11795
|
+
case ".jpg":
|
|
11796
|
+
case ".jpeg":
|
|
11797
|
+
return "image/jpeg";
|
|
11798
|
+
case ".webp":
|
|
11799
|
+
return "image/webp";
|
|
11800
|
+
case ".svg":
|
|
11801
|
+
return "image/svg+xml";
|
|
11802
|
+
default:
|
|
11803
|
+
throw new OxygenError("invalid_request", "--logo-file must be a .png, .jpg, .jpeg, .webp, or .svg image.", { exitCode: 1 });
|
|
11804
|
+
}
|
|
11805
|
+
}
|
|
11806
|
+
async function uploadDirectoryLogo(logoPath) {
|
|
11807
|
+
const fs = await import("node:fs/promises");
|
|
11808
|
+
const path = await import("node:path");
|
|
11809
|
+
const buffer = await fs.readFile(logoPath);
|
|
11810
|
+
const form = new FormData();
|
|
11811
|
+
form.append("file", new File([buffer], path.basename(logoPath), { type: directoryLogoMimeType(path.extname(logoPath)) }));
|
|
11812
|
+
return await requestOxygen("/api/cli/directory/listing/logo", {
|
|
11813
|
+
method: "POST",
|
|
11814
|
+
formData: form,
|
|
11815
|
+
});
|
|
11816
|
+
}
|
|
10800
11817
|
// Assemble the optional campaign email binding from the --email-* flags. The
|
|
10801
11818
|
// content spec (--email-definition-file) is the author-provided email sequence
|
|
10802
11819
|
// that the API compiles to an Instantly campaign on start; provider/connection
|
|
@@ -11050,16 +12067,22 @@ options) {
|
|
|
11050
12067
|
else if (options.warmupRestart) {
|
|
11051
12068
|
warmup = { enabled: true };
|
|
11052
12069
|
}
|
|
12070
|
+
// Opinionated-safety knobs (P3): the warm-up preset curve + randomised daily caps.
|
|
12071
|
+
const warmupPreset = readOption(options.warmupPreset);
|
|
12072
|
+
const hasPreset = warmupPreset !== null && warmupPreset !== undefined;
|
|
12073
|
+
const hasRandomize = options.randomizeCaps !== undefined;
|
|
11053
12074
|
const hasLimits = Object.keys(limits).length > 0;
|
|
11054
12075
|
const hasWorkingHours = Object.keys(workingHours).length > 0;
|
|
11055
12076
|
const hasWarmup = warmup !== undefined;
|
|
11056
|
-
if (!hasLimits && !hasWorkingHours && !hasWarmup) {
|
|
11057
|
-
throw new OxygenError("invalid_request", "Pass at least one limit flag (e.g. --invites-per-day), --timezone,
|
|
12077
|
+
if (!hasLimits && !hasWorkingHours && !hasWarmup && !hasPreset && !hasRandomize) {
|
|
12078
|
+
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 });
|
|
11058
12079
|
}
|
|
11059
12080
|
return {
|
|
11060
12081
|
...(hasLimits ? { limits } : {}),
|
|
11061
12082
|
...(hasWorkingHours ? { working_hours: workingHours } : {}),
|
|
11062
12083
|
...(hasWarmup ? { warmup } : {}),
|
|
12084
|
+
...(hasPreset ? { warmup_preset: warmupPreset } : {}),
|
|
12085
|
+
...(hasRandomize ? { randomize_daily_caps: options.randomizeCaps } : {}),
|
|
11063
12086
|
};
|
|
11064
12087
|
}
|
|
11065
12088
|
function readPositiveInt(value) {
|
|
@@ -11137,6 +12160,27 @@ function readSequenceSettings(options) {
|
|
|
11137
12160
|
if (phoneColumnKey) {
|
|
11138
12161
|
settings.phone_column_key = phoneColumnKey;
|
|
11139
12162
|
}
|
|
12163
|
+
// stop_on_bounce defaults ON server-side; commander's `--no-stop-on-bounce`
|
|
12164
|
+
// sets this to false, so only send the key when the user explicitly opts OUT.
|
|
12165
|
+
// Leaving it absent keeps the safe default and a settings PATCH clean.
|
|
12166
|
+
if (options.stopOnBounce === false) {
|
|
12167
|
+
settings.stop_on_bounce = false;
|
|
12168
|
+
}
|
|
12169
|
+
const maxEmailsPerDay = readPositiveInteger(options.maxEmailsPerDay);
|
|
12170
|
+
if (maxEmailsPerDay !== undefined)
|
|
12171
|
+
settings.max_emails_per_day = maxEmailsPerDay;
|
|
12172
|
+
const maxNewEnrollmentsPerDay = readPositiveInteger(options.maxNewEnrollmentsPerDay);
|
|
12173
|
+
if (maxNewEnrollmentsPerDay !== undefined)
|
|
12174
|
+
settings.max_new_enrollments_per_day = maxNewEnrollmentsPerDay;
|
|
12175
|
+
const prioritization = readOption(options.sequencePrioritization);
|
|
12176
|
+
if (prioritization)
|
|
12177
|
+
settings.sequence_prioritization = prioritization;
|
|
12178
|
+
const scheduleTemplate = readOption(options.scheduleTemplate);
|
|
12179
|
+
if (scheduleTemplate)
|
|
12180
|
+
settings.schedule_template = scheduleTemplate;
|
|
12181
|
+
const espMatching = readOption(options.espMatching);
|
|
12182
|
+
if (espMatching)
|
|
12183
|
+
settings.esp_matching = espMatching;
|
|
11140
12184
|
return Object.keys(settings).length > 0 ? settings : undefined;
|
|
11141
12185
|
}
|
|
11142
12186
|
function muteTokenEcho() {
|