@oxygen-agent/cli 1.1014.1600 → 1.1014.1763
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-families/program.js +4 -0
- package/dist/command-manifest.js +18 -3
- package/dist/command-support.js +1 -1
- package/dist/commands/admin.js +82 -1
- package/dist/commands/billing.js +1 -1
- package/dist/commands/copilot.js +29 -7
- package/dist/commands/custom-integrations.js +13 -0
- package/dist/commands/domains.js +7 -0
- package/dist/commands/email-assets.d.ts +3 -0
- package/dist/commands/email-assets.js +78 -0
- package/dist/commands/email-templates.d.ts +3 -0
- package/dist/commands/email-templates.js +101 -0
- package/dist/commands/inbox.js +1 -1
- package/dist/commands/integrations.js +5 -1
- package/dist/commands/mailboxes.js +26 -6
- package/dist/commands/managed-inboxes.js +2 -2
- package/dist/commands/senders.js +4 -4
- package/dist/commands/sequences.js +47 -12
- package/dist/commands/subscriptions.d.ts +2 -4
- package/dist/commands/subscriptions.js +297 -51
- package/dist/credential-storage/store.d.ts +10 -0
- package/dist/credential-storage/store.js +282 -0
- package/dist/credentials.d.ts +1 -3
- package/dist/credentials.js +3 -275
- package/dist/mailbox-fleet-output.js +11 -2
- package/dist/sequence-start-at.d.ts +7 -0
- package/dist/sequence-start-at.js +21 -0
- package/dist/sequences-brand-kit-command.d.ts +28 -0
- package/dist/sequences-brand-kit-command.js +83 -0
- package/dist/sequences-content-commands.d.ts +28 -0
- package/dist/sequences-content-commands.js +176 -0
- package/dist/sequences-newsletter-commands.d.ts +6 -0
- package/dist/sequences-newsletter-commands.js +108 -0
- package/dist/sequences-test-send-command.d.ts +3 -0
- package/dist/sequences-test-send-command.js +47 -0
- package/dist/workspace-placement-commands.js +3 -2
- package/node_modules/@oxygen/formula/dist/evaluate.js +2 -4
- package/node_modules/@oxygen/formula/dist/expression.d.ts +7 -5
- package/node_modules/@oxygen/formula/dist/expression.js +26 -4
- package/node_modules/@oxygen/formula/dist/formula-functions.d.ts +6 -0
- package/node_modules/@oxygen/formula/dist/formula-functions.js +55 -0
- package/node_modules/@oxygen/shared/dist/billing.d.ts +2 -2
- package/node_modules/@oxygen/shared/dist/billing.js +5 -7
- package/node_modules/@oxygen/shared/dist/bulk-email-pacing.d.ts +105 -0
- package/node_modules/@oxygen/shared/dist/bulk-email-pacing.js +125 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +29 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +236 -18
- package/node_modules/@oxygen/shared/dist/clay-migration-auth-state.d.ts +7 -1
- package/node_modules/@oxygen/shared/dist/clay-migration-auth-state.js +6 -5
- package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +1 -0
- package/node_modules/@oxygen/shared/dist/contracts/clay-migration.d.ts +40 -1
- package/node_modules/@oxygen/shared/dist/contracts/clay-migration.js +60 -6
- package/node_modules/@oxygen/shared/dist/contracts/copilot-attachment-read.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/contracts/copilot-attachment-read.js +7 -0
- package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +2 -1
- package/node_modules/@oxygen/shared/dist/contracts/teammates.js +6 -1
- package/node_modules/@oxygen/shared/dist/copilot-journeys.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/copilot-playbooks.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/copilot-playbooks.generated.js +2 -2
- package/node_modules/@oxygen/shared/dist/email-brand-kit.d.ts +108 -0
- package/node_modules/@oxygen/shared/dist/email-brand-kit.js +214 -0
- package/node_modules/@oxygen/shared/dist/email-classification-scope.d.ts +35 -0
- package/node_modules/@oxygen/shared/dist/email-classification-scope.js +40 -0
- package/node_modules/@oxygen/shared/dist/email-delivery.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/email-delivery.js +14 -0
- package/node_modules/@oxygen/shared/dist/email-documents.d.ts +61 -0
- package/node_modules/@oxygen/shared/dist/email-documents.js +143 -0
- package/node_modules/@oxygen/shared/dist/email-dsn.js +52 -10
- package/node_modules/@oxygen/shared/dist/email-html-sanitize.d.ts +23 -0
- package/node_modules/@oxygen/shared/dist/email-html-sanitize.js +96 -0
- package/node_modules/@oxygen/shared/dist/email-subscriptions.d.ts +63 -1
- package/node_modules/@oxygen/shared/dist/email-subscriptions.js +149 -1
- package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.d.ts +41 -2
- package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.js +50 -1
- package/node_modules/@oxygen/shared/dist/native-chat.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/native-chat.js +12 -1
- package/node_modules/@oxygen/shared/dist/newsletter-analytics.d.ts +90 -0
- package/node_modules/@oxygen/shared/dist/newsletter-analytics.js +28 -0
- package/node_modules/@oxygen/shared/dist/newsletter-test-send.d.ts +20 -0
- package/node_modules/@oxygen/shared/dist/newsletter-test-send.js +46 -0
- package/node_modules/@oxygen/shared/dist/object-storage.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/object-storage.js +12 -0
- package/node_modules/@oxygen/shared/dist/onboarding-research.d.ts +9 -2
- package/node_modules/@oxygen/shared/dist/onboarding-research.js +9 -2
- package/node_modules/@oxygen/shared/dist/plan-band.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/plan-band.js +17 -0
- package/node_modules/@oxygen/shared/dist/plan-capabilities.d.ts +4 -2
- package/node_modules/@oxygen/shared/dist/plan-capabilities.js +12 -1
- package/node_modules/@oxygen/shared/dist/plan-limits-view.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/plan-limits-view.js +3 -0
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +5 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +29 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.js +29 -0
- package/node_modules/@oxygen/shared/dist/recipes.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/recipes.js +13 -0
- package/node_modules/@oxygen/shared/dist/sending-domain-faults.d.ts +21 -0
- package/node_modules/@oxygen/shared/dist/sending-domain-faults.js +53 -0
- package/node_modules/@oxygen/shared/dist/sending-domain-rollout.d.ts +30 -0
- package/node_modules/@oxygen/shared/dist/sending-domain-rollout.js +75 -0
- package/node_modules/@oxygen/shared/dist/sequence-failures.d.ts +10 -1
- package/node_modules/@oxygen/shared/dist/sequence-failures.js +68 -6
- package/node_modules/@oxygen/shared/dist/sequence-lifecycle.d.ts +34 -3
- package/node_modules/@oxygen/shared/dist/sequence-lifecycle.js +57 -4
- package/node_modules/@oxygen/shared/dist/sequence-template.d.ts +17 -0
- package/node_modules/@oxygen/shared/dist/sequence-template.js +0 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +41 -0
- package/node_modules/@oxygen/shared/dist/sequences.js +86 -4
- package/node_modules/@oxygen/shared/dist/ses-identity-policy.d.ts +19 -0
- package/node_modules/@oxygen/shared/dist/ses-identity-policy.js +54 -0
- package/node_modules/@oxygen/shared/dist/skill-play-routing.d.ts +22 -0
- package/node_modules/@oxygen/shared/dist/skill-play-routing.js +75 -0
- package/node_modules/@oxygen/shared/dist/skill-plays.generated.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/skill-plays.generated.js +269 -0
- package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +7 -0
- package/node_modules/@oxygen/shared/dist/spend-safety.js +7 -0
- package/node_modules/@oxygen/shared/dist/teammates.js +2 -1
- package/node_modules/@oxygen/shared/dist/tool-call-arguments.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/tool-call-arguments.js +68 -0
- package/node_modules/@oxygen/shared/dist/user-capability-routing.js +15 -1
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/workspace-event-catalog.js +2 -2
- package/node_modules/@oxygen/shared/dist/workspace-placement.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/workspace-placement.js +11 -0
- package/package.json +2 -2
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { JsonOptions } from "./cli-output.js";
|
|
2
|
+
import { ExtractedCommandActionHandler } from "./command-support.js";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
type BrandKitSetOptions = JsonOptions & {
|
|
5
|
+
logoUrl?: string;
|
|
6
|
+
logoWidth?: string;
|
|
7
|
+
colorBackground?: string;
|
|
8
|
+
colorContentBackground?: string;
|
|
9
|
+
colorText?: string;
|
|
10
|
+
colorLink?: string;
|
|
11
|
+
colorButton?: string;
|
|
12
|
+
colorButtonText?: string;
|
|
13
|
+
containerWidth?: string;
|
|
14
|
+
font?: string;
|
|
15
|
+
companyName?: string;
|
|
16
|
+
postalAddress?: string;
|
|
17
|
+
social?: string;
|
|
18
|
+
preferencePageTitle?: string;
|
|
19
|
+
preferencePageDescription?: string;
|
|
20
|
+
};
|
|
21
|
+
/** `network=url` pairs, comma-separated; "" clears every link. */
|
|
22
|
+
export declare function readSocialLinksOption(value: string | undefined): Array<{
|
|
23
|
+
network: string;
|
|
24
|
+
url: string;
|
|
25
|
+
}> | undefined;
|
|
26
|
+
export declare function brandKitSetBody(options: BrandKitSetOptions): Record<string, unknown>;
|
|
27
|
+
export declare function sequencesBrandKitCommand(handleAsyncAction: ExtractedCommandActionHandler): Command;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { requestOxygen } from "./http-client.js";
|
|
2
|
+
import { readClearableOption } from "./util.js";
|
|
3
|
+
import { OxygenError } from "@oxygen/shared/cli-result";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
/** A whole number; "" clears (null); absence leaves the field alone. */
|
|
6
|
+
function readClearableInteger(value, flag) {
|
|
7
|
+
const text = readClearableOption(value);
|
|
8
|
+
if (text === undefined || text === null)
|
|
9
|
+
return text;
|
|
10
|
+
const parsed = Number(text);
|
|
11
|
+
if (!Number.isInteger(parsed))
|
|
12
|
+
throw new OxygenError("invalid_request", `${flag} must be a whole number.`, { exitCode: 2 });
|
|
13
|
+
return parsed;
|
|
14
|
+
}
|
|
15
|
+
/** `network=url` pairs, comma-separated; "" clears every link. */
|
|
16
|
+
export function readSocialLinksOption(value) {
|
|
17
|
+
if (value === undefined)
|
|
18
|
+
return undefined;
|
|
19
|
+
return value.split(",").map((entry) => entry.trim()).filter(Boolean).map((entry) => {
|
|
20
|
+
const at = entry.indexOf("=");
|
|
21
|
+
if (at <= 0) {
|
|
22
|
+
throw new OxygenError("invalid_request", "--social takes network=url pairs, e.g. linkedin=https://linkedin.com/company/acme.", { exitCode: 2 });
|
|
23
|
+
}
|
|
24
|
+
return { network: entry.slice(0, at).trim().toLowerCase(), url: entry.slice(at + 1).trim() };
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export function brandKitSetBody(options) {
|
|
28
|
+
// A literal \n in a shell argument is a line break in the address.
|
|
29
|
+
const postalAddress = options.postalAddress === undefined ? undefined : options.postalAddress.replace(/\\n/g, "\n");
|
|
30
|
+
// An absent flag stays undefined, which the JSON body drops: only named fields change.
|
|
31
|
+
const body = {
|
|
32
|
+
logo_url: readClearableOption(options.logoUrl),
|
|
33
|
+
logo_width: readClearableInteger(options.logoWidth, "--logo-width"),
|
|
34
|
+
background_color: readClearableOption(options.colorBackground),
|
|
35
|
+
content_background_color: readClearableOption(options.colorContentBackground),
|
|
36
|
+
text_color: readClearableOption(options.colorText),
|
|
37
|
+
link_color: readClearableOption(options.colorLink),
|
|
38
|
+
button_color: readClearableOption(options.colorButton),
|
|
39
|
+
button_text_color: readClearableOption(options.colorButtonText),
|
|
40
|
+
container_width: readClearableInteger(options.containerWidth, "--container-width"),
|
|
41
|
+
font_family: readClearableOption(options.font),
|
|
42
|
+
company_name: readClearableOption(options.companyName),
|
|
43
|
+
postal_address: postalAddress === undefined ? undefined : (postalAddress.trim() ? postalAddress : null),
|
|
44
|
+
social_links: readSocialLinksOption(options.social),
|
|
45
|
+
preference_page_title: readClearableOption(options.preferencePageTitle),
|
|
46
|
+
preference_page_description: readClearableOption(options.preferencePageDescription),
|
|
47
|
+
};
|
|
48
|
+
if (Object.values(body).every((value) => value === undefined)) {
|
|
49
|
+
throw new OxygenError("invalid_request", "Pass at least one brand kit flag, such as --postal-address.", { exitCode: 2 });
|
|
50
|
+
}
|
|
51
|
+
return body;
|
|
52
|
+
}
|
|
53
|
+
export function sequencesBrandKitCommand(handleAsyncAction) {
|
|
54
|
+
return new Command("brand-kit")
|
|
55
|
+
.description("The workspace's newsletter brand kit: logo, colours, width, font, company name, postal address, social links and preference page title. Every newsletter's footer carries the company name and postal address; a newsletter starts only once the postal address is set. 0 credits.")
|
|
56
|
+
.addCommand(new Command("get")
|
|
57
|
+
.description("Show the brand kit with defaults applied and whether newsletters can start (newsletter_ready).")
|
|
58
|
+
.option("--json", "Print a JSON envelope.")
|
|
59
|
+
.action(async (options) => {
|
|
60
|
+
await handleAsyncAction("sequences brand-kit get", options, () => requestOxygen("/api/cli/sequencer/brand-kit"));
|
|
61
|
+
}))
|
|
62
|
+
.addCommand(new Command("set")
|
|
63
|
+
.description("Change brand kit fields; only the flags you pass change, and \"\" clears one (a colour, width or font returns to its default).")
|
|
64
|
+
.option("--logo-url <url>", "https:// URL of the logo image.")
|
|
65
|
+
.option("--logo-width <px>", "Logo width in pixels, 40-600.")
|
|
66
|
+
.option("--color-background <hex>", "Page background, e.g. #f4f4f5.")
|
|
67
|
+
.option("--color-content-background <hex>", "Content area background.")
|
|
68
|
+
.option("--color-text <hex>", "Body text colour.")
|
|
69
|
+
.option("--color-link <hex>", "Link colour.")
|
|
70
|
+
.option("--color-button <hex>", "Button colour.")
|
|
71
|
+
.option("--color-button-text <hex>", "Button text colour.")
|
|
72
|
+
.option("--container-width <px>", "Content width in pixels, 480-800 (default 600).")
|
|
73
|
+
.option("--font <font>", "system, georgia, arial, helvetica or verdana.")
|
|
74
|
+
.option("--company-name <name>", "Shown in every newsletter's footer.")
|
|
75
|
+
.option("--postal-address <address>", "Postal address for every newsletter's footer, up to 500 characters; \\n starts a new line.")
|
|
76
|
+
.option("--social <links>", "Comma-separated network=url pairs (website, linkedin, x, facebook, instagram, youtube, tiktok, github).")
|
|
77
|
+
.option("--preference-page-title <title>", "Title of the subscription preference page.")
|
|
78
|
+
.option("--preference-page-description <text>", "Text under the preference page title.")
|
|
79
|
+
.option("--json", "Print a JSON envelope.")
|
|
80
|
+
.action(async (options) => {
|
|
81
|
+
await handleAsyncAction("sequences brand-kit set", options, () => requestOxygen("/api/cli/sequencer/brand-kit", { method: "PATCH", body: brandKitSetBody(options) }));
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import type { ExtractedCommandActionHandler } from "./command-support.js";
|
|
3
|
+
/** The content flags `sequences content set` and `email-templates save` share. */
|
|
4
|
+
export type ContentFileOptions = {
|
|
5
|
+
htmlFile?: string;
|
|
6
|
+
jsonFile?: string;
|
|
7
|
+
textFile?: string;
|
|
8
|
+
previewText?: string;
|
|
9
|
+
name?: string;
|
|
10
|
+
};
|
|
11
|
+
export declare function readRevisionFlag(value: string | undefined, flag: string): number | undefined;
|
|
12
|
+
/** The document fields a set call sends: from --html-file or a --json-file export, with optional text and preview overrides. */
|
|
13
|
+
export declare function contentBody(options: ContentFileOptions): Record<string, unknown>;
|
|
14
|
+
type LoadedSequence = {
|
|
15
|
+
sequence: {
|
|
16
|
+
id: string;
|
|
17
|
+
name?: string;
|
|
18
|
+
status?: string;
|
|
19
|
+
definition: {
|
|
20
|
+
steps?: unknown[];
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
sequence_revision?: string;
|
|
24
|
+
web_url?: string;
|
|
25
|
+
};
|
|
26
|
+
export declare function readSequence(sequence: string): Promise<LoadedSequence>;
|
|
27
|
+
export declare function sequencesContentCommand(handleAsyncAction: ExtractedCommandActionHandler): Command;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { OxygenError } from "@oxygen/shared/cli-result";
|
|
3
|
+
import { sequenceContentStep, withSequenceStepBodyDocument } from "@oxygen/shared/sequences";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { parseJsonValue } from "./command-support.js";
|
|
6
|
+
import { requestOxygen } from "./http-client.js";
|
|
7
|
+
import { isRecord, readOption } from "./util.js";
|
|
8
|
+
export function readRevisionFlag(value, flag) {
|
|
9
|
+
if (value === undefined)
|
|
10
|
+
return undefined;
|
|
11
|
+
const revision = Number(value);
|
|
12
|
+
if (!Number.isInteger(revision) || revision < 1)
|
|
13
|
+
throw new OxygenError("invalid_request", `${flag} must be a positive integer.`, { exitCode: 2 });
|
|
14
|
+
return revision;
|
|
15
|
+
}
|
|
16
|
+
function readTextFile(path, flag) {
|
|
17
|
+
try {
|
|
18
|
+
return readFileSync(path, "utf8");
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new OxygenError("invalid_request", `${flag} could not be read.`, { details: { flag, path }, exitCode: 2 });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** The document fields a set call sends: from --html-file or a --json-file export, with optional text and preview overrides. */
|
|
25
|
+
export function contentBody(options) {
|
|
26
|
+
let exported = {};
|
|
27
|
+
if (options.jsonFile) {
|
|
28
|
+
const parsed = parseJsonValue(readTextFile(options.jsonFile, "--json-file"), "--json-file");
|
|
29
|
+
if (!isRecord(parsed) || typeof parsed.html !== "string") {
|
|
30
|
+
throw new OxygenError("invalid_request", "--json-file must hold a JSON object with html (and optional text, preview_text, editor_json).", { exitCode: 2 });
|
|
31
|
+
}
|
|
32
|
+
exported = parsed;
|
|
33
|
+
}
|
|
34
|
+
// Flags override the export field by field; one object, no self-spreads.
|
|
35
|
+
const html = options.htmlFile ? readTextFile(options.htmlFile, "--html-file") : exported.html;
|
|
36
|
+
const text = options.textFile ? readTextFile(options.textFile, "--text-file") : exported.text;
|
|
37
|
+
const previewText = options.previewText ?? exported.preview_text;
|
|
38
|
+
const name = readOption(options.name);
|
|
39
|
+
return {
|
|
40
|
+
html,
|
|
41
|
+
...(text !== undefined ? { text } : {}),
|
|
42
|
+
...(previewText !== undefined ? { preview_text: previewText } : {}),
|
|
43
|
+
...(exported.editor_json !== undefined ? { editor_json: exported.editor_json } : {}),
|
|
44
|
+
...(name ? { name } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** A step's content links to its Sequence's web home (a newsletter's composer), not the document's. */
|
|
48
|
+
function sequenceLink(read) {
|
|
49
|
+
return read.web_url ? { web_url: read.web_url, deepLink: read.web_url } : {};
|
|
50
|
+
}
|
|
51
|
+
export async function readSequence(sequence) {
|
|
52
|
+
const read = await requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`);
|
|
53
|
+
const loaded = read.sequence;
|
|
54
|
+
if (!loaded?.id || !loaded.definition) {
|
|
55
|
+
throw new OxygenError("sequence_not_found", "Sequence not found.", { details: { sequence }, exitCode: 4 });
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
sequence: { ...loaded, id: loaded.id, definition: loaded.definition },
|
|
59
|
+
...(read.sequence_revision ? { sequence_revision: read.sequence_revision } : {}),
|
|
60
|
+
...(read.web_url ? { web_url: read.web_url } : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async function contentGet(sequence, options) {
|
|
64
|
+
const stepId = readOption(options.step);
|
|
65
|
+
if (!stepId)
|
|
66
|
+
throw new OxygenError("invalid_request", "--step is required.", { exitCode: 2 });
|
|
67
|
+
const read = await readSequence(sequence);
|
|
68
|
+
const { step, bodyDocument } = sequenceContentStep(read.sequence.definition, stepId);
|
|
69
|
+
if (!bodyDocument) {
|
|
70
|
+
return {
|
|
71
|
+
sequence_id: read.sequence.id, step_id: stepId, body_document: null,
|
|
72
|
+
body_template: step.body_template ?? "", body_html_template: step.body_html_template ?? null,
|
|
73
|
+
next_step: `Give this step a designed body: oxygen sequences content set ${read.sequence.id} --step ${stepId} --html-file <file>`,
|
|
74
|
+
...sequenceLink(read),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const query = bodyDocument.revision ? `?revision=${bodyDocument.revision}` : "";
|
|
78
|
+
const document = await requestOxygen(`/api/cli/email-documents/${encodeURIComponent(bodyDocument.id)}${query}`);
|
|
79
|
+
return { sequence_id: read.sequence.id, step_id: stepId, body_document: bodyDocument, ...document, ...sequenceLink(read) };
|
|
80
|
+
}
|
|
81
|
+
async function contentSet(sequence, options) {
|
|
82
|
+
const stepId = readOption(options.step);
|
|
83
|
+
if (!stepId)
|
|
84
|
+
throw new OxygenError("invalid_request", "--step is required.", { exitCode: 2 });
|
|
85
|
+
const fromDocument = readOption(options.fromDocument);
|
|
86
|
+
const fromStarter = readOption(options.fromStarter);
|
|
87
|
+
const sources = [fromDocument, fromStarter, options.htmlFile || options.jsonFile].filter(Boolean).length;
|
|
88
|
+
if (sources > 1) {
|
|
89
|
+
throw new OxygenError("conflicting_flags", "Pass one of --from-document, --from-starter or --html-file/--json-file.", { exitCode: 2 });
|
|
90
|
+
}
|
|
91
|
+
if (sources === 0) {
|
|
92
|
+
throw new OxygenError("invalid_request", "Pass --html-file, --json-file, --from-document or --from-starter.", { exitCode: 2 });
|
|
93
|
+
}
|
|
94
|
+
const revision = readRevisionFlag(options.revision, "--revision");
|
|
95
|
+
const expectedRevision = readRevisionFlag(options.expectedRevision, "--expected-revision");
|
|
96
|
+
if (revision !== undefined && !fromDocument)
|
|
97
|
+
throw new OxygenError("invalid_request", "--revision applies to --from-document.", { exitCode: 2 });
|
|
98
|
+
const body = fromDocument || fromStarter ? {} : contentBody(options);
|
|
99
|
+
const read = await readSequence(sequence);
|
|
100
|
+
const { bodyDocument } = sequenceContentStep(read.sequence.definition, stepId);
|
|
101
|
+
if (read.sequence.status && read.sequence.status !== "draft") {
|
|
102
|
+
throw new OxygenError("sequence_journey_locked", "A started sequence keeps the content it went live with. Duplicate it to a new draft to change the email.", {
|
|
103
|
+
details: { sequence_id: read.sequence.id, status: read.sequence.status, next_step: `oxygen sequences duplicate ${read.sequence.id}` }, exitCode: 1,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
let saved;
|
|
107
|
+
if (fromDocument) {
|
|
108
|
+
saved = await requestOxygen(`/api/cli/email-documents/${encodeURIComponent(fromDocument)}`, {
|
|
109
|
+
method: "POST", body: {
|
|
110
|
+
action: "duplicate", role: "step",
|
|
111
|
+
...(revision !== undefined ? { revision } : {}),
|
|
112
|
+
...(readOption(options.name) ? { name: readOption(options.name) } : {}),
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
else if (fromStarter) {
|
|
117
|
+
saved = await requestOxygen("/api/cli/email-documents", {
|
|
118
|
+
method: "POST", body: { role: "step", starter_id: fromStarter, name: readOption(options.name) ?? `${read.sequence.name ?? "Sequence"}: ${stepId}` },
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
else if (bodyDocument) {
|
|
122
|
+
saved = await requestOxygen(`/api/cli/email-documents/${encodeURIComponent(bodyDocument.id)}`, {
|
|
123
|
+
method: "PATCH", body: { ...body, ...(expectedRevision !== undefined ? { expected_revision: expectedRevision } : {}) },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
saved = await requestOxygen("/api/cli/email-documents", {
|
|
128
|
+
method: "POST", body: { role: "step", name: `${read.sequence.name ?? "Sequence"}: ${stepId}`, ...body },
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const documentId = saved.document?.id;
|
|
132
|
+
if (!documentId)
|
|
133
|
+
throw new OxygenError("unexpected_response", "The email was saved but its id was not returned; run sequences content get.", { exitCode: 1 });
|
|
134
|
+
const definition = withSequenceStepBodyDocument({ steps: read.sequence.definition.steps ?? [] }, stepId, documentId);
|
|
135
|
+
if (definition) {
|
|
136
|
+
await requestOxygen(`/api/cli/sequences/${encodeURIComponent(read.sequence.id)}`, {
|
|
137
|
+
method: "PATCH", body: { definition, ...(read.sequence_revision ? { expected_revision: read.sequence_revision } : {}) },
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return { ...saved, sequence_id: read.sequence.id, step_id: stepId, body_document: { id: documentId }, definition_updated: definition !== null, ...sequenceLink(read) };
|
|
141
|
+
}
|
|
142
|
+
export function sequencesContentCommand(handleAsyncAction) {
|
|
143
|
+
return new Command("content")
|
|
144
|
+
.description("A newsletter step's designed email body (HTML up to 100 KB), stored as a revisioned email document the step references. Sends through a sending domain; merge tags render per recipient with HTML escaping; {{unsubscribe_url}} places the unsubscribe link (else a footer is added).")
|
|
145
|
+
.addCommand(new Command("get")
|
|
146
|
+
.description("Show an email step's content: its email document (HTML, text, preview text, revision, size against the 100 KB limit), or its plain body when it has none.")
|
|
147
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
148
|
+
.requiredOption("--step <id>", "The email_send step id.")
|
|
149
|
+
.option("--json", "Print a JSON envelope.")
|
|
150
|
+
.action(async (sequence, options) => {
|
|
151
|
+
await handleAsyncAction("sequences content get", options, () => contentGet(sequence, options));
|
|
152
|
+
}))
|
|
153
|
+
.addCommand(new Command("set")
|
|
154
|
+
.description("Set a draft sequence's email step body from an HTML file or an editor JSON export: saves a new revision of the step's email document (creating it on first use) and points the step at it. Scripts and event handlers are removed; HTML above 100 KB is refused.")
|
|
155
|
+
.argument("<sequence>", "Sequence id or slug (a draft).")
|
|
156
|
+
.requiredOption("--step <id>", "The email_send step id.")
|
|
157
|
+
.option("--html-file <path>", "The email HTML.")
|
|
158
|
+
.option("--json-file <path>", "A JSON object { html, text?, preview_text?, editor_json? }, e.g. the editor's export.")
|
|
159
|
+
.option("--text-file <path>", "The plain-text part. Default: derived from the HTML.")
|
|
160
|
+
.option("--preview-text <text>", "The inbox preview line shown after the subject.")
|
|
161
|
+
.option("--name <name>", "Document name.")
|
|
162
|
+
.option("--from-document <id>", "Start from a copy of this email document or template instead of a file.")
|
|
163
|
+
.option("--from-starter <id>", "Start from a copy of a built-in starter template (ids: `oxygen email-templates list`).")
|
|
164
|
+
.option("--revision <n>", "With --from-document: copy this revision (default its current one).")
|
|
165
|
+
.option("--expected-revision <n>", "Refuse the save if the step's document is no longer at this revision (another edit landed).")
|
|
166
|
+
.option("--json", "Print a JSON envelope.")
|
|
167
|
+
.action(async (sequence, options) => {
|
|
168
|
+
await handleAsyncAction("sequences content set", options, () => contentSet(sequence, options));
|
|
169
|
+
}))
|
|
170
|
+
.addCommand(new Command("templates")
|
|
171
|
+
.description("List the workspace's email templates and the built-in starters (no content); start a step from one with `sequences content set --from-document <id>` or `--from-starter <id>`. Manage them with `oxygen email-templates`.")
|
|
172
|
+
.option("--json", "Print a JSON envelope.")
|
|
173
|
+
.action(async (options) => {
|
|
174
|
+
await handleAsyncAction("sequences content templates", options, () => requestOxygen("/api/cli/email-documents?role=template"));
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
/** Validated `--interval`; undefined when absent. */
|
|
3
|
+
export declare function readNewsletterIntervalFlag(value: string | undefined): string | undefined;
|
|
4
|
+
/** The newsletter block of `sequences stats` (empty for a mailbox Sequence). */
|
|
5
|
+
export declare function formatNewsletterStatsLines(value: unknown): string[];
|
|
6
|
+
export declare function sequencesRecipientsCommand(): Command;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { OxygenError } from "@oxygen/shared/cli-result";
|
|
2
|
+
import { NEWSLETTER_RECIPIENT_STATUSES, NEWSLETTER_TIMELINE_INTERVALS, isNewsletterRecipientStatus, isNewsletterTimelineInterval, } from "@oxygen/shared/newsletter-analytics";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { handleReadActionWithLens, renderTextTable } from "./command-support.js";
|
|
5
|
+
import { requestOxygen } from "./http-client.js";
|
|
6
|
+
import { isRecord } from "./util.js";
|
|
7
|
+
/**
|
|
8
|
+
* Newsletter analytics on the CLI: the `newsletter` block `sequences stats`
|
|
9
|
+
* prints for a sending-domain Sequence, its `--interval` flag, and
|
|
10
|
+
* `oxygen sequences recipients`. MCP twin: `oxygen_newsletters` stats / recipients.
|
|
11
|
+
*/
|
|
12
|
+
const record = (value) => (isRecord(value) ? value : {});
|
|
13
|
+
const count = (value) => (typeof value === "number" && Number.isFinite(value) ? value : 0);
|
|
14
|
+
const percent = (value) => (typeof value === "number" && Number.isFinite(value) ? `${(value * 100).toFixed(1)}%` : "—");
|
|
15
|
+
/** Validated `--interval`; undefined when absent. */
|
|
16
|
+
export function readNewsletterIntervalFlag(value) {
|
|
17
|
+
if (value === undefined)
|
|
18
|
+
return undefined;
|
|
19
|
+
if (!isNewsletterTimelineInterval(value)) {
|
|
20
|
+
throw new OxygenError("invalid_request", `--interval must be one of ${NEWSLETTER_TIMELINE_INTERVALS.join(", ")}.`, { exitCode: 2 });
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
/** The newsletter block of `sequences stats` (empty for a mailbox Sequence). */
|
|
25
|
+
export function formatNewsletterStatsLines(value) {
|
|
26
|
+
if (!isRecord(value))
|
|
27
|
+
return [];
|
|
28
|
+
const opens = record(value.opens);
|
|
29
|
+
const clicks = record(value.clicks);
|
|
30
|
+
const bounces = record(value.bounces);
|
|
31
|
+
const rates = record(value.rates);
|
|
32
|
+
const lines = [
|
|
33
|
+
"",
|
|
34
|
+
"Newsletter (SES)",
|
|
35
|
+
`Targeted ${count(value.recipients_targeted)} · sent ${count(value.sent)} · delivered ${count(value.delivered)} · suppressed ${count(value.suppressed)}`,
|
|
36
|
+
...renderTextTable(["METRIC", "UNIQUE", "TOTAL", "RATE"], [
|
|
37
|
+
["Opens (estimated)", String(count(opens.unique)), String(count(opens.total)), percent(rates.open_rate)],
|
|
38
|
+
["Clicks", String(count(clicks.unique)), String(count(clicks.total)), percent(rates.click_rate)],
|
|
39
|
+
["Hard bounces", String(count(bounces.hard)), "", percent(rates.bounce_rate)],
|
|
40
|
+
["Soft bounces", String(count(bounces.soft)), "", ""],
|
|
41
|
+
["Complaints", String(count(value.complaints)), "", percent(rates.complaint_rate)],
|
|
42
|
+
["Unsubscribes", String(count(value.unsubscribes)), "", percent(rates.unsubscribe_rate)],
|
|
43
|
+
]),
|
|
44
|
+
"Opens are estimated (Apple Mail Privacy Protection and scanners open mail); bot events are excluded. Rates are over delivered, bounces over sent.",
|
|
45
|
+
];
|
|
46
|
+
const links = Array.isArray(value.links) ? value.links.filter(isRecord) : [];
|
|
47
|
+
if (links.length > 0) {
|
|
48
|
+
lines.push("", ...renderTextTable(["LINK", "UNIQUE", "TOTAL"], links.map((link) => [
|
|
49
|
+
String(link.link ?? ""), String(count(link.unique_clicks)), String(count(link.total_clicks)),
|
|
50
|
+
])));
|
|
51
|
+
}
|
|
52
|
+
return lines;
|
|
53
|
+
}
|
|
54
|
+
function formatRecipients(data) {
|
|
55
|
+
const recipients = Array.isArray(data.recipients) ? data.recipients.filter(isRecord) : [];
|
|
56
|
+
const total = count(data.total);
|
|
57
|
+
const offset = count(data.offset);
|
|
58
|
+
const lines = [
|
|
59
|
+
`${typeof data.name === "string" ? data.name : "Newsletter"}: ${total} ${data.status ? `${String(data.status)} ` : ""}recipient${total === 1 ? "" : "s"}`
|
|
60
|
+
+ (recipients.length > 0 ? ` (showing ${offset + 1}-${offset + recipients.length})` : ""),
|
|
61
|
+
];
|
|
62
|
+
if (recipients.length > 0) {
|
|
63
|
+
lines.push("", ...renderTextTable(["EMAIL", "STATUS", "OPENS", "CLICKS", "LAST EVENT"], recipients.map((row) => [
|
|
64
|
+
String(row.email ?? ""), String(row.status ?? ""), String(count(row.opens)), String(count(row.clicks)),
|
|
65
|
+
typeof row.last_event_at === "string" ? row.last_event_at : "—",
|
|
66
|
+
])));
|
|
67
|
+
}
|
|
68
|
+
if (typeof data.next_offset === "number")
|
|
69
|
+
lines.push("", `Next page: --offset ${data.next_offset}`);
|
|
70
|
+
const link = typeof data.web_url === "string" ? data.web_url : null;
|
|
71
|
+
if (link)
|
|
72
|
+
lines.push("", link);
|
|
73
|
+
return `${lines.join("\n")}\n`;
|
|
74
|
+
}
|
|
75
|
+
function wholeFlag(value, flag) {
|
|
76
|
+
if (value === undefined)
|
|
77
|
+
return undefined;
|
|
78
|
+
if (!/^\d+$/.test(value.trim()))
|
|
79
|
+
throw new OxygenError("invalid_request", `${flag} must be a non-negative whole number.`, { exitCode: 2 });
|
|
80
|
+
return value.trim();
|
|
81
|
+
}
|
|
82
|
+
export function sequencesRecipientsCommand() {
|
|
83
|
+
return new Command("recipients")
|
|
84
|
+
.description(`A newsletter's (sending-domain Sequence's) recipients, one row per message with its status (${NEWSLETTER_RECIPIENT_STATUSES.join(", ")}), opens, clicks and last event, newest activity first. Paged by --offset. Opens are estimated; bot events are excluded. 0 credits.`)
|
|
85
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
86
|
+
.option("--status <status>", `Only recipients with this status: ${NEWSLETTER_RECIPIENT_STATUSES.join(", ")}.`)
|
|
87
|
+
.option("--limit <n>", "Rows per page (1-200, default 50).")
|
|
88
|
+
.option("--offset <n>", "Skip this many rows (pass next_offset from the previous page).")
|
|
89
|
+
.option("--json", "Print a JSON envelope.")
|
|
90
|
+
.action(async (sequence, options) => {
|
|
91
|
+
await handleReadActionWithLens("sequences recipients", options, () => {
|
|
92
|
+
if (options.status !== undefined && !isNewsletterRecipientStatus(options.status)) {
|
|
93
|
+
throw new OxygenError("invalid_request", `--status must be one of ${NEWSLETTER_RECIPIENT_STATUSES.join(", ")}.`, { exitCode: 2 });
|
|
94
|
+
}
|
|
95
|
+
const params = new URLSearchParams();
|
|
96
|
+
if (options.status)
|
|
97
|
+
params.set("status", options.status);
|
|
98
|
+
const limit = wholeFlag(options.limit, "--limit");
|
|
99
|
+
if (limit !== undefined)
|
|
100
|
+
params.set("limit", limit);
|
|
101
|
+
const offset = wholeFlag(options.offset, "--offset");
|
|
102
|
+
if (offset !== undefined)
|
|
103
|
+
params.set("offset", offset);
|
|
104
|
+
const query = params.toString();
|
|
105
|
+
return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/recipients${query ? `?${query}` : ""}`);
|
|
106
|
+
}, formatRecipients);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { OxygenError } from "@oxygen/shared/cli-result";
|
|
2
|
+
import { NEWSLETTER_TEST_MAX_RECIPIENTS } from "@oxygen/shared/newsletter-test-send";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { parseJsonValue } from "./command-support.js";
|
|
5
|
+
import { requestOxygen } from "./http-client.js";
|
|
6
|
+
import { isRecord, readOption } from "./util.js";
|
|
7
|
+
async function testSend(sequence, options) {
|
|
8
|
+
if (options.listRecipients) {
|
|
9
|
+
if (options.to || options.sampleLead) {
|
|
10
|
+
throw new OxygenError("conflicting_flags", "--list-recipients takes no --to or --sample-lead.", { exitCode: 2 });
|
|
11
|
+
}
|
|
12
|
+
return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/test-send`);
|
|
13
|
+
}
|
|
14
|
+
const stepId = readOption(options.step);
|
|
15
|
+
if (!stepId)
|
|
16
|
+
throw new OxygenError("invalid_request", "--step is required.", { exitCode: 2 });
|
|
17
|
+
const to = (options.to ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
18
|
+
if (to.length === 0)
|
|
19
|
+
throw new OxygenError("invalid_request", "--to is required: one or more member email addresses, comma-separated.", { exitCode: 2 });
|
|
20
|
+
if (to.length > NEWSLETTER_TEST_MAX_RECIPIENTS) {
|
|
21
|
+
throw new OxygenError("invalid_request", `--to takes at most ${NEWSLETTER_TEST_MAX_RECIPIENTS} addresses.`, { exitCode: 2 });
|
|
22
|
+
}
|
|
23
|
+
let sampleLead;
|
|
24
|
+
if (options.sampleLead !== undefined) {
|
|
25
|
+
const parsed = parseJsonValue(options.sampleLead, "--sample-lead");
|
|
26
|
+
if (!isRecord(parsed))
|
|
27
|
+
throw new OxygenError("invalid_request", "--sample-lead must be a JSON object of merge values.", { exitCode: 2 });
|
|
28
|
+
sampleLead = parsed;
|
|
29
|
+
}
|
|
30
|
+
return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/test-send`, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
body: { step_id: stepId, to, ...(sampleLead ? { sample_lead: sampleLead } : {}) },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export function sequencesTestSendCommand(handleAsyncAction) {
|
|
36
|
+
return new Command("test-send")
|
|
37
|
+
.description(`Send a test copy of a newsletter (sending-domain) email step to up to ${NEWSLETTER_TEST_MAX_RECIPIENTS} verified addresses of this workspace's members, rendered as delivery renders it with the subject prefixed "[Test]". Costs 0.05 credits per email; 20 test sends per workspace per day. Writes no Sequence activity and is left out of analytics.`)
|
|
38
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
39
|
+
.option("--step <id>", "The email_send step id.")
|
|
40
|
+
.option("--to <emails>", "Member email addresses, comma-separated.")
|
|
41
|
+
.option("--sample-lead <json>", "Merge values as a JSON object. Default: the first enrolled lead's values, else each tag's fallback.")
|
|
42
|
+
.option("--list-recipients", "List the member addresses a test may go to instead of sending.")
|
|
43
|
+
.option("--json", "Print a JSON envelope.")
|
|
44
|
+
.action(async (sequence, options) => {
|
|
45
|
+
await handleAsyncAction("sequences test-send", options, () => testSend(sequence, options));
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -11,7 +11,8 @@ export function registerWorkspacePlacementCommands(root, handle) {
|
|
|
11
11
|
.option("--request-id <id>", "Request UUID from preview.").option("--plan <hash>", "Exact preview plan hash.").option("--max-live-sends <n>", "Maximum approved diagnostic emails (1–50).")
|
|
12
12
|
.option("--preview-cursor <id>", "Read the next manifest page using the same input, request ID and rendering time.").option("--preview-limit <n>", "Preview manifest page size (1–20); never use with approval.")
|
|
13
13
|
.option("--preview-probe-id <id>", "Read exact rendered copy for a preview probe; reuse request ID and rendering time.").option("--preview-part <part>", "body_text or body_html.").option("--preview-offset <n>", "Unicode character offset returned by copy preview.").option("--preview-copy-limit <n>", "Copy page size (1–3000 characters).")
|
|
14
|
-
.option("--allow-paused-probe", "Explicitly allow diagnostic sends from paused senders, preserving pauses.")
|
|
14
|
+
.option("--allow-paused-probe", "Explicitly allow diagnostic sends from paused senders, preserving pauses.")
|
|
15
|
+
.option("--allow-daily-limit-override", "Staff only: approve daily sender/receiver quota exceptions for this exact test, one diagnostic per sender. Preserve mailbox settings.").option("--json", "Print JSON.")
|
|
15
16
|
.action(async (options) => handle("deliverability workspace-test run", options, () => {
|
|
16
17
|
const input = readJsonFileValue(resolve(options.input), "--input");
|
|
17
18
|
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
@@ -20,7 +21,7 @@ export function registerWorkspacePlacementCommands(root, handle) {
|
|
|
20
21
|
...(options.approved ? { approved: true } : {}), ...(options.requestId ? { request_id: options.requestId } : {}),
|
|
21
22
|
...(options.plan ? { plan_hash: options.plan } : {}), ...(options.renderedAt ? { rendered_at: options.renderedAt } : {}), ...(options.trackingOpens ? { tracking_opens: true } : {}), ...(options.trackingClicks ? { tracking_clicks: true } : {}), ...(options.maxLiveSends ? { max_live_sends: Number(options.maxLiveSends) } : {}),
|
|
22
23
|
...(options.receiverEnvironment ? { receiver_environment: options.receiverEnvironment } : {}), ...(options.receiverWorkspace ? { receiver_workspace_id: options.receiverWorkspace } : {}),
|
|
23
|
-
...(options.allowPausedProbe ? { allow_paused_probe: true } : {}), ...(options.receiverScope ? { receiver_scope: options.receiverScope } : {}), ...(options.receiverProviders ? { receiver_providers: String(options.receiverProviders).split(",") } : {}),
|
|
24
|
+
...(options.allowPausedProbe ? { allow_paused_probe: true } : {}), ...(options.allowDailyLimitOverride ? { allow_daily_limit_override: true } : {}), ...(options.receiverScope ? { receiver_scope: options.receiverScope } : {}), ...(options.receiverProviders ? { receiver_providers: String(options.receiverProviders).split(",") } : {}),
|
|
24
25
|
...(options.previewCursor ? { preview_cursor: options.previewCursor } : {}), ...(options.previewLimit !== undefined ? { preview_limit: Number(options.previewLimit) } : {}),
|
|
25
26
|
...(options.previewProbeId ? { preview_probe_id: options.previewProbeId } : {}), ...(options.previewPart ? { preview_part: options.previewPart } : {}), ...(options.previewOffset !== undefined ? { preview_offset: Number(options.previewOffset) } : {}), ...(options.previewCopyLimit !== undefined ? { preview_copy_limit: Number(options.previewCopyLimit) } : {}) } });
|
|
26
27
|
})))
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { OxygenError } from "@oxygen/shared/cli-result";
|
|
2
2
|
import { evaluateNoveltyScore } from "./refactor-internals/novelty-score.js";
|
|
3
3
|
import { CROSS_ROW_FUNCTIONS, parseFormulaExpression, } from "./expression.js";
|
|
4
|
-
import { FORMULA_FUNCTION_REGISTRY, checkFormulaFunctionArity, compareFormulaValues, formulaExpressionError, formulaValuesEqual, readFormulaNumber, toFormulaBoolean, } from "./formula-functions.js";
|
|
4
|
+
import { FORMULA_FUNCTION_REGISTRY, checkFormulaFunctionArity, compareFormulaValues, unsupportedFormulaFunctionError, formulaExpressionError, formulaValuesEqual, readFormulaNumber, toFormulaBoolean, } from "./formula-functions.js";
|
|
5
5
|
export function evaluateFormulaInScope(expression, scope, options = {}) {
|
|
6
6
|
return evaluateFormulaAst(parseFormulaExpression(expression, options), scope);
|
|
7
7
|
}
|
|
@@ -37,9 +37,7 @@ function evaluateFormulaAstWithMode(node, scope, resolutionMode) {
|
|
|
37
37
|
if (spec?.evaluate) {
|
|
38
38
|
return spec.evaluate(node.args.map(evaluateArgument));
|
|
39
39
|
}
|
|
40
|
-
throw
|
|
41
|
-
function: node.name,
|
|
42
|
-
});
|
|
40
|
+
throw unsupportedFormulaFunctionError(node.name);
|
|
43
41
|
}
|
|
44
42
|
}
|
|
45
43
|
}
|
|
@@ -19,11 +19,6 @@ export type FormulaAst = {
|
|
|
19
19
|
name: string;
|
|
20
20
|
args: FormulaAst[];
|
|
21
21
|
};
|
|
22
|
-
/**
|
|
23
|
-
* Functions evaluated against the whole column rather than the current scope.
|
|
24
|
-
* They are intercepted before registry dispatch (the registry entry carries
|
|
25
|
-
* metadata only) because they need a corpus the scope supplies.
|
|
26
|
-
*/
|
|
27
22
|
export declare const CROSS_ROW_FUNCTIONS: ReadonlySet<string>;
|
|
28
23
|
/**
|
|
29
24
|
* How to tokenize. One flag today, and it is deliberately opt-in: a table formula
|
|
@@ -62,6 +57,13 @@ export declare function validateFormulaAst(ast: FormulaAst): void;
|
|
|
62
57
|
* for days with received_length 290 against the old 256 cap.
|
|
63
58
|
*/
|
|
64
59
|
export declare function validateFormulaRegexLiterals(ast: FormulaAst): void;
|
|
60
|
+
/**
|
|
61
|
+
* SAVE-TIME ONLY, like validateFormulaRegexLiterals: refuse `||` next to a text
|
|
62
|
+
* literal. `||` is logical or here, so SQL's `first_name || ' ' || last_name`
|
|
63
|
+
* parses and stores, then fills every row with `true`. Stored columns keep
|
|
64
|
+
* reading; only a new write is refused, with the concat form to use instead.
|
|
65
|
+
*/
|
|
66
|
+
export declare function validateFormulaTextJoin(expression: string): void;
|
|
65
67
|
/** Validate syntax, supported functions, and arity without evaluating any data. */
|
|
66
68
|
export declare function validateFormulaExpression(expression: string, options?: ParseOptions): void;
|
|
67
69
|
export declare function walkExpression(node: FormulaAst, visit: (node: FormulaAst) => void): void;
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { FORMULA_FUNCTION_REGISTRY, MAX_FORMULA_REGEX_PATTERN_LENGTH, checkFormulaFunctionArity, formulaExpressionError, } from "./formula-functions.js";
|
|
1
|
+
import { FORMULA_FUNCTION_REGISTRY, MAX_FORMULA_REGEX_PATTERN_LENGTH, checkFormulaFunctionArity, formulaExpressionError, unsupportedFormulaFunctionError, } from "./formula-functions.js";
|
|
2
2
|
/**
|
|
3
3
|
* Functions evaluated against the whole column rather than the current scope.
|
|
4
4
|
* They are intercepted before registry dispatch (the registry entry carries
|
|
5
5
|
* metadata only) because they need a corpus the scope supplies.
|
|
6
6
|
*/
|
|
7
|
+
/** SQL an author pasted: `case when`, `cast(x as type)` or `x::type`. */
|
|
8
|
+
const SQL_SYNTAX_PATTERN = /\bcase\s+when\b|\bcast\s*\([^)]*\bas\b|::/i;
|
|
7
9
|
export const CROSS_ROW_FUNCTIONS = new Set(["novelty_score"]);
|
|
8
10
|
export function parseFormulaExpression(expression, options = {}) {
|
|
9
11
|
const parser = new FormulaParser(tokenize(expression, options), expression);
|
|
@@ -30,9 +32,7 @@ export function validateFormulaAst(ast) {
|
|
|
30
32
|
}
|
|
31
33
|
const spec = FORMULA_FUNCTION_REGISTRY.get(normalized);
|
|
32
34
|
if (!spec) {
|
|
33
|
-
throw
|
|
34
|
-
function: node.name,
|
|
35
|
-
});
|
|
35
|
+
throw unsupportedFormulaFunctionError(node.name);
|
|
36
36
|
}
|
|
37
37
|
checkFormulaFunctionArity(spec, node.args.length);
|
|
38
38
|
});
|
|
@@ -78,6 +78,22 @@ export function validateFormulaRegexLiterals(ast) {
|
|
|
78
78
|
});
|
|
79
79
|
});
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* SAVE-TIME ONLY, like validateFormulaRegexLiterals: refuse `||` next to a text
|
|
83
|
+
* literal. `||` is logical or here, so SQL's `first_name || ' ' || last_name`
|
|
84
|
+
* parses and stores, then fills every row with `true`. Stored columns keep
|
|
85
|
+
* reading; only a new write is refused, with the concat form to use instead.
|
|
86
|
+
*/
|
|
87
|
+
export function validateFormulaTextJoin(expression) {
|
|
88
|
+
const tokens = tokenize(expression);
|
|
89
|
+
tokens.forEach((token, index) => {
|
|
90
|
+
if (token.type !== "operator" || token.value !== "||")
|
|
91
|
+
return;
|
|
92
|
+
if (tokens[index - 1]?.type !== "string" && tokens[index + 1]?.type !== "string")
|
|
93
|
+
return;
|
|
94
|
+
throw formulaExpressionError("|| is logical or in formulas, not text joining; join text with concat(first_name, \" \", last_name).", { position: token.position, expression, hint: "Use concat(a, b, ...) to join text; || and or compare truth values." });
|
|
95
|
+
});
|
|
96
|
+
}
|
|
81
97
|
/** Validate syntax, supported functions, and arity without evaluating any data. */
|
|
82
98
|
export function validateFormulaExpression(expression, options = {}) {
|
|
83
99
|
validateFormulaAst(parseFormulaExpression(expression, options));
|
|
@@ -310,6 +326,9 @@ class FormulaParser {
|
|
|
310
326
|
return formulaExpressionError(message, {
|
|
311
327
|
position: token.position,
|
|
312
328
|
expression: this.expression,
|
|
329
|
+
...(SQL_SYNTAX_PATTERN.test(this.expression)
|
|
330
|
+
? { hint: "SQL CASE/CAST is not formula syntax: use if(condition, then, else), switch(value, case1, result1, ..., default) and number(value)." }
|
|
331
|
+
: {}),
|
|
313
332
|
});
|
|
314
333
|
}
|
|
315
334
|
}
|
|
@@ -423,6 +442,9 @@ function tokenize(expression, options = {}) {
|
|
|
423
442
|
position: index,
|
|
424
443
|
character: char,
|
|
425
444
|
expression,
|
|
445
|
+
...(char === "[" || char === "]"
|
|
446
|
+
? { hint: "Formulas have no [index] access: use first(list), last(list), path(value, \"0\") or email_domain(email)." }
|
|
447
|
+
: {}),
|
|
426
448
|
});
|
|
427
449
|
}
|
|
428
450
|
tokens.push({ type: "eof", position: expression.length });
|
|
@@ -50,6 +50,12 @@ export declare function readFormulaJsonPath(value: unknown, rawPath: string): un
|
|
|
50
50
|
export declare function checkFormulaFunctionArity(spec: FormulaFunctionMeta, received: number): void;
|
|
51
51
|
export declare const FORMULA_DATE_UNITS: readonly ["seconds", "minutes", "hours", "days", "weeks", "months", "years"];
|
|
52
52
|
export declare const FORMULA_FUNCTION_REGISTRY: ReadonlyMap<string, FormulaFunctionSpec>;
|
|
53
|
+
/**
|
|
54
|
+
* The refusal for a call to a function the formula language does not have:
|
|
55
|
+
* the likely intended function (`did_you_mean`) and every supported name, so the
|
|
56
|
+
* author corrects the call without another lookup.
|
|
57
|
+
*/
|
|
58
|
+
export declare function unsupportedFormulaFunctionError(name: string): OxygenError;
|
|
53
59
|
export declare const FORMULA_FUNCTION_CATEGORIES: readonly FormulaFunctionCategory[];
|
|
54
60
|
export declare function listFormulaFunctions(category?: FormulaFunctionCategory): FormulaFunctionMeta[];
|
|
55
61
|
/** Operators the expression language supports, for the discovery surfaces. */
|