@oxygen-agent/cli 1.750.4 → 1.782.1
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/command-manifest.js +11 -1
- package/dist/help.js +8 -0
- package/dist/index.js +492 -96
- package/node_modules/@oxygen/shared/dist/billing.d.ts +88 -46
- package/node_modules/@oxygen/shared/dist/billing.js +134 -74
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +12 -4
- package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
- package/node_modules/@oxygen/shared/dist/copilot-journeys.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/copilot-journeys.js +23 -5
- package/node_modules/@oxygen/shared/dist/future-signup-events.d.ts +13 -2
- package/node_modules/@oxygen/shared/dist/future-signup-events.js +17 -2
- package/node_modules/@oxygen/shared/dist/future-signup-lifecycle-projection.d.ts +106 -1
- package/node_modules/@oxygen/shared/dist/future-signup-lifecycle-projection.js +156 -45
- package/node_modules/@oxygen/shared/dist/index.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/index.js +5 -0
- package/node_modules/@oxygen/shared/dist/object-storage.d.ts +33 -0
- package/node_modules/@oxygen/shared/dist/object-storage.js +69 -4
- package/node_modules/@oxygen/shared/dist/person-name.d.ts +40 -0
- package/node_modules/@oxygen/shared/dist/person-name.js +23 -0
- package/node_modules/@oxygen/shared/dist/plan-capabilities.d.ts +82 -0
- package/node_modules/@oxygen/shared/dist/plan-capabilities.js +130 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +2 -2
- package/node_modules/@oxygen/shared/dist/plan-limits.js +18 -2
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +50 -56
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +77 -90
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +62 -0
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +91 -0
- package/node_modules/@oxygen/shared/dist/provider-funding-errors.d.ts +44 -0
- package/node_modules/@oxygen/shared/dist/provider-funding-errors.js +81 -0
- package/node_modules/@oxygen/shared/dist/publishing-limits.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/publishing-limits.js +24 -0
- package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +27 -6
- package/node_modules/@oxygen/shared/dist/spend-safety.js +34 -6
- package/node_modules/@oxygen/shared/dist/table-capacity.d.ts +13 -0
- package/node_modules/@oxygen/shared/dist/table-capacity.js +14 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +6 -3
- package/node_modules/@oxygen/shared/package.json +10 -0
- package/node_modules/@oxygen/workflows/dist/graph/diff.d.ts +33 -0
- package/node_modules/@oxygen/workflows/dist/graph/diff.js +75 -0
- package/node_modules/@oxygen/workflows/dist/graph/expression.js +17 -1
- package/node_modules/@oxygen/workflows/dist/graph/index.d.ts +1 -0
- package/node_modules/@oxygen/workflows/dist/graph/index.js +1 -0
- package/node_modules/@oxygen/workflows/dist/graph/lint.js +30 -28
- package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +2 -2
- package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.js +1 -1
- package/node_modules/@oxygen/workflows/dist/graph/remap.js +0 -5
- package/node_modules/@oxygen/workflows/dist/graph/topology.d.ts +27 -0
- package/node_modules/@oxygen/workflows/dist/graph/topology.js +95 -0
- package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +27 -8
- package/node_modules/@oxygen/workflows/dist/graph/types.js +2 -4
- package/node_modules/@oxygen/workflows/dist/index.js +0 -1
- package/node_modules/@oxygen/workflows/dist/portable.js +0 -9
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -416,6 +416,7 @@ async function handleAsyncAction(command, options, action) {
|
|
|
416
416
|
const data = await action();
|
|
417
417
|
emitSuccess(command, data, options);
|
|
418
418
|
writeDryRunNotice(data);
|
|
419
|
+
writeAvatarWarning(data);
|
|
419
420
|
writeCreditsReceipt(data);
|
|
420
421
|
}
|
|
421
422
|
catch (error) {
|
|
@@ -449,6 +450,19 @@ function writeDryRunNotice(data) {
|
|
|
449
450
|
if (typeof block.next_step === "string")
|
|
450
451
|
process.stderr.write(`${block.next_step}\n`);
|
|
451
452
|
}
|
|
453
|
+
// A sender-profile write that could not durably host the photo still succeeds —
|
|
454
|
+
// the profile is saved, just with a picture no inbox vendor will ever be able to
|
|
455
|
+
// fetch. That is the one failure mode nobody notices until the mailboxes ship
|
|
456
|
+
// faceless and the vendor refuses to change them, so the server's
|
|
457
|
+
// `avatar_warning` gets its own stderr line instead of living only inside the
|
|
458
|
+
// stdout envelope a human never reads.
|
|
459
|
+
function writeAvatarWarning(data) {
|
|
460
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
461
|
+
return;
|
|
462
|
+
const warning = data.avatar_warning;
|
|
463
|
+
if (typeof warning === "string" && warning.trim())
|
|
464
|
+
process.stderr.write(`${warning}\n`);
|
|
465
|
+
}
|
|
452
466
|
// Paid envelopes (push 3 legibility) carry a `credits` block: quote on
|
|
453
467
|
// dry_run, receipt on live, remaining balance on both. Mirror it as one
|
|
454
468
|
// stderr line so spend stays visible in a terminal without polluting the
|
|
@@ -801,6 +815,23 @@ function writeMaxCreditsHint(error) {
|
|
|
801
815
|
if (!(error instanceof OxygenError))
|
|
802
816
|
return;
|
|
803
817
|
switch (error.code) {
|
|
818
|
+
// The plan wall. Unlike every other case in this switch, no flag on the
|
|
819
|
+
// command can unblock it — so the hint names the capability in the
|
|
820
|
+
// customer's own words, says what still works without upgrading, and gives
|
|
821
|
+
// the URL. A raw `upgrade_required` code with a JSON blob is the worst
|
|
822
|
+
// possible version of the single most important refusal in the product.
|
|
823
|
+
case "upgrade_required": {
|
|
824
|
+
const label = readDetailsString(error.details, "capability_label")
|
|
825
|
+
?? "This capability";
|
|
826
|
+
const upgradeUrl = readDetailsString(error.details, "upgrade_url");
|
|
827
|
+
const nextStep = readDetailsString(error.details, "next_step");
|
|
828
|
+
process.stderr.write(`hint: ${label} needs a paid plan.\n`);
|
|
829
|
+
if (nextStep)
|
|
830
|
+
process.stderr.write(`hint: ${nextStep}\n`);
|
|
831
|
+
if (upgradeUrl)
|
|
832
|
+
process.stderr.write(`hint: upgrade at ${upgradeUrl}\n`);
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
804
835
|
case "effect_unknown_approval_required": {
|
|
805
836
|
process.stderr.write("hint: verify every affected external destination in error.details, then re-run with --approved-effect-unknown only if another execution is safe\n");
|
|
806
837
|
return;
|
|
@@ -1687,6 +1718,55 @@ function buildPublishingPostsDraftBody(options) {
|
|
|
1687
1718
|
body.source_url = sourceUrl;
|
|
1688
1719
|
return body;
|
|
1689
1720
|
}
|
|
1721
|
+
// `linkedin intent autoenroll` request body. The route reads `captures` (the wire
|
|
1722
|
+
// name of the capture kinds) plus `post_id`/`post`, so the CSV `--kinds` flag maps
|
|
1723
|
+
// onto `captures` here rather than inventing a second server field.
|
|
1724
|
+
//
|
|
1725
|
+
// An omitted `--kinds` sends NO captures key at all: the server then defaults to the
|
|
1726
|
+
// captures `linkedin intent setup` already provisioned for this sender, and a
|
|
1727
|
+
// client-side default list would instead CREATE captures the user never agreed to.
|
|
1728
|
+
// Same reasoning for the caps — omitted means "inherit the plan-tier ceiling and the
|
|
1729
|
+
// sender's ramped daily cap", which only the server can resolve.
|
|
1730
|
+
function buildLinkedinIntentAutoenrollBody(options) {
|
|
1731
|
+
const account = readOption(options.account);
|
|
1732
|
+
if (!account)
|
|
1733
|
+
throw new Error("--account is required.");
|
|
1734
|
+
if (options.disable && options.approved) {
|
|
1735
|
+
throw new OxygenError("conflicting_flags", "Pass either --disable (revoke the standing grant) or --approved (arm it), not both.", { exitCode: 1 });
|
|
1736
|
+
}
|
|
1737
|
+
// Revoking only ever narrows authority, so it carries the scope and nothing else —
|
|
1738
|
+
// forwarding caps or a sequence on a disable would imply they still bind something.
|
|
1739
|
+
const kinds = readCsvOption(options.kinds);
|
|
1740
|
+
const post = readOption(options.post);
|
|
1741
|
+
if (options.disable) {
|
|
1742
|
+
return {
|
|
1743
|
+
account,
|
|
1744
|
+
disable: true,
|
|
1745
|
+
...(kinds.length > 0 ? { captures: kinds } : {}),
|
|
1746
|
+
...(post ? { post } : {}),
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
const sequence = readOption(options.sequence);
|
|
1750
|
+
const postUrl = readOption(options.postUrl);
|
|
1751
|
+
// Both readers THROW on a typed-but-unusable cap (0, negative, non-numeric) rather
|
|
1752
|
+
// than returning undefined, which matters here: undefined means "inherit the server
|
|
1753
|
+
// default", so coercing a typed `--max-enrolls-per-day 0` to absent would answer a
|
|
1754
|
+
// request to enrol NOBODY by granting the sender's full ramped daily cap. Narrowing
|
|
1755
|
+
// must never widen. Pinned by the cap tests in index.test.ts.
|
|
1756
|
+
const maxCreditsPerCycle = readPositiveNumber(options.maxCreditsPerCycle);
|
|
1757
|
+
const maxEnrollsPerDay = readPositiveInt(options.maxEnrollsPerDay);
|
|
1758
|
+
return {
|
|
1759
|
+
account,
|
|
1760
|
+
...(sequence ? { sequence } : {}),
|
|
1761
|
+
...(kinds.length > 0 ? { captures: kinds } : {}),
|
|
1762
|
+
...(maxCreditsPerCycle !== undefined ? { max_credits_per_cycle: maxCreditsPerCycle } : {}),
|
|
1763
|
+
...(maxEnrollsPerDay !== undefined ? { max_enrolls_per_day: maxEnrollsPerDay } : {}),
|
|
1764
|
+
...(options.includeExistingNetwork ? { include_existing_network: true } : {}),
|
|
1765
|
+
...(post ? { post } : {}),
|
|
1766
|
+
...(postUrl ? { post_url: postUrl } : {}),
|
|
1767
|
+
...(options.approved ? { approved: true } : {}),
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1690
1770
|
function buildSequenceDraftBody(options) {
|
|
1691
1771
|
const goal = readOption(options.goal);
|
|
1692
1772
|
if (!goal)
|
|
@@ -2230,6 +2310,87 @@ function requireDomainArg(positional, option) {
|
|
|
2230
2310
|
}
|
|
2231
2311
|
return domain;
|
|
2232
2312
|
}
|
|
2313
|
+
/**
|
|
2314
|
+
* Build the `mailboxes[]` an order route receives, from whichever input form the
|
|
2315
|
+
* caller used, plus the optional `--sender` stamp.
|
|
2316
|
+
*
|
|
2317
|
+
* Shared by `managed-inboxes subscribe` and `add-inboxes` because the two used to
|
|
2318
|
+
* drift: only one of them accepted the `--count/--prefix` shorthand, and each
|
|
2319
|
+
* assembled its own body, so a fix to one silently missed the other. `forms`
|
|
2320
|
+
* parameterises which spellings the calling command accepts instead.
|
|
2321
|
+
*
|
|
2322
|
+
* The `--sender` stamp is the reason this is one function. The inbox vendor has no
|
|
2323
|
+
* post-provisioning update, so whatever first/last name reaches it is permanent —
|
|
2324
|
+
* which is why a sender-backed item must not also carry a typed name. The server
|
|
2325
|
+
* ignores those keys when `sender_profile_id` is present, but a request body that
|
|
2326
|
+
* still lists `first_name: "Ada"` reads like Ada is what ships. Strip them here so
|
|
2327
|
+
* the body says exactly what the vendor will stamp.
|
|
2328
|
+
*/
|
|
2329
|
+
function buildManagedInboxMailboxes(options, forms) {
|
|
2330
|
+
const senderProfileId = readOption(options.sender);
|
|
2331
|
+
const mailboxesJson = readOption(options.mailboxes);
|
|
2332
|
+
const filePath = readOption(options.file);
|
|
2333
|
+
const locals = splitCommaList(options.locals);
|
|
2334
|
+
const prefix = readOption(options.prefix);
|
|
2335
|
+
const count = readPositiveInt(options.count);
|
|
2336
|
+
let mailboxes;
|
|
2337
|
+
if (mailboxesJson) {
|
|
2338
|
+
mailboxes = JSON.parse(mailboxesJson);
|
|
2339
|
+
}
|
|
2340
|
+
else if (filePath) {
|
|
2341
|
+
const parsed = readJsonFileValue(resolve(filePath), "--file");
|
|
2342
|
+
mailboxes = parsed.mailboxes ?? [];
|
|
2343
|
+
}
|
|
2344
|
+
else if (locals.length > 0) {
|
|
2345
|
+
if (!senderProfileId) {
|
|
2346
|
+
// Without a sender there is no identity to attach to a bare local part, and
|
|
2347
|
+
// the vendor stamps a name on every mailbox permanently. Refusing is the
|
|
2348
|
+
// only honest answer: inventing "Talia Rosen" from `talia.rosen` would bake
|
|
2349
|
+
// a guess into a mailbox nobody can rename later.
|
|
2350
|
+
throw new OxygenError("invalid_request", "--locals needs --sender <sender profile id>: the local parts carry no name, and the inbox vendor stamps a permanent one. Pass --mailboxes/--file with real first_name/last_name instead, or `oxygen senders profiles list` to pick a sender.", { exitCode: 2 });
|
|
2351
|
+
}
|
|
2352
|
+
mailboxes = locals.map((username) => ({ username }));
|
|
2353
|
+
}
|
|
2354
|
+
else if (forms.countPrefix && (count !== undefined || prefix)) {
|
|
2355
|
+
if (count === undefined || !prefix) {
|
|
2356
|
+
throw new Error("--count and --prefix must be passed together (e.g. --count 3 --prefix ada).");
|
|
2357
|
+
}
|
|
2358
|
+
const base = prefix.toLowerCase();
|
|
2359
|
+
if (senderProfileId) {
|
|
2360
|
+
// With a sender the shorthand finally has a real identity behind it, so it
|
|
2361
|
+
// generates usernames only — the placeholder names below are exactly what
|
|
2362
|
+
// this flag exists to stop shipping.
|
|
2363
|
+
mailboxes = Array.from({ length: count }, (_entry, index) => ({ username: `${base}${index + 1}` }));
|
|
2364
|
+
}
|
|
2365
|
+
else {
|
|
2366
|
+
// The vendor stamps a first/last name on every mailbox, so there is no
|
|
2367
|
+
// name-less order shape to fall back on — the shorthand invents
|
|
2368
|
+
// placeholders rather than pretending names are optional. Anyone who
|
|
2369
|
+
// wants real human identities on the inboxes passes --sender (best),
|
|
2370
|
+
// or --mailboxes/--file.
|
|
2371
|
+
const label = `${base.charAt(0).toUpperCase()}${base.slice(1)}`;
|
|
2372
|
+
mailboxes = Array.from({ length: count }, (_entry, index) => ({
|
|
2373
|
+
username: `${base}${index + 1}`,
|
|
2374
|
+
first_name: label,
|
|
2375
|
+
last_name: String(index + 1),
|
|
2376
|
+
}));
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
else {
|
|
2380
|
+
const spellings = ["--mailboxes <json>", "--file <path>", "--locals <list> --sender <id>"];
|
|
2381
|
+
if (forms.countPrefix)
|
|
2382
|
+
spellings.push("--count <n> --prefix <base>");
|
|
2383
|
+
throw new Error(`Provide ${spellings.join(", ")}.`);
|
|
2384
|
+
}
|
|
2385
|
+
if (!senderProfileId || !Array.isArray(mailboxes))
|
|
2386
|
+
return mailboxes;
|
|
2387
|
+
return mailboxes.map((entry) => {
|
|
2388
|
+
if (!isRecord(entry))
|
|
2389
|
+
return entry;
|
|
2390
|
+
const { first_name: _firstName, last_name: _lastName, profile_picture_url: _profilePictureUrl, ...rest } = entry;
|
|
2391
|
+
return { ...rest, sender_profile_id: senderProfileId };
|
|
2392
|
+
});
|
|
2393
|
+
}
|
|
2233
2394
|
function buildPromptTemplatesCommand(surface, description) {
|
|
2234
2395
|
return new Command(surface)
|
|
2235
2396
|
.description(description)
|
|
@@ -2451,11 +2612,29 @@ export function createProgram() {
|
|
|
2451
2612
|
});
|
|
2452
2613
|
program
|
|
2453
2614
|
.command("home")
|
|
2454
|
-
.description("Your workspace standup: what happened since you last looked, what needs you,
|
|
2615
|
+
.description("Your workspace standup: what happened since you last looked, what needs you, a year of workspace activity, and — under `activation` — the one prescribed step to take next. Read-only, 0 credits. The same composition the web Home renders, so a terminal-first operator is not sent to the browser for a status check.")
|
|
2616
|
+
.option("--json", "Print a JSON envelope.")
|
|
2617
|
+
.action(async (options) => {
|
|
2618
|
+
await handleAsyncAction("home standup", options, readHomeStandup);
|
|
2619
|
+
});
|
|
2620
|
+
const ACTIVATION_DESCRIPTION = "Where this workspace stands in the prescribed activation play (inbound-led outbound) and the ONE thing to do next: each step's state, what blocks a blocked one, the exact CLI / MCP tool / API call for the next step, and an honest pacing note — LinkedIn is read on a metered drip and a sender's warm-up ramp, not the size of your list, sets how fast anyone is contacted. Read-only, 0 credits. Read the play itself with `oxygen recipes list --stage day-1 --json`.";
|
|
2621
|
+
// Bare `oxygen activation` is the spelling a founder guesses; `activation state`
|
|
2622
|
+
// is the exact name discovery routes to (it is a gatewayCommand of the
|
|
2623
|
+
// onboarding-and-copilot capability route). Both call the one read.
|
|
2624
|
+
const activationCommand = program
|
|
2625
|
+
.command("activation")
|
|
2626
|
+
.description(ACTIVATION_DESCRIPTION)
|
|
2455
2627
|
.option("--json", "Print a JSON envelope.")
|
|
2456
2628
|
.action(async (options) => {
|
|
2457
|
-
await handleAsyncAction("
|
|
2629
|
+
await handleAsyncAction("activation state", options, () => requestOxygen("/api/cli/activation/state"));
|
|
2458
2630
|
});
|
|
2631
|
+
activationCommand.addCommand(new Command("state")
|
|
2632
|
+
.description(ACTIVATION_DESCRIPTION)
|
|
2633
|
+
.option("--json", "Print a JSON envelope.")
|
|
2634
|
+
.action(async (options) => {
|
|
2635
|
+
const outputOptions = { ...options, json: options.json || Boolean(activationCommand.opts().json) };
|
|
2636
|
+
await handleAsyncAction("activation state", outputOptions, () => requestOxygen("/api/cli/activation/state"));
|
|
2637
|
+
}));
|
|
2459
2638
|
const commandsCommand = program
|
|
2460
2639
|
.command("commands")
|
|
2461
2640
|
.description("Discover CLI commands. With no subcommand, print the backwards-compatible full manifest.")
|
|
@@ -8037,7 +8216,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
8037
8216
|
.command("limits")
|
|
8038
8217
|
.description("Plan-tier limits, spend-safety defaults, and storage capacity posture.")
|
|
8039
8218
|
.addCommand(new Command("show")
|
|
8040
|
-
.description("Show
|
|
8219
|
+
.description("Show limits and Tables capacity usage: 3M rows/Table, 25M/workspace, and 20/30 GiB PostgreSQL warning/limit; S3 excluded. Recovery: https://oxygen-agent.com/docs/safety/billing.")
|
|
8041
8220
|
.option("--json", "Print a JSON envelope.")
|
|
8042
8221
|
.action(async (options) => {
|
|
8043
8222
|
await handleAsyncAction("limits show", options, () => requestOxygen("/api/cli/limits"));
|
|
@@ -8314,9 +8493,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
8314
8493
|
await handleAsyncAction("egress ips", options, () => requestOxygen("/api/cli/egress?view=ips"));
|
|
8315
8494
|
}))
|
|
8316
8495
|
.addCommand(new Command("dedicated")
|
|
8317
|
-
.description("Dedicated sending-IP add-on (
|
|
8496
|
+
.description("Dedicated sending-IP add-on (25,000 credits per 30-day period): preview the quote, order with `request --approved`, cancel with `cancel --approve`, or check the add-on status.")
|
|
8318
8497
|
.addCommand(new Command("request")
|
|
8319
|
-
.description("Preview the dedicated sending-IP add-on (
|
|
8498
|
+
.description("Preview the dedicated sending-IP add-on (25,000 credits per 30-day period, $25 face value; the org's mailboxes share the one dedicated IP). With --approved, ORDER it: the IP is provisioned automatically, the first period's credits are debited immediately, and the next debit falls 30 days after the order — not on the 1st of the month. Every mailbox you add from then on is pinned to the IP automatically; the mailboxes you already have stay on their current IP unless you pass --move-existing. Without --approved nothing is ordered or charged. While the platform vendor account is not provisioned yet, ordering fails closed and the preview says so.")
|
|
8320
8499
|
// --approved (not --approve): the credit-spending approval flag,
|
|
8321
8500
|
// which is also what derives spends_credits in the self-index.
|
|
8322
8501
|
.option("--approved", "Execute the order (a real recurring credit charge). Omit for a no-side-effect preview.")
|
|
@@ -8628,6 +8807,56 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
8628
8807
|
method: "POST",
|
|
8629
8808
|
body: { organization },
|
|
8630
8809
|
}));
|
|
8810
|
+
})))
|
|
8811
|
+
.addCommand(new Command("automation-usage")
|
|
8812
|
+
.description("Automation-actions meter integrity: the windows whose counter disagrees with their event ledger, and the audited adjustment that closes one. Staff only.")
|
|
8813
|
+
.addCommand(new Command("divergences")
|
|
8814
|
+
.description("List every automation-usage window whose counter and event ledger disagree, including windows that have outlived the detector's lookback and can only be cleared by a repair. Writes no state — but the scan IS the detector, so each call re-emits its aged-out divergence log event once per window that has outlived the lookback (tagged surface=cli, so operator reads stay separable from the 2-hourly cron's own emissions); the envelope reports what it emitted. Bounds default to the cron's; narrow them if the scan approaches its budget on a large estate.")
|
|
8815
|
+
.option("--lookback-days <days>", "Narrow the recent-window scan to this many days back. Defaults to the detector's own lookback. The aged-out scan has no time floor either way, so no divergence can hide behind a shorter lookback.")
|
|
8816
|
+
.option("--limit <windows>", "Cap the recent-window scan. Defaults to the detector's own cap. Truncation is reported as `truncated`, never silent.")
|
|
8817
|
+
.option("--aged-out-limit <windows>", "Cap the aged-out scan, which is ordered oldest-first so a cap only ever drops the newest rows. Reported as `aged_out_truncated`.")
|
|
8818
|
+
.option("--json", "Print a JSON envelope.")
|
|
8819
|
+
.action(async (options) => {
|
|
8820
|
+
await handleAsyncAction("admin automation-usage divergences", options, () => {
|
|
8821
|
+
const params = new URLSearchParams();
|
|
8822
|
+
const lookbackDays = readPositiveInteger(options.lookbackDays);
|
|
8823
|
+
const limit = readPositiveInteger(options.limit);
|
|
8824
|
+
const agedOutLimit = readPositiveInteger(options.agedOutLimit);
|
|
8825
|
+
if (lookbackDays !== undefined)
|
|
8826
|
+
params.set("lookback_days", String(lookbackDays));
|
|
8827
|
+
if (limit !== undefined)
|
|
8828
|
+
params.set("limit", String(limit));
|
|
8829
|
+
if (agedOutLimit !== undefined)
|
|
8830
|
+
params.set("aged_out_limit", String(agedOutLimit));
|
|
8831
|
+
const qs = params.toString() ? `?${params.toString()}` : "";
|
|
8832
|
+
return requestOxygen(`/api/cli/admin/automation-usage${qs}`);
|
|
8833
|
+
});
|
|
8834
|
+
}))
|
|
8835
|
+
.addCommand(new Command("reconcile")
|
|
8836
|
+
.description("Write the one audited adjustment that closes a window's counter/ledger divergence. Deliberate by construction: it needs an actor (your staff identity), a reason, and the divergence you observed — the repair refuses if the live numbers have moved since, derives the delta itself, and replays a retry into the same single ledger row. The raise-only counter is never touched; the ledger is brought to meet it, which for a counter reading BELOW its ledger means appending a negative adjustment that writes those actions off the meter of record. The ledger is append-only: the entry cannot be removed afterwards, only offset by a further adjustment. Run `admin automation-usage divergences` first — that listing's `divergence` is the exact quantity this will append.")
|
|
8837
|
+
.requiredOption("--organization <id>", "Organization id the divergence was reported for.")
|
|
8838
|
+
.requiredOption("--window-start <iso>", "Exact period start of the divergent window, e.g. 2026-07-01T00:00:00Z. A rounded value matches no window rather than repairing a neighbouring one.")
|
|
8839
|
+
.requiredOption("--expected-divergence <actions>", "The divergence you are repairing (counter - ledger), signed, straight from `admin automation-usage divergences`. Required so a repair computed off a stale alert aborts instead of writing the wrong delta.")
|
|
8840
|
+
.requiredOption("--reason <text>", "Why this correction is being made. Recorded on the ledger event — no adjustment enters the books anonymously.")
|
|
8841
|
+
.option("--incident-ref <ref>", "Incident this repairs, e.g. OXY-4100.")
|
|
8842
|
+
.option("--idempotency-key <key>", "Pin the replay key. Defaults to (billing owner, window, divergence), which already makes a retry safe.")
|
|
8843
|
+
.option("--json", "Print a JSON envelope.")
|
|
8844
|
+
.action(async (options) => {
|
|
8845
|
+
await handleAsyncAction("admin automation-usage reconcile", options, () => {
|
|
8846
|
+
const incidentRef = readOption(options.incidentRef);
|
|
8847
|
+
const idempotencyKey = readOption(options.idempotencyKey);
|
|
8848
|
+
return requestOxygen("/api/cli/admin/automation-usage", {
|
|
8849
|
+
method: "POST",
|
|
8850
|
+
body: {
|
|
8851
|
+
organization: readOption(options.organization),
|
|
8852
|
+
window_start: readOption(options.windowStart),
|
|
8853
|
+
expected_divergence: readSignedNumber(options.expectedDivergence),
|
|
8854
|
+
reason: readOption(options.reason),
|
|
8855
|
+
...(incidentRef ? { incident_ref: incidentRef } : {}),
|
|
8856
|
+
...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
|
|
8857
|
+
},
|
|
8858
|
+
});
|
|
8859
|
+
});
|
|
8631
8860
|
})));
|
|
8632
8861
|
program
|
|
8633
8862
|
.command("signup-leads")
|
|
@@ -10134,7 +10363,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10134
10363
|
});
|
|
10135
10364
|
})))
|
|
10136
10365
|
.addCommand(new Command("profiles")
|
|
10137
|
-
.description("Sender profiles: group a person's LinkedIn + WhatsApp senders and email inboxes into one sending identity. A sequence can then send every channel from the same persona. Manage: list, get, create, update, attach/detach accounts, delete.")
|
|
10366
|
+
.description("Sender profiles: group a person's LinkedIn + WhatsApp senders and email inboxes into one sending identity — one name, one first/last name, one photo. A sequence can then send every channel from the same persona, and `oxygen managed-inboxes subscribe/add-inboxes --sender <id>` orders new mailboxes under that identity instead of making you retype it per mailbox. Manage: list, get, create, update, set-photo, attach/detach accounts, delete.")
|
|
10138
10367
|
.addCommand(new Command("list")
|
|
10139
10368
|
.description("List sender profiles with their per-channel account counts (LinkedIn / WhatsApp / inboxes).")
|
|
10140
10369
|
.option("--status <status>", "Filter by status: active, paused, or archived.")
|
|
@@ -10172,10 +10401,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10172
10401
|
await handleAsyncAction("senders profiles get", options, () => requestOxygen(`/api/cli/senders/profiles/${encodeURIComponent(id)}`));
|
|
10173
10402
|
}))
|
|
10174
10403
|
.addCommand(new Command("create")
|
|
10175
|
-
.description("Create a sender profile. --from-linkedin seeds the name + avatar from a LinkedIn account and attaches it
|
|
10404
|
+
.description("Create a sender profile: one person's sending identity — their LinkedIn/WhatsApp senders and email inboxes under one name and photo. --from-linkedin seeds the name + avatar from a LinkedIn account and attaches it; then assign inboxes and WhatsApp with --mailboxes / --senders. Without --from-linkedin, --name is required. Give the profile --first-name and --last-name too: those are the exact names the inbox vendor stamps when you order mailboxes with `oxygen managed-inboxes subscribe --sender <id>`, and the vendor has no way to change a name after provisioning.")
|
|
10176
10405
|
.option("--name <name>", "Display name for the profile. Optional when --from-linkedin is given (derived from the LinkedIn account).")
|
|
10177
|
-
.option("--
|
|
10178
|
-
.option("--
|
|
10406
|
+
.option("--first-name <name>", "The person's first name, as it should appear on mailboxes ordered for this sender. Defaults to splitting --name / the LinkedIn name on the first space.")
|
|
10407
|
+
.option("--last-name <name>", "The person's last name, as it should appear on mailboxes ordered for this sender. Ordering managed inboxes for this sender needs both names; a one-word name leaves it blank and the order is refused rather than guessed.")
|
|
10408
|
+
.option("--from-linkedin <senderId>", "Seed the profile from this LinkedIn/WhatsApp sender account id: derives the name and MIRRORS the LinkedIn photo into Oxygen storage (LinkedIn's own URL expires, so a mirrored copy is what an inbox vendor can still fetch hours later) and attaches the account.")
|
|
10409
|
+
.option("--avatar-url <url>", "Use this image URL as the profile photo instead of the LinkedIn one. Must be a public https URL. An expiring link (e.g. media.licdn.com with e=<epoch>) is stored but marked non-durable and is NOT sent to an inbox vendor — use `set-photo --file` to host a permanent copy.")
|
|
10179
10410
|
.option("--status <status>", "Initial status: active (default), paused, or archived.")
|
|
10180
10411
|
.option("--senders <ids>", "Comma-separated sender account ids (LinkedIn/WhatsApp) to attach.")
|
|
10181
10412
|
.option("--mailboxes <ids>", "Comma-separated email mailbox ids to attach.")
|
|
@@ -10185,6 +10416,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10185
10416
|
method: "POST",
|
|
10186
10417
|
body: {
|
|
10187
10418
|
...(readOption(options.name) ? { name: readOption(options.name) } : {}),
|
|
10419
|
+
...(readOption(options.firstName) ? { first_name: readOption(options.firstName) } : {}),
|
|
10420
|
+
...(readOption(options.lastName) ? { last_name: readOption(options.lastName) } : {}),
|
|
10188
10421
|
...(readOption(options.fromLinkedin) ? { from_sender_account_id: readOption(options.fromLinkedin) } : {}),
|
|
10189
10422
|
...(readOption(options.avatarUrl) ? { avatar_url: readOption(options.avatarUrl) } : {}),
|
|
10190
10423
|
...(readOption(options.status) ? { status: readOption(options.status) } : {}),
|
|
@@ -10194,10 +10427,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10194
10427
|
}));
|
|
10195
10428
|
}))
|
|
10196
10429
|
.addCommand(new Command("update")
|
|
10197
|
-
.description("Update a sender profile's own fields (name, avatar, status). Use `attach`/`detach` to change its accounts.")
|
|
10430
|
+
.description("Update a sender profile's own fields (display name, first/last name, avatar, status). Use `attach`/`detach` to change its accounts, and `set-photo` for the full photo menu (upload a file, mirror LinkedIn again, or clear).")
|
|
10198
10431
|
.argument("<id>", "Sender profile id.")
|
|
10199
10432
|
.option("--name <name>", "New display name.")
|
|
10200
|
-
.option("--
|
|
10433
|
+
.option("--first-name <name>", "New first name — the exact one an inbox vendor stamps on mailboxes ordered for this sender.")
|
|
10434
|
+
.option("--last-name <name>", "New last name — the exact one an inbox vendor stamps on mailboxes ordered for this sender. Fix it BEFORE ordering: the vendor cannot rename a mailbox afterwards.")
|
|
10435
|
+
.option("--avatar-url <url>", "New avatar image URL (empty string clears it). Must be a public https URL; an expiring link is stored but marked non-durable and is NOT sent to an inbox vendor.")
|
|
10201
10436
|
.option("--status <status>", "New status: active, paused, or archived.")
|
|
10202
10437
|
.option("--json", "Print a JSON envelope.")
|
|
10203
10438
|
.action(async (id, options) => {
|
|
@@ -10205,10 +10440,57 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10205
10440
|
method: "PATCH",
|
|
10206
10441
|
body: {
|
|
10207
10442
|
...(readOption(options.name) ? { name: readOption(options.name) } : {}),
|
|
10443
|
+
...(readOption(options.firstName) ? { first_name: readOption(options.firstName) } : {}),
|
|
10444
|
+
...(readOption(options.lastName) ? { last_name: readOption(options.lastName) } : {}),
|
|
10208
10445
|
...(options.avatarUrl !== undefined ? { avatar_url: readOption(options.avatarUrl) ?? "" } : {}),
|
|
10209
10446
|
...(readOption(options.status) ? { status: readOption(options.status) } : {}),
|
|
10210
10447
|
},
|
|
10211
10448
|
}));
|
|
10449
|
+
}))
|
|
10450
|
+
.addCommand(new Command("set-photo")
|
|
10451
|
+
.description("Set the sender's profile picture — the face that goes on every mailbox you order for this person. Pick exactly one source: --file (upload your own image; Oxygen hosts it permanently), --url (a public https image), --from-linkedin (re-mirror the photo from the attached LinkedIn account), or --clear. Hosting matters: an inbox vendor fetches the photo from its own servers HOURS after the order, so a LinkedIn CDN link (media.licdn.com, e=<epoch>) has already expired by then and the mailbox ships faceless. --file and --from-linkedin both end up Oxygen-hosted, which is why they are the safe choices. Free — no credits.")
|
|
10452
|
+
.argument("<id>", "Sender profile id (from `oxygen senders profiles list`).")
|
|
10453
|
+
.option("--file <path>", "Upload a PNG, JPEG, or WebP from disk (max 8MB) and use it. Oxygen hosts the image permanently, so an inbox vendor can still fetch it at provisioning time.")
|
|
10454
|
+
.option("--url <url>", "Use an image already published at a public https URL. If the link expires (e.g. media.licdn.com with e=<epoch>) the photo is kept for Oxygen's own UI but marked non-durable and NEVER sent to an inbox vendor.")
|
|
10455
|
+
.option("--from-linkedin", "Re-mirror the photo from this profile's attached LinkedIn account into Oxygen storage. Use it when the person changed their LinkedIn picture.")
|
|
10456
|
+
.option("--clear", "Remove the photo. Mailboxes ordered afterwards ship without one.")
|
|
10457
|
+
.option("--json", "Print a JSON envelope.")
|
|
10458
|
+
.action(async (id, options) => {
|
|
10459
|
+
await handleAsyncAction("senders profiles set-photo", options, async () => {
|
|
10460
|
+
const filePath = readOption(options.file);
|
|
10461
|
+
const url = readOption(options.url);
|
|
10462
|
+
const chosen = [
|
|
10463
|
+
filePath ? "--file" : null,
|
|
10464
|
+
url ? "--url" : null,
|
|
10465
|
+
options.fromLinkedin ? "--from-linkedin" : null,
|
|
10466
|
+
options.clear ? "--clear" : null,
|
|
10467
|
+
].filter((flag) => flag !== null);
|
|
10468
|
+
// Refuse locally rather than letting the server pick a winner: the
|
|
10469
|
+
// photo is what an inbox vendor stamps permanently, so "which source
|
|
10470
|
+
// won" is never a question a caller should have to reverse-engineer
|
|
10471
|
+
// from the response.
|
|
10472
|
+
if (chosen.length === 0) {
|
|
10473
|
+
throw new OxygenError("invalid_request", "Pass one photo source: --file <path>, --url <https url>, --from-linkedin, or --clear.", { exitCode: 2 });
|
|
10474
|
+
}
|
|
10475
|
+
if (chosen.length > 1) {
|
|
10476
|
+
throw new OxygenError("conflicting_flags", `Pass exactly one photo source, not ${chosen.join(" + ")}.`, { exitCode: 2 });
|
|
10477
|
+
}
|
|
10478
|
+
// --file stages the bytes first, then finishes on the profile write
|
|
10479
|
+
// like every other source: one response, carrying the profile and its
|
|
10480
|
+
// deep-link, instead of leaving the caller holding a storage key and
|
|
10481
|
+
// an unfinished profile.
|
|
10482
|
+
const body = filePath
|
|
10483
|
+
? { avatar_upload_key: await putAvatarBytesToStorage(filePath) }
|
|
10484
|
+
: url
|
|
10485
|
+
? { avatar_url: url }
|
|
10486
|
+
: options.fromLinkedin
|
|
10487
|
+
? { avatar_from_linkedin: true }
|
|
10488
|
+
: { avatar_url: "" };
|
|
10489
|
+
return await requestOxygen(`/api/cli/senders/profiles/${encodeURIComponent(id)}`, {
|
|
10490
|
+
method: "PATCH",
|
|
10491
|
+
body,
|
|
10492
|
+
});
|
|
10493
|
+
});
|
|
10212
10494
|
}))
|
|
10213
10495
|
.addCommand(new Command("attach")
|
|
10214
10496
|
.description("Attach senders and/or inboxes to a profile. An account belongs to one profile at a time — attaching moves it.")
|
|
@@ -10595,6 +10877,30 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
10595
10877
|
.option("--json", "Print a JSON envelope.")
|
|
10596
10878
|
.action(async (options) => {
|
|
10597
10879
|
await handleAsyncAction("linkedin intent status", options, () => requestOxygen("/api/cli/linkedin/intent"));
|
|
10880
|
+
}))
|
|
10881
|
+
// A sibling of `setup`, not a flag on it: setup is free, idempotent and
|
|
10882
|
+
// needs no approval ("make sure these captures and tables exist"), while
|
|
10883
|
+
// this is a STANDING authorization to contact people on every future
|
|
10884
|
+
// cycle. One verb carrying both postures would make the safe default
|
|
10885
|
+
// ambiguous on the surface a founder reads first.
|
|
10886
|
+
.addCommand(new Command("autoenroll")
|
|
10887
|
+
.description("Authorize the captures armed by `linkedin intent setup` to enroll the people they capture into a sequence — the step that turns captured rows into outreach. PREVIEWS BY DEFAULT and writes nothing: it prints the resolved sender and its effective daily caps, the sequence, per-capture create-vs-patch, the live audience split (already 1st-degree / needs an invite / suppressed / already owned by another sender), the resolved spend + enroll caps and where each came from, and a 7-day send forecast. Re-run with --approved to arm a STANDING grant: every later cycle enrolls newly captured people under those caps without asking again, including people you have never seen. This play is a drip, not a blast — a connections import walks LinkedIn on a metered budget (15 relations reads a day by default, ~50 people a read), so a real network lands over days, and a sender on the warm-up ramp is floored at 5 invites and 5 messages a day for its first three days no matter what caps you set. Revoke at any time with `--disable`.")
|
|
10888
|
+
.requiredOption("--account <ref>", "Connected LinkedIn sender whose captures are authorized (sender id, connection id, or Unipile account id).")
|
|
10889
|
+
.option("--sequence <ref>", "Sequence (id or slug) the captured people are enrolled into. Required to arm: a standing grant has to name the journey it puts people in.")
|
|
10890
|
+
.option("--kinds <csv>", "Captures to authorize: connections, followers, profile_viewers, own_posts, post. Defaults to every capture `linkedin intent setup` already provisioned for this sender; naming a kind it never provisioned creates that capture.")
|
|
10891
|
+
.option("--max-credits-per-cycle <n>", "Hard credit ceiling for one cycle of this grant. Omit to inherit your plan tier's per-delivery default (the preview prints which applied). Capturing through a connected account costs 0 credits; a cookieless harvest does not.")
|
|
10892
|
+
.option("--max-enrolls-per-day <n>", "Cap on new people enrolled per UTC day. Omit to inherit the sender's effective — warm-up-ramped — daily message cap.")
|
|
10893
|
+
.option("--include-existing-network", "Also reach the connections you ALREADY have. Without this flag the first cycle records everyone already captured as a silent baseline and only enrolls people who connect, follow, view, or engage from now on.")
|
|
10894
|
+
.option("--post <social_id>", "Composite post social_id from `oxygen posts get`. Required when the `post` capture is authorized.")
|
|
10895
|
+
.option("--post-url <url>", "Public LinkedIn post URL retained with the post capture.")
|
|
10896
|
+
.option("--approved", "Arm the standing grant. Without it nothing is written and nobody is contacted.")
|
|
10897
|
+
.option("--disable", "Revoke the standing grant for this sender. Needs no approval — it only narrows authority. The captures and their tables keep filling; only the permission to contact people is cleared.")
|
|
10898
|
+
.option("--json", "Print a JSON envelope.")
|
|
10899
|
+
.action(async (options) => {
|
|
10900
|
+
await handleAsyncAction("linkedin intent autoenroll", options, () => requestOxygen("/api/cli/linkedin/intent/autoenroll", {
|
|
10901
|
+
method: "POST",
|
|
10902
|
+
body: buildLinkedinIntentAutoenrollBody(options),
|
|
10903
|
+
}));
|
|
10598
10904
|
})))
|
|
10599
10905
|
.addCommand(new Command("ingestion")
|
|
10600
10906
|
.description("Inspect the LinkedIn ingestion drips that read your network, post engagers, and message history into the workspace.")
|
|
@@ -12901,7 +13207,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
12901
13207
|
.option("--domain <domain>", "Sending domain (alternative to the positional argument).")
|
|
12902
13208
|
.requiredOption("--provider <provider>", "Mailbox PLATFORM: google, microsoft, or azure. (google/microsoft cap at 5 mailboxes per domain; azure allows 100.)")
|
|
12903
13209
|
.option("--vendor <vendor>", "Pin the vendor: inboxkit or cmr. Omit to let OXYGEN choose. A named vendor with no credential FAILS rather than falling back to another.")
|
|
12904
|
-
.option("--
|
|
13210
|
+
.option("--sender <id>", "Sender profile id (from `oxygen senders profiles list`) that OWNS every mailbox in this order: its first name, last name, and profile picture are stamped on each one, so you never retype them. A sender profile is one person's sending identity — their LinkedIn/WhatsApp accounts and email inboxes under one name and photo. One sender can own several addresses on the same domain (see --locals). Any first_name/last_name/profile_picture_url in --mailboxes/--file is dropped for the sender's own values.")
|
|
13211
|
+
.option("--locals <list>", "Comma-separated local parts to create for --sender, e.g. `--locals talia,talia.rosen,t.rosen` on send.acme.com orders talia@, talia.rosen@, and t.rosen@ — all owned by that one person. Requires --sender, because a bare local part carries no name and the vendor's stamp is permanent.")
|
|
13212
|
+
.option("--mailboxes <json>", "JSON array of mailboxes: [{\"username\",\"first_name\",\"last_name\",\"profile_picture_url\"}], or [{\"username\",\"sender_profile_id\"}] to take the identity from a sender profile per item (mix freely). profile_picture_url is optional and must be a PUBLIC, PERMANENT https URL — the vendor fetches it server-side at provisioning, so an expiring CDN link (e.g. a media.licdn.com URL with e=<epoch>) ships the mailbox faceless. Use `oxygen managed-inboxes upload-avatar <path>` to host one, or --sender / sender_profile_id, whose photo Oxygen already mirrors.")
|
|
12905
13213
|
.option("--file <path>", "Path to a JSON file { \"mailboxes\": [...] } (alternative to --mailboxes).")
|
|
12906
13214
|
.option("--years <n>", "Years to register the domain for (1-10). Defaults to 1.")
|
|
12907
13215
|
.option("--redirect-url <url>", "Where the domain's web root redirects. Defaults to your workspace's company website; editable later with `oxygen domains forwarding set`.")
|
|
@@ -12922,19 +13230,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
12922
13230
|
throw new Error("--domain is required.");
|
|
12923
13231
|
if (!provider)
|
|
12924
13232
|
throw new Error("--provider is required (google, microsoft, or azure).");
|
|
12925
|
-
|
|
12926
|
-
|
|
12927
|
-
|
|
12928
|
-
|
|
12929
|
-
mailboxes = JSON.parse(mailboxesJson);
|
|
12930
|
-
}
|
|
12931
|
-
else if (filePath) {
|
|
12932
|
-
const parsed = readJsonFileValue(resolve(filePath), "--file");
|
|
12933
|
-
mailboxes = parsed.mailboxes ?? [];
|
|
12934
|
-
}
|
|
12935
|
-
else {
|
|
12936
|
-
throw new Error("Provide --mailboxes <json> or --file <path>.");
|
|
12937
|
-
}
|
|
13233
|
+
// No --count/--prefix here, deliberately: that shorthand can only
|
|
13234
|
+
// reach the vendor by inventing names, and a new domain's first
|
|
13235
|
+
// order is exactly where a placeholder becomes permanent.
|
|
13236
|
+
const mailboxes = buildManagedInboxMailboxes(options, { countPrefix: false });
|
|
12938
13237
|
const years = readOption(options.years);
|
|
12939
13238
|
const vendor = readOption(options.vendor);
|
|
12940
13239
|
const redirectUrl = readOption(options.redirectUrl);
|
|
@@ -12966,47 +13265,19 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
12966
13265
|
.description("Add mailboxes to a managed domain you ALREADY own — no new domain is registered and there is NO domain registration charge, only the extra inboxes' monthly rate (plus their add-ons). WITHOUT --approved this prints a priced PREVIEW with a quote_id and orders nothing; re-run with --approved --quote <id> to place the order. When the standing OXYGEN Warm-up add-on is enabled and available, preview and approved responses carry `warmup_activation` for only the exact newly added `mailboxes`, with `scope=warmup_only`; it is null when warmup is disabled or unavailable. For InboxKit, Google carries `transport=inboxkit_managed_google_handoff`; Microsoft/Azure carries `transport=inboxkit_sequencer_export`. Both carry `mailbox_credentials_required=false` and `native_send_oauth_separate=true`, so managed warm-up needs no customer-supplied OAuth or mailbox password. Google retrieves the existing managed credential only during activation and never persists or returns it; Microsoft/Azure uses native export. Native sending authorization is separate and may still be required before OXYGEN can send. Verify each exact address with `mailboxes oauth-health --json`; InboxKit's unattended Google domain approval usually lands within minutes and the worker keeps retrying, so a waiting row is not a reason to ask the customer to sign in. The quote authorizes automatic OXYGEN Warm-up activation after provisioning, so do not run a second warmup approval. Capped per domain by the platform the domain was bought on (5 google/microsoft, 100 azure) COUNTING the inboxes already on it. To register a NEW domain use `managed-inboxes subscribe` instead.")
|
|
12967
13266
|
.argument("[domain]", "A managed domain this workspace already owns (e.g. send.acme.com). May also be passed as --domain.")
|
|
12968
13267
|
.option("--domain <domain>", "The managed inbox domain (alternative to the positional argument).")
|
|
12969
|
-
.option("--
|
|
13268
|
+
.option("--sender <id>", "Sender profile id (from `oxygen senders profiles list`) that OWNS every mailbox in this order: its first name, last name, and profile picture are stamped on each one, so you never retype them. A sender profile is one person's sending identity — their LinkedIn/WhatsApp accounts and email inboxes under one name and photo. One sender can own several addresses on the same domain (see --locals). Any first_name/last_name/profile_picture_url in --mailboxes/--file is dropped for the sender's own values.")
|
|
13269
|
+
.option("--locals <list>", "Comma-separated local parts to add for --sender, e.g. `--locals talia,talia.rosen,t.rosen` adds talia@, talia.rosen@, and t.rosen@ on this domain — all owned by that one person. Requires --sender, because a bare local part carries no name and the vendor's stamp is permanent.")
|
|
13270
|
+
.option("--mailboxes <json>", "JSON array of mailboxes: [{\"username\",\"first_name\",\"last_name\",\"profile_picture_url\"}], or [{\"username\",\"sender_profile_id\"}] to take the identity from a sender profile per item (mix freely). The vendor stamps the names on each mailbox permanently, so real names belong here. profile_picture_url is optional and must be a PUBLIC, PERMANENT https URL the vendor can fetch at provisioning; `oxygen managed-inboxes upload-avatar <path>` returns one, and a sender profile's mirrored photo already is one.")
|
|
12970
13271
|
.option("--file <path>", "Path to a JSON file { \"mailboxes\": [...] } (alternative to --mailboxes).")
|
|
12971
13272
|
.option("--count <n>", "Shorthand for --mailboxes: how many inboxes to add. Requires --prefix.")
|
|
12972
|
-
.option("--prefix <base>", "Shorthand username base for --count: `--count 3 --prefix ada` adds ada1, ada2, ada3
|
|
13273
|
+
.option("--prefix <base>", "Shorthand username base for --count: `--count 3 --prefix ada` adds ada1, ada2, ada3. WITH --sender all three belong to that person, named and pictured as them; WITHOUT --sender they get placeholder names (Ada 1, Ada 2, Ada 3) the vendor can never change. Pass --sender, or --mailboxes/--file with real names.")
|
|
12973
13274
|
.option("--approved", "Place the order (requires --quote). Without it, a priced preview is returned.")
|
|
12974
13275
|
.option("--quote <id>", "The quote_id from a fresh preview. Required with --approved.")
|
|
12975
13276
|
.option("--json", "Print a JSON envelope.")
|
|
12976
13277
|
.action(async (domainArg, options) => {
|
|
12977
13278
|
await handleAsyncAction("managed-inboxes add-inboxes", options, () => {
|
|
12978
13279
|
const domain = requireDomainArg(domainArg, options.domain);
|
|
12979
|
-
|
|
12980
|
-
const mailboxesJson = readOption(options.mailboxes);
|
|
12981
|
-
const filePath = readOption(options.file);
|
|
12982
|
-
const prefix = readOption(options.prefix);
|
|
12983
|
-
const count = readPositiveInt(options.count);
|
|
12984
|
-
if (mailboxesJson) {
|
|
12985
|
-
mailboxes = JSON.parse(mailboxesJson);
|
|
12986
|
-
}
|
|
12987
|
-
else if (filePath) {
|
|
12988
|
-
const parsed = readJsonFileValue(resolve(filePath), "--file");
|
|
12989
|
-
mailboxes = parsed.mailboxes ?? [];
|
|
12990
|
-
}
|
|
12991
|
-
else if (count !== undefined || prefix) {
|
|
12992
|
-
if (count === undefined || !prefix) {
|
|
12993
|
-
throw new Error("--count and --prefix must be passed together (e.g. --count 3 --prefix ada).");
|
|
12994
|
-
}
|
|
12995
|
-
// The vendor stamps a first/last name on every mailbox, so there is no
|
|
12996
|
-
// name-less order shape to fall back on — the shorthand invents
|
|
12997
|
-
// placeholders rather than pretending names are optional. Anyone who
|
|
12998
|
-
// wants real human identities on the inboxes passes --mailboxes/--file.
|
|
12999
|
-
const base = prefix.toLowerCase();
|
|
13000
|
-
const label = `${base.charAt(0).toUpperCase()}${base.slice(1)}`;
|
|
13001
|
-
mailboxes = Array.from({ length: count }, (_entry, index) => ({
|
|
13002
|
-
username: `${base}${index + 1}`,
|
|
13003
|
-
first_name: label,
|
|
13004
|
-
last_name: String(index + 1),
|
|
13005
|
-
}));
|
|
13006
|
-
}
|
|
13007
|
-
else {
|
|
13008
|
-
throw new Error("Provide --mailboxes <json>, --file <path>, or --count <n> --prefix <base>.");
|
|
13009
|
-
}
|
|
13280
|
+
const mailboxes = buildManagedInboxMailboxes(options, { countPrefix: true });
|
|
13010
13281
|
const quote = readOption(options.quote);
|
|
13011
13282
|
// Refuse locally rather than spending a round trip on a PAID path: the
|
|
13012
13283
|
// route requires the quote that priced this exact expansion, so
|
|
@@ -13086,39 +13357,10 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
13086
13357
|
.option("--json", "Print a JSON envelope.")
|
|
13087
13358
|
.action(async (path, options) => {
|
|
13088
13359
|
await handleAsyncAction("managed-inboxes upload-avatar", options, async () => {
|
|
13089
|
-
const
|
|
13090
|
-
|
|
13091
|
-
|
|
13092
|
-
//
|
|
13093
|
-
// straight to object storage, then confirm — the confirm step is
|
|
13094
|
-
// where the server sniffs the real bytes.
|
|
13095
|
-
const ticket = await requestOxygen("/api/cli/managed-inboxes/avatars", {
|
|
13096
|
-
method: "POST",
|
|
13097
|
-
body: { content_type: contentType, content_length: bytes.byteLength },
|
|
13098
|
-
});
|
|
13099
|
-
const upload = readRecord(ticket, "upload");
|
|
13100
|
-
const uploadUrl = readRecordString(upload, "url");
|
|
13101
|
-
const storageKey = readRecordString(ticket, "storage_key");
|
|
13102
|
-
if (!uploadUrl || !storageKey) {
|
|
13103
|
-
throw new OxygenError("invalid_response", "Oxygen API response is missing the avatar upload URL.", { exitCode: 1 });
|
|
13104
|
-
}
|
|
13105
|
-
const controller = new AbortController();
|
|
13106
|
-
const timer = setTimeout(() => controller.abort(), 120_000);
|
|
13107
|
-
let putResponse;
|
|
13108
|
-
try {
|
|
13109
|
-
putResponse = await fetch(uploadUrl, {
|
|
13110
|
-
method: "PUT",
|
|
13111
|
-
body: new Uint8Array(bytes),
|
|
13112
|
-
headers: { "content-type": contentType },
|
|
13113
|
-
signal: controller.signal,
|
|
13114
|
-
});
|
|
13115
|
-
}
|
|
13116
|
-
finally {
|
|
13117
|
-
clearTimeout(timer);
|
|
13118
|
-
}
|
|
13119
|
-
if (!putResponse.ok) {
|
|
13120
|
-
throw new OxygenError("avatar_upload_failed", `Uploading the photo to object storage failed (HTTP ${putResponse.status}).`, { details: { status: putResponse.status }, exitCode: 1 });
|
|
13121
|
-
}
|
|
13360
|
+
const storageKey = await putAvatarBytesToStorage(path);
|
|
13361
|
+
// Confirming against the avatars route is what turns the staged
|
|
13362
|
+
// bytes into a public URL — the server sniffs the real bytes here,
|
|
13363
|
+
// so a .png that is actually a PDF is caught before an order.
|
|
13122
13364
|
return await requestOxygen("/api/cli/managed-inboxes/avatars", {
|
|
13123
13365
|
method: "PATCH",
|
|
13124
13366
|
body: { storage_key: storageKey },
|
|
@@ -13129,7 +13371,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
13129
13371
|
.description("Native email sending pool: register/refresh Google/Microsoft mailboxes (including secure local credential-file transfer), pause/disable inboxes, connect EmailGuard monitoring, and inspect/control OXYGEN Warm-up. Fresh managed InboxKit Google/Microsoft/Azure orders activate exact-scope warm-up automatically after provisioning under their approved default-on add-on. Google reuses the managed credential just in time; Microsoft/Azure uses native export. Standalone warm-up plans and credit caps are for BYOK/imported mailboxes, managed opt-outs, or later separate enrollment. OXYGEN Warm-up never owns campaign dispatch. Safe import preflight: run `oxygen mailboxes compatibility --catalog-only --json`, then `oxygen mailboxes import --file <path> --validate-only --json` (add the credential source flags shown by import help when the file contains credentials).")
|
|
13130
13372
|
.addCommand(new Command("list")
|
|
13131
13373
|
.description("List the org's sending mailboxes with provider, status, warmup state, source (managed = bought through Oxygen, byok = bring-your-own), and a pool overview (including counts by source). To see only Google/Microsoft mailboxes that still need OAuth connection and the right remedy for each, use `oxygen mailboxes oauth-health --json`.")
|
|
13132
|
-
.option("--status <status>", "Filter by status: active, paused, or
|
|
13374
|
+
.option("--status <status>", "Filter by status: active, paused, disabled, or provisioning (ordered, still being set up).")
|
|
13133
13375
|
.option("--tag <tags>", "Comma-separated workspace tags — matches mailboxes carrying ANY of these tags (see `oxygen tags list`).")
|
|
13134
13376
|
.option("--json", "Print a JSON envelope.")
|
|
13135
13377
|
.action(async (options) => {
|
|
@@ -13721,9 +13963,24 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
13721
13963
|
body: { ...(mailboxes.length > 0 ? { mailboxes } : {}) },
|
|
13722
13964
|
});
|
|
13723
13965
|
});
|
|
13966
|
+
}))
|
|
13967
|
+
.addCommand(new Command("reconnect")
|
|
13968
|
+
.description("Push the mailbox's CURRENT credential to its warmup provider, for an inbox that is already enrolled. This is how you recover a warmup enrollment whose app password was rotated or revoked (the provider reporting 'Username and Password not accepted'): re-import the new credential with `mailboxes import`, then run this. It costs 0 credits and leaves your warmup subscription and its renewal date exactly as they are — you do NOT need to disable and re-enable warmup, which would charge a second month. The provider cannot change a stored password in place, so the inbox's warmup history restarts from day 1. Targets the whole pool unless --mailboxes is given.")
|
|
13969
|
+
.option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
|
|
13970
|
+
.option("--json", "Print a JSON envelope.")
|
|
13971
|
+
.action(async (options) => {
|
|
13972
|
+
await handleAsyncAction("mailboxes warmup reconnect", options, () => {
|
|
13973
|
+
const mailboxes = readCsvOption(options.mailboxes);
|
|
13974
|
+
return requestOxygen("/api/cli/mailboxes/warmup/reconnect", {
|
|
13975
|
+
method: "POST",
|
|
13976
|
+
body: {
|
|
13977
|
+
...(mailboxes.length > 0 ? { mailboxes } : {}),
|
|
13978
|
+
},
|
|
13979
|
+
});
|
|
13980
|
+
});
|
|
13724
13981
|
}))
|
|
13725
13982
|
.addCommand(new Command("disable")
|
|
13726
|
-
.description("Disable warmup and unenroll each mailbox from the rail that actually enrolled it. On a managed rail — OXYGEN Warm-up today, TrulyInbox for older enrollments — this also cancels that mailbox's warmup subscription, so billing stops with the provider removal. This is how you wind an inbox off the retired TrulyInbox rail. Targets the whole pool unless --mailboxes is given.")
|
|
13983
|
+
.description("Disable warmup and unenroll each mailbox from the rail that actually enrolled it. On a managed rail — OXYGEN Warm-up today, TrulyInbox for older enrollments — this also cancels that mailbox's warmup subscription, so billing stops with the provider removal. This is how you wind an inbox off the retired TrulyInbox rail. NOT the way to fix a rotated app password — use `mailboxes warmup reconnect` for that, which costs 0 credits instead of a new 3,000-credit month. Targets the whole pool unless --mailboxes is given.")
|
|
13727
13984
|
.option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
|
|
13728
13985
|
.option("--json", "Print a JSON envelope.")
|
|
13729
13986
|
.action(async (options) => {
|
|
@@ -13736,7 +13993,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
13736
13993
|
});
|
|
13737
13994
|
}))
|
|
13738
13995
|
.addCommand(new Command("status")
|
|
13739
|
-
.description("Read warmup analytics back from the rail each mailbox is enrolled on and update its state in the pool.
|
|
13996
|
+
.description("Read warmup analytics back from the rail each mailbox is enrolled on and update its state in the pool. 0 Oxygen credits and no campaign sends, but it pauses a mailbox whose warmup has run away. Targets the whole pool unless --mailboxes is given.")
|
|
13740
13997
|
.option("--mailboxes <list>", "Comma-separated mailbox ids or addresses to sync. Omit to sync the whole pool.")
|
|
13741
13998
|
.option("--provider <name>", "Optional machine routing filter. Omit it for OXYGEN Warm-up; pass trulyinbox only to read inboxes still warming on the retired rail.")
|
|
13742
13999
|
.option("--dry-run", "Skip the provider call (mailboxes marked pending).")
|
|
@@ -14388,6 +14645,7 @@ Run completion:
|
|
|
14388
14645
|
.option("--approved", "Authorize autonomous tool calls for this revision.")
|
|
14389
14646
|
.option("--max-credits <n>", "Optional explicit positive ceiling for each autonomous delivery; omit to use the plan-tier default.")
|
|
14390
14647
|
.option("--draft", "Save without going live: keeps the running version serving every trigger. Publish later by applying again without --draft.")
|
|
14648
|
+
.option("--review", "Show what publishing this would change against the version now serving triggers — added, removed and modified steps, what leaves Oxygen, the accounts used, and whether it needs standing authority. Writes nothing.")
|
|
14391
14649
|
.option("--include-bundle", "Include durable recipe bundles in JSON output.")
|
|
14392
14650
|
.option("--json", "Print a JSON envelope.")
|
|
14393
14651
|
.action(async (options) => {
|
|
@@ -14402,6 +14660,7 @@ Run completion:
|
|
|
14402
14660
|
// byte-identical to what every previous CLI sent, so behaviour
|
|
14403
14661
|
// against an older server is unchanged rather than merely equivalent.
|
|
14404
14662
|
...(options.draft ? { publish: false } : {}),
|
|
14663
|
+
...(options.review ? { review: true } : {}),
|
|
14405
14664
|
...(options.approved ? { approved: true } : {}),
|
|
14406
14665
|
...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
|
|
14407
14666
|
},
|
|
@@ -14537,9 +14796,11 @@ Run completion:
|
|
|
14537
14796
|
.option("--max-credits <n>", "Required credit ceiling for live calls.")
|
|
14538
14797
|
.option("--approved", "Required for live calls after inspecting a dry run.")
|
|
14539
14798
|
.option("--revision <n>", "Run a specific saved version instead of the live one. Dry-run and smoke-test only: publish a version to run it live.")
|
|
14799
|
+
.option("--node <node_id>", "Test one node: runs that node plus only the predecessors it needs, from the same saved graph. Dry-run and smoke-test only.")
|
|
14800
|
+
.option("--preview", "Show what a live run of this exact version would do — every step that leaves Oxygen, the accounts it would use, and the billable-step floor — without running anything.")
|
|
14540
14801
|
.option("--include-bundle", "Include durable recipe bundles in JSON output.")
|
|
14541
14802
|
.option("--json", "Print a JSON envelope.")
|
|
14542
|
-
.addHelpText("after", "\nSafety: smoke_test and dry_run share one boundary: 0 credits, no paid provider calls, no external writes. Oxygen internal reads use current workspace data, and oxygen.http_json_request may make a real outbound GET. Every mode creates an inspectable Workflow run record.\n")
|
|
14803
|
+
.addHelpText("after", "\nSafety: smoke_test and dry_run share one boundary: 0 credits, no paid provider calls, no external writes. Oxygen internal reads use current workspace data, and oxygen.http_json_request may make a real outbound GET. Every mode creates an inspectable Workflow run record.\n\n--node scopes a test to one step. Oxygen plans the slice on the server from the exact saved version and records it on the run, so the run shows precisely which nodes were allowed to execute; you never submit a node list. Selecting the trigger, a disabled node, or a node unreachable from the trigger is refused with the reason. Only canonical graph workflows support it.\n")
|
|
14543
14804
|
.action(async (workflowArg, options) => {
|
|
14544
14805
|
const maxCredits = readPositiveNumber(options.maxCredits);
|
|
14545
14806
|
const revisionVersion = readPositiveNumber(options.revision);
|
|
@@ -14555,6 +14816,8 @@ Run completion:
|
|
|
14555
14816
|
...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
|
|
14556
14817
|
...(options.approved ? { approved: true } : {}),
|
|
14557
14818
|
...(revisionVersion !== undefined ? { revision_version: revisionVersion } : {}),
|
|
14819
|
+
...(readOption(options.node) ? { test_node_id: readOption(options.node) } : {}),
|
|
14820
|
+
...(options.preview ? { preview: true } : {}),
|
|
14558
14821
|
},
|
|
14559
14822
|
}), options));
|
|
14560
14823
|
}))
|
|
@@ -14835,11 +15098,16 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
|
|
|
14835
15098
|
.addCommand(new Command("cancel")
|
|
14836
15099
|
.description("Cancel a queued or running workflow run.")
|
|
14837
15100
|
.argument("<run_id>", "Workflow run UUID.")
|
|
15101
|
+
.option("--preview", "Show what cancelling would and would not undo — steps in flight, and any external write already dispatched that cancelling cannot recall. Cancels nothing.")
|
|
14838
15102
|
.option("--json", "Print a JSON envelope.")
|
|
15103
|
+
.addHelpText("after", "\nCancelling stops further steps; it cannot recall a step already dispatched to a provider. Use --preview first when the run may be mid-write.\n")
|
|
14839
15104
|
.action(async (runId, options) => {
|
|
14840
15105
|
await handleAsyncAction("workflows cancel", options, () => requestOxygen("/api/cli/workflows/cancel", {
|
|
14841
15106
|
method: "POST",
|
|
14842
|
-
body: {
|
|
15107
|
+
body: {
|
|
15108
|
+
run_id: runId,
|
|
15109
|
+
...(options.preview ? { preview: true } : {}),
|
|
15110
|
+
},
|
|
14843
15111
|
}));
|
|
14844
15112
|
}))
|
|
14845
15113
|
.addCommand(new Command("approvals")
|
|
@@ -18746,6 +19014,51 @@ function imageContentTypeForPath(path) {
|
|
|
18746
19014
|
return "image/png";
|
|
18747
19015
|
throw new OxygenError("unsupported_image", "Profile pictures must be a .png, .jpg, or .webp file.", { details: { path }, exitCode: 2 });
|
|
18748
19016
|
}
|
|
19017
|
+
/**
|
|
19018
|
+
* Hops one and two of the avatar upload: presign, then PUT the raw bytes straight
|
|
19019
|
+
* to Oxygen object storage. Returns the storage key the caller confirms with.
|
|
19020
|
+
*
|
|
19021
|
+
* Shared because two commands stage the same bytes in the same bucket and then
|
|
19022
|
+
* diverge: `managed-inboxes upload-avatar` confirms against the avatars route to
|
|
19023
|
+
* get a public URL to paste into an order, while `senders profiles set-photo`
|
|
19024
|
+
* hands the key to the profile writer as `avatar_upload_key`, which re-checks org
|
|
19025
|
+
* ownership and re-sniffs the bytes itself. Copying the two hops into the second
|
|
19026
|
+
* caller would have duplicated the abort timeout and the failure taxonomy, and
|
|
19027
|
+
* the copy is exactly where they drift.
|
|
19028
|
+
*/
|
|
19029
|
+
async function putAvatarBytesToStorage(path) {
|
|
19030
|
+
const resolved = resolve(path);
|
|
19031
|
+
const bytes = readFileSync(resolved);
|
|
19032
|
+
const contentType = imageContentTypeForPath(resolved);
|
|
19033
|
+
const ticket = await requestOxygen("/api/cli/managed-inboxes/avatars", {
|
|
19034
|
+
method: "POST",
|
|
19035
|
+
body: { content_type: contentType, content_length: bytes.byteLength },
|
|
19036
|
+
});
|
|
19037
|
+
const upload = readRecord(ticket, "upload");
|
|
19038
|
+
const uploadUrl = readRecordString(upload, "url");
|
|
19039
|
+
const storageKey = readRecordString(ticket, "storage_key");
|
|
19040
|
+
if (!uploadUrl || !storageKey) {
|
|
19041
|
+
throw new OxygenError("invalid_response", "Oxygen API response is missing the avatar upload URL.", { exitCode: 1 });
|
|
19042
|
+
}
|
|
19043
|
+
const controller = new AbortController();
|
|
19044
|
+
const timer = setTimeout(() => controller.abort(), 120_000);
|
|
19045
|
+
let putResponse;
|
|
19046
|
+
try {
|
|
19047
|
+
putResponse = await fetch(uploadUrl, {
|
|
19048
|
+
method: "PUT",
|
|
19049
|
+
body: new Uint8Array(bytes),
|
|
19050
|
+
headers: { "content-type": contentType },
|
|
19051
|
+
signal: controller.signal,
|
|
19052
|
+
});
|
|
19053
|
+
}
|
|
19054
|
+
finally {
|
|
19055
|
+
clearTimeout(timer);
|
|
19056
|
+
}
|
|
19057
|
+
if (!putResponse.ok) {
|
|
19058
|
+
throw new OxygenError("avatar_upload_failed", `Uploading the photo to object storage failed (HTTP ${putResponse.status}).`, { details: { status: putResponse.status }, exitCode: 1 });
|
|
19059
|
+
}
|
|
19060
|
+
return storageKey;
|
|
19061
|
+
}
|
|
18749
19062
|
function readRecord(value, key) {
|
|
18750
19063
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
18751
19064
|
return null;
|
|
@@ -19216,6 +19529,51 @@ async function handleUpdateAction(options) {
|
|
|
19216
19529
|
emitCliFailure("update", error);
|
|
19217
19530
|
}
|
|
19218
19531
|
}
|
|
19532
|
+
/**
|
|
19533
|
+
* `oxygen home`: the standup PLUS the prescribed play's next step.
|
|
19534
|
+
*
|
|
19535
|
+
* Two reads, because the web Home already composes exactly these two — its RSC calls
|
|
19536
|
+
* the standup composition and `readActivationState` side by side (apps/web/src/lib/
|
|
19537
|
+
* activation/state.ts) — and a terminal-first operator must not be the one surface
|
|
19538
|
+
* shown a standup with no next move. Both legs are existing read-only `/api/cli/*`
|
|
19539
|
+
* contracts, so neither surface knows anything the other cannot ask for.
|
|
19540
|
+
*
|
|
19541
|
+
* The activation leg is allSettled and never fatal: a standup that read fine must not
|
|
19542
|
+
* be thrown away because the play read failed. When it does fail the leg is NAMED in
|
|
19543
|
+
* `degraded` rather than dropped silently — an absent next step has to read as "we
|
|
19544
|
+
* could not tell", never as "there is nothing to do".
|
|
19545
|
+
*/
|
|
19546
|
+
async function readHomeStandup() {
|
|
19547
|
+
const [standup, activation] = await Promise.allSettled([
|
|
19548
|
+
requestOxygen("/api/cli/home/standup"),
|
|
19549
|
+
requestOxygen("/api/cli/activation/state"),
|
|
19550
|
+
]);
|
|
19551
|
+
if (standup.status === "rejected")
|
|
19552
|
+
throw standup.reason;
|
|
19553
|
+
const data = standup.value;
|
|
19554
|
+
if (activation.status === "fulfilled") {
|
|
19555
|
+
return {
|
|
19556
|
+
...data,
|
|
19557
|
+
// The play's progress and its single next step; `oxygen activation` prints the
|
|
19558
|
+
// per-step detail this deliberately leaves out.
|
|
19559
|
+
activation: {
|
|
19560
|
+
play: activation.value.play ?? null,
|
|
19561
|
+
next_step: activation.value.next_step ?? null,
|
|
19562
|
+
safety: activation.value.safety ?? null,
|
|
19563
|
+
degraded: activation.value.degraded ?? [],
|
|
19564
|
+
full_state: "oxygen activation --json",
|
|
19565
|
+
},
|
|
19566
|
+
};
|
|
19567
|
+
}
|
|
19568
|
+
return {
|
|
19569
|
+
...data,
|
|
19570
|
+
activation: null,
|
|
19571
|
+
degraded: [
|
|
19572
|
+
...(Array.isArray(data.degraded) ? data.degraded : []),
|
|
19573
|
+
"activation",
|
|
19574
|
+
],
|
|
19575
|
+
};
|
|
19576
|
+
}
|
|
19219
19577
|
function buildApiKeyCreateBody(options) {
|
|
19220
19578
|
const body = {};
|
|
19221
19579
|
const name = readOption(options.name);
|
|
@@ -22429,6 +22787,44 @@ function readPositiveNumber(value) {
|
|
|
22429
22787
|
}
|
|
22430
22788
|
return parsed;
|
|
22431
22789
|
}
|
|
22790
|
+
/**
|
|
22791
|
+
* A whole count (days, rows, windows). Its positive-number sibling accepts 2.5,
|
|
22792
|
+
* which for a scan bound is always a typo — rejected here so it costs no round trip,
|
|
22793
|
+
* and rejected rather than rounded so a bound nobody asked for is never scanned.
|
|
22794
|
+
*/
|
|
22795
|
+
function readPositiveInteger(value) {
|
|
22796
|
+
const trimmed = value?.trim();
|
|
22797
|
+
if (!trimmed)
|
|
22798
|
+
return undefined;
|
|
22799
|
+
const parsed = Number(trimmed);
|
|
22800
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
22801
|
+
throw new OxygenError("invalid_number", "Expected a positive whole number.", {
|
|
22802
|
+
details: { value },
|
|
22803
|
+
exitCode: 1,
|
|
22804
|
+
});
|
|
22805
|
+
}
|
|
22806
|
+
return parsed;
|
|
22807
|
+
}
|
|
22808
|
+
/**
|
|
22809
|
+
* A signed, finite number (`--expected-divergence`). Unlike its positive and
|
|
22810
|
+
* non-negative siblings the sign is the payload here — a negative divergence is
|
|
22811
|
+
* the meter counter under-reading its own ledger — so only a non-finite value is
|
|
22812
|
+
* rejected. Parsed client-side so a typo fails before a request that would
|
|
22813
|
+
* otherwise reach a billing ledger is sent at all.
|
|
22814
|
+
*/
|
|
22815
|
+
function readSignedNumber(value) {
|
|
22816
|
+
const trimmed = value?.trim();
|
|
22817
|
+
if (!trimmed)
|
|
22818
|
+
return undefined;
|
|
22819
|
+
const parsed = Number(trimmed);
|
|
22820
|
+
if (!Number.isFinite(parsed)) {
|
|
22821
|
+
throw new OxygenError("invalid_number", "Expected a finite number.", {
|
|
22822
|
+
details: { value },
|
|
22823
|
+
exitCode: 1,
|
|
22824
|
+
});
|
|
22825
|
+
}
|
|
22826
|
+
return parsed;
|
|
22827
|
+
}
|
|
22432
22828
|
function readNonNegativeNumber(value) {
|
|
22433
22829
|
const trimmed = value?.trim();
|
|
22434
22830
|
if (!trimmed)
|