@oxygen-agent/cli 1.575.19 → 1.591.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
CHANGED
package/dist/help.js
CHANGED
|
@@ -137,6 +137,7 @@ export function applyOxygenHelp(program, binaryName) {
|
|
|
137
137
|
// first-touch surface names the skill that teaches the GTM loops.
|
|
138
138
|
` 2. ${binaryName} skills install --json load the skills that teach the GTM loops (automatic after login)`,
|
|
139
139
|
` 3. ${binaryName} commands --json full machine-readable command grammar (this CLI's self-index)`,
|
|
140
|
+
` Recurring monitors: use a shareable Blueprint; start with ${binaryName} blueprints list --json.`,
|
|
140
141
|
"",
|
|
141
142
|
"Conventions:",
|
|
142
143
|
" --json prints a stable envelope {ok, data|error, meta}; errors always print it.",
|
package/dist/index.js
CHANGED
|
@@ -639,99 +639,217 @@ function readJsonFileValue(path, inputName) {
|
|
|
639
639
|
/**
|
|
640
640
|
* Whitelist mailbox import fields before the local file crosses the network.
|
|
641
641
|
* In particular, a credential cannot be silently sent through the ordinary
|
|
642
|
-
* inline path: the caller must choose --from
|
|
643
|
-
* the encrypted transfer-vault contract.
|
|
642
|
+
* inline path: the caller must choose --from credentials (or the Hypertide
|
|
643
|
+
* shortcut) so the server applies the encrypted transfer-vault contract.
|
|
644
644
|
*/
|
|
645
|
-
function normalizeMailboxImportFile(value,
|
|
645
|
+
function normalizeMailboxImportFile(value, mode) {
|
|
646
646
|
if (!Array.isArray(value) || value.length === 0) {
|
|
647
647
|
throw new OxygenError("invalid_request", "--file must contain a non-empty mailboxes array.", { exitCode: 1 });
|
|
648
648
|
}
|
|
649
|
-
|
|
649
|
+
const rows = value.map((entry, index) => {
|
|
650
650
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
651
651
|
throw new OxygenError("invalid_request", `mailboxes[${index}] must be an object.`, { exitCode: 1 });
|
|
652
652
|
}
|
|
653
|
-
|
|
654
|
-
const row = source === "hypertide"
|
|
655
|
-
? normalizeHypertideMailboxExportRow(rawRow, index)
|
|
656
|
-
: rawRow;
|
|
657
|
-
const hasCredential = typeof row.app_password === "string" || typeof row.password === "string";
|
|
658
|
-
if (source === "inline" && hasCredential) {
|
|
659
|
-
throw new OxygenError("invalid_request", "Mailbox credentials require --from hypertide; the ordinary inline import never accepts or forwards passwords.", { exitCode: 1 });
|
|
660
|
-
}
|
|
661
|
-
return {
|
|
662
|
-
...(typeof row.email_address === "string"
|
|
663
|
-
? { email_address: row.email_address }
|
|
664
|
-
: {}),
|
|
665
|
-
...(typeof row.provider === "string" ? { provider: row.provider } : {}),
|
|
666
|
-
...(typeof row.workspace_external_id === "string"
|
|
667
|
-
? { workspace_external_id: row.workspace_external_id }
|
|
668
|
-
: {}),
|
|
669
|
-
...(source === "hypertide" && typeof row.app_password === "string"
|
|
670
|
-
? { app_password: row.app_password }
|
|
671
|
-
: {}),
|
|
672
|
-
};
|
|
653
|
+
return normalizeMailboxExportRow(entry, index, mode);
|
|
673
654
|
});
|
|
655
|
+
return dedupeMailboxImportRows(rows);
|
|
656
|
+
}
|
|
657
|
+
function summarizeMailboxImportValidation(mailboxes, input) {
|
|
658
|
+
const providers = { google: 0, microsoft: 0 };
|
|
659
|
+
const infrastructurePlatforms = {};
|
|
660
|
+
let credentialRows = 0;
|
|
661
|
+
for (const mailbox of mailboxes) {
|
|
662
|
+
if (mailbox.provider === "google")
|
|
663
|
+
providers.google += 1;
|
|
664
|
+
if (mailbox.provider === "microsoft")
|
|
665
|
+
providers.microsoft += 1;
|
|
666
|
+
if (typeof mailbox.infrastructure_platform === "string") {
|
|
667
|
+
const platform = mailbox.infrastructure_platform;
|
|
668
|
+
infrastructurePlatforms[platform] =
|
|
669
|
+
(infrastructurePlatforms[platform] ?? 0) + 1;
|
|
670
|
+
}
|
|
671
|
+
if (typeof mailbox.app_password === "string")
|
|
672
|
+
credentialRows += 1;
|
|
673
|
+
}
|
|
674
|
+
return {
|
|
675
|
+
valid: true,
|
|
676
|
+
source: input.source,
|
|
677
|
+
...(input.sourceProvider
|
|
678
|
+
? { source_provider: input.sourceProvider }
|
|
679
|
+
: {}),
|
|
680
|
+
rows: mailboxes.length,
|
|
681
|
+
providers,
|
|
682
|
+
infrastructure_platforms: infrastructurePlatforms,
|
|
683
|
+
credential_rows: credentialRows,
|
|
684
|
+
identity_only_rows: mailboxes.length - credentialRows,
|
|
685
|
+
limits: {
|
|
686
|
+
max_rows: MAILBOX_IMPORT_ROW_LIMIT,
|
|
687
|
+
max_file_bytes: MAILBOX_IMPORT_FILE_MAX_BYTES,
|
|
688
|
+
},
|
|
689
|
+
mutation: false,
|
|
690
|
+
network_request: false,
|
|
691
|
+
provider_call: false,
|
|
692
|
+
credits_used: 0,
|
|
693
|
+
next_action: "Re-run the same command without --validate-only to register these mailboxes.",
|
|
694
|
+
};
|
|
674
695
|
}
|
|
675
|
-
const
|
|
696
|
+
const MAILBOX_EMAIL_HEADERS = new Set([
|
|
676
697
|
"address",
|
|
677
698
|
"email",
|
|
678
699
|
"emailaddress",
|
|
700
|
+
"fromemail",
|
|
679
701
|
"mailbox",
|
|
680
702
|
"mailboxaddress",
|
|
681
703
|
"mailboxemail",
|
|
682
704
|
]);
|
|
683
|
-
const
|
|
705
|
+
const MAILBOX_PROVIDER_HEADERS = new Set([
|
|
684
706
|
"emailprovider",
|
|
707
|
+
"esp",
|
|
685
708
|
"mailboxprovider",
|
|
686
709
|
"platform",
|
|
687
710
|
"provider",
|
|
688
711
|
"serviceprovider",
|
|
689
712
|
"type",
|
|
690
713
|
]);
|
|
691
|
-
const
|
|
714
|
+
const MAILBOX_PASSWORD_HEADERS = new Set([
|
|
692
715
|
"apppassword",
|
|
716
|
+
"applicationpassword",
|
|
717
|
+
"googleapppassword",
|
|
693
718
|
"imappassword",
|
|
694
719
|
"mailboxpassword",
|
|
695
720
|
"password",
|
|
696
721
|
"smtppassword",
|
|
697
722
|
]);
|
|
698
|
-
const
|
|
723
|
+
const MAILBOX_WORKSPACE_HEADERS = new Set([
|
|
699
724
|
"externalaccountid",
|
|
725
|
+
"mailboxid",
|
|
726
|
+
"mailboxuid",
|
|
727
|
+
"sourceaccountid",
|
|
728
|
+
"sourcemailboxid",
|
|
729
|
+
"uid",
|
|
700
730
|
"workspaceexternalid",
|
|
701
731
|
]);
|
|
732
|
+
const MAILBOX_PLATFORM_HEADERS = new Set([
|
|
733
|
+
"infrastructure",
|
|
734
|
+
"infrastructureplatform",
|
|
735
|
+
"infraplatform",
|
|
736
|
+
]);
|
|
737
|
+
const MAILBOX_TENANT_HEADERS = new Set([
|
|
738
|
+
"azuretenantid",
|
|
739
|
+
"entratenantid",
|
|
740
|
+
"microsofttenantid",
|
|
741
|
+
"tenantid",
|
|
742
|
+
]);
|
|
743
|
+
const MAILBOX_NON_TRANSFERABLE_SECRET_HEADERS = new Set([
|
|
744
|
+
"accesstoken",
|
|
745
|
+
"applicationsecret",
|
|
746
|
+
"authenticatorsecret",
|
|
747
|
+
"authorization",
|
|
748
|
+
"authorizationcode",
|
|
749
|
+
"bearertoken",
|
|
750
|
+
"clientsecret",
|
|
751
|
+
"clientprivatekey",
|
|
752
|
+
"delegationkey",
|
|
753
|
+
"idtoken",
|
|
754
|
+
"mfacode",
|
|
755
|
+
"mfasecret",
|
|
756
|
+
"oauthaccesstoken",
|
|
757
|
+
"oauthcode",
|
|
758
|
+
"oauthrefreshtoken",
|
|
759
|
+
"oauthtoken",
|
|
760
|
+
"onetimepassword",
|
|
761
|
+
"otp",
|
|
762
|
+
"otpsecret",
|
|
763
|
+
"privatekey",
|
|
764
|
+
"refreshtoken",
|
|
765
|
+
"serviceaccountkey",
|
|
766
|
+
"serviceaccountjson",
|
|
767
|
+
"serviceaccountprivatekey",
|
|
768
|
+
"totp",
|
|
769
|
+
"totpsecret",
|
|
770
|
+
]);
|
|
702
771
|
const MAILBOX_IMPORT_FILE_MAX_BYTES = 5 * 1024 * 1024;
|
|
703
772
|
const MAILBOX_IMPORT_ROW_LIMIT = 500;
|
|
704
773
|
/**
|
|
705
|
-
*
|
|
706
|
-
*
|
|
707
|
-
*
|
|
708
|
-
*
|
|
709
|
-
* are
|
|
710
|
-
*
|
|
711
|
-
* boundary; Oxygen derives the standard Google/Microsoft endpoints itself.
|
|
774
|
+
* Normalize common mailbox-vendor export labels locally, then send only
|
|
775
|
+
* Oxygen's canonical fields. Passwords are accepted only in credential mode
|
|
776
|
+
* and only become Google app passwords; Microsoft passwords are dropped because
|
|
777
|
+
* tenant consent is its only warmup path. OAuth grants, MFA/TOTP seeds, and
|
|
778
|
+
* delegation keys are rejected in every mode. Host columns may prove the
|
|
779
|
+
* provider but never cross the request boundary.
|
|
712
780
|
*/
|
|
713
|
-
function
|
|
781
|
+
function normalizeMailboxExportRow(row, index, mode) {
|
|
714
782
|
const byHeader = new Map();
|
|
715
783
|
for (const [header, value] of Object.entries(row)) {
|
|
716
|
-
|
|
784
|
+
const normalizedHeader = normalizeMailboxExportHeader(header);
|
|
785
|
+
const existing = byHeader.get(normalizedHeader);
|
|
786
|
+
if (existing !== undefined &&
|
|
787
|
+
String(existing).trim() !== String(value ?? "").trim()) {
|
|
788
|
+
throw new OxygenError("invalid_request", `mailboxes[${index}] contains conflicting columns that normalize to ${normalizedHeader}.`, { exitCode: 1 });
|
|
789
|
+
}
|
|
790
|
+
byHeader.set(normalizedHeader, value);
|
|
791
|
+
}
|
|
792
|
+
const email = readUniqueMailboxExportString(byHeader, MAILBOX_EMAIL_HEADERS, index, "email_address", (value) => value.trim().toLowerCase()) ?? readEmailShapedMailboxUsername(byHeader);
|
|
793
|
+
const providerValues = readMailboxExportStrings(byHeader, MAILBOX_PROVIDER_HEADERS);
|
|
794
|
+
const provider = normalizeMailboxProvider(providerValues, byHeader, index);
|
|
795
|
+
if (!email ||
|
|
796
|
+
email.length > 320 ||
|
|
797
|
+
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
798
|
+
throw new OxygenError("invalid_request", `mailboxes[${index}].email_address must be a valid address of at most 320 characters.`, { exitCode: 1 });
|
|
799
|
+
}
|
|
800
|
+
if (!provider) {
|
|
801
|
+
throw new OxygenError("invalid_request", `mailboxes[${index}].provider could not be inferred. Oxygen imports Google Workspace and Microsoft 365/Entra identities; generic SMTP credentials are not transferable yet.`, { exitCode: 1 });
|
|
717
802
|
}
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
const passwords = readMailboxExportStrings(byHeader,
|
|
803
|
+
if (hasMailboxExportValue(byHeader, MAILBOX_NON_TRANSFERABLE_SECRET_HEADERS) ||
|
|
804
|
+
hasNonTransferableMailboxAuthLikeValue(byHeader)) {
|
|
805
|
+
throw new OxygenError("invalid_request", `mailboxes[${index}] contains OAuth, MFA/TOTP, client-secret, or delegation material. Those credentials are never transferable; import the mailbox identity and complete the provider's administrator consent flow.`, { exitCode: 1 });
|
|
806
|
+
}
|
|
807
|
+
const passwords = readMailboxExportStrings(byHeader, MAILBOX_PASSWORD_HEADERS);
|
|
723
808
|
const distinctPasswords = [...new Set(passwords)];
|
|
809
|
+
if (distinctPasswords.some((password) => password.length > 1024)) {
|
|
810
|
+
throw new OxygenError("invalid_request", `mailboxes[${index}].app_password must be at most 1024 characters.`, { exitCode: 1 });
|
|
811
|
+
}
|
|
812
|
+
if (mode === "identity" && distinctPasswords.length > 0) {
|
|
813
|
+
throw new OxygenError("invalid_request", "Mailbox credentials require --from credentials --vendor <source> (or --from hypertide for a Hypertide export); keep --validate-only for a no-network preflight. An identity import never accepts or forwards passwords.", { exitCode: 1 });
|
|
814
|
+
}
|
|
724
815
|
if (provider !== "microsoft" && distinctPasswords.length > 1) {
|
|
725
816
|
throw new OxygenError("invalid_request", `mailboxes[${index}] contains conflicting SMTP/IMAP/app-password values.`, { exitCode: 1 });
|
|
726
817
|
}
|
|
727
|
-
|
|
818
|
+
if (mode === "credential" &&
|
|
819
|
+
provider === "google" &&
|
|
820
|
+
distinctPasswords.length === 0) {
|
|
821
|
+
throw new OxygenError("invalid_request", `mailboxes[${index}] needs a Google app password for credential import. OAuth tokens and an ordinary account password are not portable.`, { exitCode: 1 });
|
|
822
|
+
}
|
|
823
|
+
const workspaceExternalId = readUniqueMailboxExportString(byHeader, MAILBOX_WORKSPACE_HEADERS, index, "workspace_external_id", (value) => value.trim());
|
|
824
|
+
if (workspaceExternalId && workspaceExternalId.length > 512) {
|
|
825
|
+
throw new OxygenError("invalid_request", `mailboxes[${index}].workspace_external_id must be at most 512 characters.`, { exitCode: 1 });
|
|
826
|
+
}
|
|
827
|
+
const tenantId = readUniqueMailboxExportString(byHeader, MAILBOX_TENANT_HEADERS, index, "tenant_id", (value) => value.trim().toLowerCase());
|
|
828
|
+
if (tenantId && provider !== "microsoft") {
|
|
829
|
+
throw new OxygenError("invalid_request", `mailboxes[${index}].tenant_id applies only to Microsoft mailboxes.`, { exitCode: 1 });
|
|
830
|
+
}
|
|
831
|
+
if (tenantId &&
|
|
832
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(tenantId.trim())) {
|
|
833
|
+
throw new OxygenError("invalid_request", `mailboxes[${index}].tenant_id must be a Microsoft Entra tenant GUID.`, { exitCode: 1 });
|
|
834
|
+
}
|
|
835
|
+
const infrastructurePlatformRaw = readUniqueMailboxExportString(byHeader, MAILBOX_PLATFORM_HEADERS, index, "infrastructure_platform", (value) => normalizeMailboxExportHeader(value)) ??
|
|
836
|
+
(providerValues.some((value) => ["azure", "entra", "microsoftazure"].includes(normalizeMailboxExportHeader(value)))
|
|
837
|
+
? "microsoft_azure"
|
|
838
|
+
: null);
|
|
839
|
+
const infrastructurePlatform = normalizeMailboxInfrastructurePlatform(infrastructurePlatformRaw, provider, index);
|
|
728
840
|
return {
|
|
729
|
-
|
|
730
|
-
|
|
841
|
+
email_address: email.trim().toLowerCase(),
|
|
842
|
+
provider,
|
|
731
843
|
...(workspaceExternalId !== null
|
|
732
|
-
? { workspace_external_id: workspaceExternalId }
|
|
844
|
+
? { workspace_external_id: workspaceExternalId.trim() }
|
|
845
|
+
: {}),
|
|
846
|
+
...(infrastructurePlatformRaw
|
|
847
|
+
? { infrastructure_platform: infrastructurePlatform }
|
|
733
848
|
: {}),
|
|
734
|
-
...(
|
|
849
|
+
...(tenantId ? { tenant_id: tenantId.trim().toLowerCase() } : {}),
|
|
850
|
+
...(mode === "credential" &&
|
|
851
|
+
provider === "google" &&
|
|
852
|
+
distinctPasswords[0] !== undefined
|
|
735
853
|
? { app_password: distinctPasswords[0] }
|
|
736
854
|
: {}),
|
|
737
855
|
};
|
|
@@ -752,6 +870,16 @@ function readMailboxExportString(byHeader, headers) {
|
|
|
752
870
|
}
|
|
753
871
|
return null;
|
|
754
872
|
}
|
|
873
|
+
function readUniqueMailboxExportString(byHeader, headers, index, field, normalize) {
|
|
874
|
+
const values = readMailboxExportStrings(byHeader, headers);
|
|
875
|
+
if (values.length === 0)
|
|
876
|
+
return null;
|
|
877
|
+
const normalized = [...new Set(values.map(normalize))];
|
|
878
|
+
if (normalized.length > 1) {
|
|
879
|
+
throw new OxygenError("invalid_request", `mailboxes[${index}] contains conflicting ${field} aliases.`, { exitCode: 1 });
|
|
880
|
+
}
|
|
881
|
+
return normalized[0] ?? null;
|
|
882
|
+
}
|
|
755
883
|
function readMailboxExportStrings(byHeader, headers) {
|
|
756
884
|
const values = [];
|
|
757
885
|
for (const header of headers) {
|
|
@@ -761,24 +889,115 @@ function readMailboxExportStrings(byHeader, headers) {
|
|
|
761
889
|
}
|
|
762
890
|
return values;
|
|
763
891
|
}
|
|
892
|
+
function hasMailboxExportValue(byHeader, headers) {
|
|
893
|
+
for (const header of headers) {
|
|
894
|
+
const value = byHeader.get(header);
|
|
895
|
+
if (value !== undefined && value !== null && String(value).trim()) {
|
|
896
|
+
return true;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
return false;
|
|
900
|
+
}
|
|
901
|
+
function hasNonTransferableMailboxAuthLikeValue(byHeader) {
|
|
902
|
+
for (const [header, value] of byHeader) {
|
|
903
|
+
if (/(?:token|secret|privatekey|totp|passcode|authorization)/.test(header) &&
|
|
904
|
+
value !== undefined &&
|
|
905
|
+
value !== null &&
|
|
906
|
+
String(value).trim()) {
|
|
907
|
+
return true;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
return false;
|
|
911
|
+
}
|
|
764
912
|
function readEmailShapedMailboxUsername(byHeader) {
|
|
765
913
|
for (const header of ["username", "imapusername", "smtpusername"]) {
|
|
766
914
|
const value = byHeader.get(header);
|
|
767
915
|
if (typeof value === "string" && value.includes("@"))
|
|
768
|
-
return value;
|
|
916
|
+
return value.trim().toLowerCase();
|
|
769
917
|
}
|
|
770
918
|
return null;
|
|
771
919
|
}
|
|
772
|
-
function
|
|
773
|
-
const
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
920
|
+
function normalizeMailboxProvider(providerValues, byHeader, index) {
|
|
921
|
+
const explicit = providerValues.map((value) => {
|
|
922
|
+
const normalized = normalizeMailboxExportHeader(value);
|
|
923
|
+
if (["google", "googleworkspace", "gmail", "gsuite"].includes(normalized)) {
|
|
924
|
+
return "google";
|
|
925
|
+
}
|
|
926
|
+
if ([
|
|
927
|
+
"azure",
|
|
928
|
+
"entra",
|
|
929
|
+
"m365",
|
|
930
|
+
"microsoft",
|
|
931
|
+
"microsoft365",
|
|
932
|
+
"microsoftazure",
|
|
933
|
+
"ms365",
|
|
934
|
+
"o365",
|
|
935
|
+
"office365",
|
|
936
|
+
"outlook",
|
|
937
|
+
].includes(normalized)) {
|
|
938
|
+
return "microsoft";
|
|
939
|
+
}
|
|
940
|
+
throw new OxygenError("invalid_request", `mailboxes[${index}].provider is unsupported; expected Google/Gmail/Workspace or Microsoft/Outlook/Office 365/Entra.`, { exitCode: 1 });
|
|
941
|
+
});
|
|
942
|
+
const explicitKinds = [...new Set(explicit)];
|
|
943
|
+
if (explicitKinds.length > 1) {
|
|
944
|
+
throw new OxygenError("invalid_request", `mailboxes[${index}] contains conflicting provider aliases.`, { exitCode: 1 });
|
|
945
|
+
}
|
|
946
|
+
const hostValues = [
|
|
947
|
+
byHeader.get("imaphost"),
|
|
948
|
+
byHeader.get("imaphostname"),
|
|
949
|
+
byHeader.get("imapserver"),
|
|
950
|
+
byHeader.get("smtphost"),
|
|
951
|
+
byHeader.get("smtphostname"),
|
|
952
|
+
byHeader.get("smtpserver"),
|
|
953
|
+
].filter((value) => typeof value === "string" && Boolean(value.trim()));
|
|
954
|
+
const hostKinds = hostValues.map((value) => {
|
|
955
|
+
const hostname = normalizeMailboxHost(value);
|
|
956
|
+
if (hostname === "smtp.gmail.com" || hostname === "imap.gmail.com") {
|
|
957
|
+
return "google";
|
|
958
|
+
}
|
|
959
|
+
if (hostname === "smtp.office365.com" ||
|
|
960
|
+
hostname === "outlook.office365.com") {
|
|
961
|
+
return "microsoft";
|
|
962
|
+
}
|
|
963
|
+
throw new OxygenError("invalid_request", `mailboxes[${index}] contains a non-standard SMTP/IMAP host. Oxygen imports Google Workspace and Microsoft 365/Entra identities; generic SMTP credential transport is not supported.`, { exitCode: 1 });
|
|
964
|
+
});
|
|
965
|
+
const inferredKinds = [...new Set(hostKinds)];
|
|
966
|
+
if (inferredKinds.length > 1) {
|
|
967
|
+
throw new OxygenError("invalid_request", `mailboxes[${index}] contains conflicting Google and Microsoft mail hosts.`, { exitCode: 1 });
|
|
968
|
+
}
|
|
969
|
+
const explicitKind = explicitKinds[0];
|
|
970
|
+
const inferredKind = inferredKinds[0];
|
|
971
|
+
if (explicitKind && inferredKind && explicitKind !== inferredKind) {
|
|
972
|
+
throw new OxygenError("invalid_request", `mailboxes[${index}] has conflicting provider and SMTP/IMAP host evidence.`, { exitCode: 1 });
|
|
973
|
+
}
|
|
974
|
+
return explicitKind ?? inferredKind ?? null;
|
|
975
|
+
}
|
|
976
|
+
function normalizeMailboxHost(value) {
|
|
977
|
+
const withoutScheme = value
|
|
978
|
+
.trim()
|
|
979
|
+
.toLowerCase()
|
|
980
|
+
.replace(/^[a-z]+:\/\//, "");
|
|
981
|
+
return withoutScheme.split(/[/:]/, 1)[0] ?? "";
|
|
982
|
+
}
|
|
983
|
+
function normalizeMailboxInfrastructurePlatform(raw, provider, index) {
|
|
984
|
+
if (!raw) {
|
|
985
|
+
return provider === "google" ? "google_workspace" : "microsoft_365";
|
|
986
|
+
}
|
|
987
|
+
const normalized = normalizeMailboxExportHeader(raw);
|
|
988
|
+
if (["google", "googleworkspace", "gmail", "gsuite", "workspace"].includes(normalized)) {
|
|
989
|
+
if (provider !== "google") {
|
|
990
|
+
throw mailboxPlatformConflict(index, provider);
|
|
991
|
+
}
|
|
992
|
+
return "google_workspace";
|
|
993
|
+
}
|
|
994
|
+
if (["azure", "entra", "microsoftazure"].includes(normalized)) {
|
|
995
|
+
if (provider !== "microsoft") {
|
|
996
|
+
throw mailboxPlatformConflict(index, provider);
|
|
997
|
+
}
|
|
998
|
+
return "microsoft_azure";
|
|
778
999
|
}
|
|
779
1000
|
if ([
|
|
780
|
-
"azure",
|
|
781
|
-
"entra",
|
|
782
1001
|
"m365",
|
|
783
1002
|
"microsoft",
|
|
784
1003
|
"microsoft365",
|
|
@@ -787,25 +1006,50 @@ function normalizeHypertideMailboxProvider(providerRaw, byHeader, index) {
|
|
|
787
1006
|
"office365",
|
|
788
1007
|
"outlook",
|
|
789
1008
|
].includes(normalized)) {
|
|
790
|
-
|
|
1009
|
+
if (provider !== "microsoft") {
|
|
1010
|
+
throw mailboxPlatformConflict(index, provider);
|
|
1011
|
+
}
|
|
1012
|
+
return "microsoft_365";
|
|
791
1013
|
}
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
.toLowerCase();
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
1014
|
+
throw new OxygenError("invalid_request", `mailboxes[${index}].infrastructure_platform is unsupported; expected Google Workspace, Microsoft 365, Azure, or Entra.`, { exitCode: 1 });
|
|
1015
|
+
}
|
|
1016
|
+
function mailboxPlatformConflict(index, provider) {
|
|
1017
|
+
return new OxygenError("invalid_request", `mailboxes[${index}].infrastructure_platform conflicts with provider=${provider}.`, { exitCode: 1 });
|
|
1018
|
+
}
|
|
1019
|
+
function dedupeMailboxImportRows(rows) {
|
|
1020
|
+
const unique = new Map();
|
|
1021
|
+
for (const row of rows) {
|
|
1022
|
+
const address = String(row.email_address).toLowerCase();
|
|
1023
|
+
const existing = unique.get(address);
|
|
1024
|
+
if (!existing) {
|
|
1025
|
+
unique.set(address, row);
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
if (JSON.stringify(existing) !== JSON.stringify(row)) {
|
|
1029
|
+
throw new OxygenError("invalid_request", `Duplicate mailbox ${address} has conflicting provider, platform, tenant, external id, or credential fields.`, { exitCode: 1 });
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
return [...unique.values()];
|
|
1033
|
+
}
|
|
1034
|
+
function normalizeMailboxImportVendor(raw, from) {
|
|
1035
|
+
if (from === "hypertide") {
|
|
1036
|
+
if (raw && raw.trim().toLowerCase() !== "hypertide") {
|
|
1037
|
+
throw new OxygenError("invalid_request", "--from hypertide is fixed to --vendor hypertide.", { exitCode: 1 });
|
|
1038
|
+
}
|
|
1039
|
+
return null;
|
|
1040
|
+
}
|
|
1041
|
+
if (from === "credentials" && !raw) {
|
|
1042
|
+
throw new OxygenError("invalid_request", "--vendor <source> is required with --from credentials so the imported mailbox keeps its provenance.", { exitCode: 1 });
|
|
1043
|
+
}
|
|
1044
|
+
if (!raw)
|
|
807
1045
|
return null;
|
|
808
|
-
|
|
1046
|
+
const normalized = raw.trim().toLowerCase();
|
|
1047
|
+
if (normalized.length < 1 ||
|
|
1048
|
+
normalized.length > 64 ||
|
|
1049
|
+
!/^[a-z0-9]+(?:[._-][a-z0-9]+)*$/.test(normalized)) {
|
|
1050
|
+
throw new OxygenError("invalid_request", "--vendor must be 1-64 lowercase letters, digits, dots, underscores, or hyphens.", { exitCode: 1 });
|
|
1051
|
+
}
|
|
1052
|
+
return normalized;
|
|
809
1053
|
}
|
|
810
1054
|
async function readMailboxImportFile(path) {
|
|
811
1055
|
let buffer;
|
|
@@ -813,7 +1057,7 @@ async function readMailboxImportFile(path) {
|
|
|
813
1057
|
buffer = readFileSync(path);
|
|
814
1058
|
}
|
|
815
1059
|
catch {
|
|
816
|
-
throw new OxygenError("mailbox_import_file_unreadable", `Couldn't read --file '${basename(path)}'. Check that the path exists and is readable.
|
|
1060
|
+
throw new OxygenError("mailbox_import_file_unreadable", `Couldn't read --file '${basename(path)}'. Check that the path exists and is readable. CSV/JSON/JSONL/XLSX identity files are accepted; credential exports require --from credentials (or --from hypertide). See https://oxygen-agent.com/docs/providers/mailbox-compatibility.`, { exitCode: 1 });
|
|
817
1061
|
}
|
|
818
1062
|
if (buffer.byteLength > MAILBOX_IMPORT_FILE_MAX_BYTES) {
|
|
819
1063
|
throw new OxygenError("invalid_request", `Mailbox import files must be ${MAILBOX_IMPORT_FILE_MAX_BYTES / 1024 / 1024} MB or smaller.`, { exitCode: 1 });
|
|
@@ -821,7 +1065,13 @@ async function readMailboxImportFile(path) {
|
|
|
821
1065
|
const format = inferRowsFileFormat(path);
|
|
822
1066
|
let rows;
|
|
823
1067
|
if (format === "json") {
|
|
824
|
-
|
|
1068
|
+
let parsed;
|
|
1069
|
+
try {
|
|
1070
|
+
parsed = JSON.parse(buffer.toString("utf8"));
|
|
1071
|
+
}
|
|
1072
|
+
catch {
|
|
1073
|
+
throw new OxygenError("invalid_json", "--file must contain valid JSON. File contents are omitted from this error because mailbox imports may contain credentials.", { exitCode: 1 });
|
|
1074
|
+
}
|
|
825
1075
|
if (Array.isArray(parsed)) {
|
|
826
1076
|
rows = parsed;
|
|
827
1077
|
}
|
|
@@ -836,7 +1086,12 @@ async function readMailboxImportFile(path) {
|
|
|
836
1086
|
}
|
|
837
1087
|
}
|
|
838
1088
|
else {
|
|
839
|
-
|
|
1089
|
+
try {
|
|
1090
|
+
rows = await parseRowsFileBuffer(buffer, format);
|
|
1091
|
+
}
|
|
1092
|
+
catch {
|
|
1093
|
+
throw new OxygenError("invalid_mailbox_import_file", `Couldn't parse the ${format.toUpperCase()} mailbox file. File contents are omitted from this error because mailbox imports may contain credentials.`, { exitCode: 1 });
|
|
1094
|
+
}
|
|
840
1095
|
}
|
|
841
1096
|
if (rows.length > MAILBOX_IMPORT_ROW_LIMIT) {
|
|
842
1097
|
throw new OxygenError("invalid_request", `Mailbox imports are limited to ${MAILBOX_IMPORT_ROW_LIMIT} rows per request.`, { exitCode: 1 });
|
|
@@ -1233,7 +1488,10 @@ function readCrmEnrichmentMaxCredits(value) {
|
|
|
1233
1488
|
return credits === undefined ? {} : { max_credits: credits };
|
|
1234
1489
|
}
|
|
1235
1490
|
function buildCrmSearchBody(query, options) {
|
|
1236
|
-
|
|
1491
|
+
// `crm search` is the only command in the family that took the plural, which
|
|
1492
|
+
// cost a failed invocation every time an agent reached for the sibling
|
|
1493
|
+
// spelling. Both are accepted; the plural still wins if somebody passes both.
|
|
1494
|
+
const objects = readCsvOption(options.objects ?? options.object);
|
|
1237
1495
|
const limit = readPositiveInt(options.limit);
|
|
1238
1496
|
return {
|
|
1239
1497
|
query,
|
|
@@ -3405,6 +3663,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
3405
3663
|
.description("Search CRM records by identity or record label.")
|
|
3406
3664
|
.argument("<query>", "Domain, email, LinkedIn URL, or record name to search for.")
|
|
3407
3665
|
.option("--objects <objects>", "Comma-separated CRM object slugs to search. Defaults to all configured objects.")
|
|
3666
|
+
.option("--object <object>", "Alias for --objects; every sibling crm command spells it singular.")
|
|
3408
3667
|
.option("--limit <limit>", "Maximum records to return.")
|
|
3409
3668
|
.option("--json", "Print a JSON envelope.")
|
|
3410
3669
|
.action(async (query, options) => {
|
|
@@ -3545,9 +3804,9 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
3545
3804
|
}));
|
|
3546
3805
|
}))
|
|
3547
3806
|
.addCommand(new Command("timeline")
|
|
3548
|
-
.description("Show a CRM record's activity timeline, newest first.")
|
|
3807
|
+
.description("Show a CRM record's activity timeline, newest first. Address the record by row id OR by any of the object's identities \u2014 a company domain, a person's email or LinkedIn URL.")
|
|
3549
3808
|
.argument("<object>", "CRM object slug, such as companies or people.")
|
|
3550
|
-
.argument("<
|
|
3809
|
+
.argument("<record>", "CRM record row id, or an identity value such as acme.com or sarah@acme.com.")
|
|
3551
3810
|
.option("--limit <limit>", "Maximum activities to return.")
|
|
3552
3811
|
.option("--cursor <cursor>", "Pagination cursor from a previous page.")
|
|
3553
3812
|
.option("--json", "Print a JSON envelope.")
|
|
@@ -5581,13 +5840,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5581
5840
|
});
|
|
5582
5841
|
}))
|
|
5583
5842
|
.addCommand(new Command("apply")
|
|
5584
|
-
.description("Apply a blueprint
|
|
5843
|
+
.description("Apply a blueprint: 0 credits and no provider calls or external writes, but creates workspace tables, columns, prompts, and a disabled workflow. It does not enable or run the workflow.")
|
|
5585
5844
|
.argument("[slug]", "Blueprint slug (for stored or seed blueprints).")
|
|
5586
5845
|
.option("--file <path>", "Read a blueprint envelope from a local JSON file.")
|
|
5587
5846
|
.option("--from-url <url>", "Fetch a shared blueprint envelope from a public Oxygen share URL.")
|
|
5588
5847
|
.option("--input-json <json>", "Seed blueprint input (parameters) as JSON.")
|
|
5589
5848
|
.option("--table-ref <ref=id...>", "Reuse an existing table for a blueprint ref (repeatable).", collectMultiple, [])
|
|
5590
|
-
.option("--workflow-id <id>", "
|
|
5849
|
+
.option("--workflow-id <id>", "Set the new workflow's manifest id/slug; this does not update or reuse an existing workflow.")
|
|
5591
5850
|
.option("--workflow-name <name>", "Override the resulting workflow name.")
|
|
5592
5851
|
.option("--json", "Print a JSON envelope.")
|
|
5593
5852
|
.action(async (slug, options) => {
|
|
@@ -5723,7 +5982,17 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5723
5982
|
const qs = params.toString() ? `?${params.toString()}` : "";
|
|
5724
5983
|
return requestOxygen(`/api/blueprints/marketplace${qs}`, { requireAuth: false });
|
|
5725
5984
|
});
|
|
5726
|
-
}))
|
|
5985
|
+
}))
|
|
5986
|
+
.addHelpText("after", [
|
|
5987
|
+
"",
|
|
5988
|
+
"Safety:",
|
|
5989
|
+
" list, describe, and preflight use 0 credits, make no provider calls, and do not change workspace state.",
|
|
5990
|
+
" export has the same workspace safety; --out only writes the named local file.",
|
|
5991
|
+
" apply also uses 0 credits and makes no provider calls or external writes, but it creates workspace",
|
|
5992
|
+
" tables, prompts, and a disabled workflow. Its response reports future per-run credit ceilings.",
|
|
5993
|
+
" save, archive, tag, share, publish, and their inverse commands change workspace or publication state.",
|
|
5994
|
+
"",
|
|
5995
|
+
].join("\n"));
|
|
5727
5996
|
program
|
|
5728
5997
|
.command("recipes")
|
|
5729
5998
|
.description("Business-case GTM playbooks: proven plays with prerequisites, credit posture, and approval gates spelled out.")
|
|
@@ -5856,7 +6125,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5856
6125
|
.command("columns")
|
|
5857
6126
|
.description("Workspace table column commands.")
|
|
5858
6127
|
.addCommand(new Command("add")
|
|
5859
|
-
.description("Add a nullable column to a workspace table.")
|
|
6128
|
+
.description("Add a nullable column to a workspace table. Writes the definition only — this never runs the column and never spends credits; use `columns run` for that, with --dry-run first to see the cost.")
|
|
5860
6129
|
.argument("<table>", "Table id or slug.")
|
|
5861
6130
|
.option("--label <label>", "Display label for the new column. Required unless --prompt-key supplies a default title.")
|
|
5862
6131
|
.option("--key <key>", "Optional stable column key. Defaults to a normalized label.")
|
|
@@ -6017,7 +6286,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
6017
6286
|
.description("Run an executable AI, tool, formula, enrichment, bind, lookup, or local custom HTTP column for one row or a bounded batch. Paid server-side columns always run durably in the background. Bind create-mode (onNoMatch=create) needs --approved.")
|
|
6018
6287
|
.argument("<table>", "Table id or slug.")
|
|
6019
6288
|
.argument("<column>", "Column id or key.")
|
|
6020
|
-
.option("--row-id <row_id>", "Workspace row id to run.")
|
|
6289
|
+
.option("--row-id <row_id>", "Workspace row id to run. Get one from `oxygen tables query <table> --limit 1 --json` — the field is `_row_id`, not `id`.")
|
|
6021
6290
|
.option("--limit <n>", "Run the next N rows whose target cell is still empty (--force runs the first N regardless). Repeat until rowCount is 0 to page through a table. Defaults to 10; inline deterministic runs have a hard cap of 25.")
|
|
6022
6291
|
.option("--all", "Run all rows. Requires --background.")
|
|
6023
6292
|
.option("--filter-json <json>", "Row selector filter object or array for background runs. Do not combine with --all, --limit, or --row-id.")
|
|
@@ -7108,7 +7377,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
7108
7377
|
})));
|
|
7109
7378
|
program
|
|
7110
7379
|
.command("billing")
|
|
7111
|
-
.description("Plan and managed credit commands. Spend splits into FLEXIBLE (ad-hoc: enrichment, AI, automation — drawn from your free-to-spend balance) and FIXED recurring per-resource monthly commitments blocked out of it; see `billing commitments`.")
|
|
7380
|
+
.description("Plan and managed credit commands. Spend splits into FLEXIBLE (ad-hoc: enrichment, AI, automation — drawn from your free-to-spend balance) and FIXED recurring per-resource monthly commitments blocked out of it; see `billing commitments`. THREE CLOCKS, deliberately different: the CREDIT CYCLE that `billing allowance` reports against (your plan's monthly grant window); each resource's own COMMITMENT RENEWAL, anchored to the day you connected it, so `billing commitments --json` next_due_at rarely matches the cycle end; and your SUBSCRIPTION PERIOD in `billing balance` (annual plans span many credit cycles). A number from one clock will not reconcile against another.")
|
|
7112
7381
|
.addCommand(new Command("change")
|
|
7113
7382
|
.description("Preview an upgrade or downgrade and return a Stripe confirmation link. Nothing changes until confirmed in Stripe.")
|
|
7114
7383
|
.requiredOption("--to <tier>", "Target plan: starter, pro, or team.")
|
|
@@ -7126,13 +7395,13 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
7126
7395
|
await handleAsyncAction("billing balance", options, () => requestOxygen("/api/cli/billing/balance"));
|
|
7127
7396
|
}))
|
|
7128
7397
|
.addCommand(new Command("commitments")
|
|
7129
|
-
.description("List the fixed monthly credit commitments blocked at your subscription renewal — per connected sending mailbox, OXYGEN-sold mailbox, warm-up, deliverability, and connected LinkedIn account — with unit price, quantity, and next-due date. Read-only, 0 Oxygen credits.")
|
|
7398
|
+
.description("List the fixed monthly credit commitments blocked at your subscription renewal — per connected sending mailbox, OXYGEN-sold mailbox, warm-up, deliverability, and connected LinkedIn account — with unit price, quantity, and next-due date. This is the FORWARD run-rate: what your currently-connected resources will cost at their next renewal. It is NOT this cycle's charges — compare `billing allowance` fixed_spent_credits for that, and expect the two to differ. Each resource renews on its OWN anchor (the day it was connected), so next_due_at is per-resource and rarely lines up with the credit cycle. Read-only, 0 Oxygen credits.")
|
|
7130
7399
|
.option("--json", "Print a JSON envelope.")
|
|
7131
7400
|
.action(async (options) => {
|
|
7132
7401
|
await handleAsyncAction("billing commitments", options, () => requestOxygen("/api/cli/billing/commitments"));
|
|
7133
7402
|
}))
|
|
7134
7403
|
.addCommand(new Command("allowance")
|
|
7135
|
-
.description("Show this billing cycle's credit pool as one breakdown: fixed spend, flexible spend, credits committed to the next renewal, and what is free to spend — the numbers behind the bar on the credit-usage page. The parts always sum to the total. Read-only, 0 Oxygen credits.")
|
|
7404
|
+
.description("Show this billing cycle's credit pool as one breakdown: fixed spend, flexible spend, credits committed to the next renewal, and what is free to spend — the numbers behind the bar on the credit-usage page. The parts always sum to the total. fixed_spent_credits is what has ALREADY been charged this cycle, NOT your monthly run-rate — for the forward figure billed at the next renewal see `billing commitments`, which is normally much larger. reserved_credits is in-flight spend already carved out of free_to_spend_credits, so treat free_to_spend as the ceiling and free_to_spend minus reserved as what is genuinely uncommitted. Every segment carries region=fixed|flexible. Read-only, 0 Oxygen credits.")
|
|
7136
7405
|
.option("--json", "Print a JSON envelope.")
|
|
7137
7406
|
.action(async (options) => {
|
|
7138
7407
|
await handleAsyncAction("billing allowance", options, () => requestOxygen("/api/cli/billing/allowance"));
|
|
@@ -7504,6 +7773,19 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
7504
7773
|
program
|
|
7505
7774
|
.command("admin")
|
|
7506
7775
|
.description("Staff-only commands.")
|
|
7776
|
+
.addCommand(new Command("kpis")
|
|
7777
|
+
.description("Show company acquisition, matured seven-day trial conversion, MRR/ARR, and churn. OXYGEN staff only; workspace admin role alone does not grant access.")
|
|
7778
|
+
.addOption(new Option("--range <range>", "Range for acquisition, seven-day trial cohorts, and churn; current MRR/ARR remain point-in-time.")
|
|
7779
|
+
.choices(["7d", "30d", "90d"])
|
|
7780
|
+
.default("30d"))
|
|
7781
|
+
.option("--json", "Print a JSON envelope.")
|
|
7782
|
+
.action(async (options) => {
|
|
7783
|
+
await handleAsyncAction("admin kpis", options, () => {
|
|
7784
|
+
const range = readOption(options.range);
|
|
7785
|
+
const suffix = range && range !== "30d" ? `?range=${encodeURIComponent(range)}` : "";
|
|
7786
|
+
return requestOxygen(`/api/cli/admin/kpis${suffix}`);
|
|
7787
|
+
});
|
|
7788
|
+
}))
|
|
7507
7789
|
.addCommand(new Command("costs")
|
|
7508
7790
|
.description("Show provider costs (COGS) per workspace. Staff only.")
|
|
7509
7791
|
.option("--top <n>", "Limit number of workspace columns. Defaults to all.")
|
|
@@ -9377,7 +9659,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
9377
9659
|
});
|
|
9378
9660
|
})));
|
|
9379
9661
|
program.addCommand(new Command("engagement")
|
|
9380
|
-
.description("
|
|
9662
|
+
.description("Capture LinkedIn intent from one known post (harvest needs its public URL or the composite social_id from `oxygen posts get`) or from your connected account's viewers, followers, and connections. For recurring competitor-profile monitoring that discovers future posts, start with `oxygen recipes list competitor --json`. Harvests run as a slow, durable drip under a conservative read budget.")
|
|
9381
9663
|
.addCommand(new Command("harvest")
|
|
9382
9664
|
.description("Start (or re-arm) a harvest of a post's engagers into a workspace table you can enroll into a sequence. Engagers drip into the table over many ticks; poll `engagement status` to watch it fill. No messages are sent. Cookieless harvests spend Oxygen credits per scraper page and require --max-credits.")
|
|
9383
9665
|
.requiredOption("--post <social_id_or_url>", "Composite post social_id from `oxygen posts get` (NOT the activity URN), or the public LinkedIn post URL for cookieless.")
|
|
@@ -9493,6 +9775,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
9493
9775
|
}))
|
|
9494
9776
|
.addCommand(new Command("watch")
|
|
9495
9777
|
.description("Declarative engagement watches (the signals wedge): stand up a watch on a post's engagers, 'who viewed my profile', or the sender's inbound network (new followers / new connections) that harvests people into a table and, under a standing approval, auto-enrolls them into a sequence. The watch materializes the harvest drip and enrolls newly-harvested people each cycle — the sequence still gates its own sends.")
|
|
9778
|
+
.addHelpText("after", "\nScope: this watches one known post or one connected-account signal. For daily discovery across one or more public profiles' recent posts, use `oxygen blueprints describe linkedin-profile-engager-monitor --json`.\n")
|
|
9496
9779
|
.addCommand(new Command("create")
|
|
9497
9780
|
.description("Arm an engagement watch. `--kind post` watches a post's reactors + commenters (needs --post social_id; a cookieless post also needs --post-url); `--kind profile_viewers` watches 'who viewed my profile' (source unipile); `--kind followers` / `--kind connections` watch the sender's inbound network — people NEW to the org's orbit stream into the watch table as they follow/connect (source unipile, no credits, reads metered against the account's daily ingest budget). With --auto-enroll it enrolls harvested people into --sequence, capped by --max-enrolls-per-day — the inbound-led-outbound loop. --max-credits-per-cycle is the standing per-cycle spend cap (required for --auto-enroll and cookieless). No messages are sent by the watch itself.")
|
|
9498
9781
|
.requiredOption("--kind <kind>", "post | profile_viewers | followers | connections.")
|
|
@@ -11697,7 +11980,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
11697
11980
|
});
|
|
11698
11981
|
})));
|
|
11699
11982
|
program.addCommand(new Command("mailboxes")
|
|
11700
|
-
.description("Native email sending pool: register/refresh Google/Microsoft mailboxes (including secure local Hypertide transfer), pause/disable inboxes, connect EmailGuard monitoring, and run managed TrulyInbox warmup with explicit plans and credit caps.")
|
|
11983
|
+
.description("Native email sending pool: register/refresh Google/Microsoft mailboxes (including secure local Hypertide transfer), pause/disable inboxes, connect EmailGuard monitoring, and run managed TrulyInbox warmup with explicit plans and credit caps. 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).")
|
|
11701
11984
|
.addCommand(new Command("list")
|
|
11702
11985
|
.description("List the org's sending mailboxes with provider, status, warmup state, and a pool overview.")
|
|
11703
11986
|
.option("--status <status>", "Filter by status: active, paused, or disabled.")
|
|
@@ -11726,13 +12009,20 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
11726
12009
|
await handleAsyncAction("mailboxes health", options, () => requestOxygen("/api/cli/mailboxes/health"));
|
|
11727
12010
|
}))
|
|
11728
12011
|
.addCommand(new Command("compatibility")
|
|
11729
|
-
.description("Read-only compatibility report for every selected mailbox: origin vendor, real infrastructure tier (including InboxKit Azure), mailbox auth, TrulyInbox warmup path, EmailGuard monitoring path, and the exact next action. JSON data.compatibility contains checked mailbox rows; data.provider_matrix always contains the complete current
|
|
12012
|
+
.description("Read-only compatibility report for every selected mailbox: origin vendor, real infrastructure tier (including InboxKit Azure), mailbox auth, TrulyInbox warmup path, EmailGuard monitoring path, and the exact next action. Use --catalog-only for the compact, workspace-independent import contract with no mailbox rows. JSON data.compatibility contains checked mailbox rows; data.provider_matrix always contains the complete current 18-pair product matrix even when mailboxes are filtered; data.import_methods and data.import_fields describe every supported ingestion path; data.non_transferable_auth names credentials that must be reconnected; data.summary rolls up states; data.web_url opens the pool. States distinguish credential_required, consent_required, and vendor_blocked. Never decrypts a credential or calls a downstream provider.")
|
|
11730
12013
|
.option("--mailboxes <list>", "Comma-separated mailbox ids or addresses. Omit for the whole pool.")
|
|
12014
|
+
.option("--catalog-only", "Return only the bounded import-method, field, auth-boundary, and 18-pair provider catalogs; do not read or return workspace mailbox rows.")
|
|
11731
12015
|
.option("--json", "Print a JSON envelope.")
|
|
11732
12016
|
.action(async (options) => {
|
|
11733
12017
|
await handleAsyncAction("mailboxes compatibility", options, () => {
|
|
11734
12018
|
const mailboxes = readCsvOption(options.mailboxes);
|
|
12019
|
+
if (options.catalogOnly === true && mailboxes.length > 0) {
|
|
12020
|
+
throw new OxygenError("invalid_scope", "--catalog-only cannot be combined with --mailboxes.", { exitCode: 1 });
|
|
12021
|
+
}
|
|
11735
12022
|
const params = new URLSearchParams();
|
|
12023
|
+
if (options.catalogOnly === true) {
|
|
12024
|
+
params.set("catalog_only", "true");
|
|
12025
|
+
}
|
|
11736
12026
|
if (mailboxes.length > 0) {
|
|
11737
12027
|
params.set("mailboxes", mailboxes.join(","));
|
|
11738
12028
|
}
|
|
@@ -11741,38 +12031,54 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
11741
12031
|
});
|
|
11742
12032
|
}))
|
|
11743
12033
|
.addCommand(new Command("import")
|
|
11744
|
-
.description("Register (or refresh) sending mailboxes in bulk.
|
|
12034
|
+
.description("Register (or refresh) sending mailboxes in bulk. Use --validate-only with a local file for a non-mutating, no-network preflight; without it, import is a 0-credit state mutation whose upsert is idempotent by mailbox address. CSV/JSON/JSONL/XLSX identity files from any vendor use --file plus optional --vendor. Compatible Google app-password exports use --from credentials --vendor <source>; --from hypertide remains a shortcut. Connected Zapmail inventories use --from zapmail. In credential files, app_password is Google-only; Microsoft password fields are discarded locally. OAuth grants, MFA/TOTP seeds, and delegation keys never transfer: Microsoft warmup requires Entra tenant-admin consent and EmailGuard remains vendor_blocked for Microsoft. Google app passwords are sent only in the request body, encrypted server-side, and never returned.")
|
|
11745
12035
|
.addHelpText("after", [
|
|
11746
12036
|
"",
|
|
11747
|
-
"Ordinary identity file contract:",
|
|
11748
|
-
|
|
12037
|
+
"Ordinary identity file contract (CSV / JSON / JSONL / XLSX):",
|
|
12038
|
+
" Canonical fields: email_address, provider, workspace_external_id?, infrastructure_platform?, tenant_id?. Common vendor aliases such as Email, From Email, ESP, Mailbox ID, and Entra Tenant ID are mapped locally. Credentials are rejected.",
|
|
11749
12039
|
"",
|
|
11750
|
-
"
|
|
11751
|
-
" Limits: 500 rows / 5 MB.",
|
|
12040
|
+
"All local file imports:",
|
|
12041
|
+
" Limits: 500 rows / 5 MB for identity, credential, and Hypertide files.",
|
|
12042
|
+
"",
|
|
12043
|
+
"Credential file contract:",
|
|
11752
12044
|
' JSON: {"mailboxes":[{"email_address":"ada@send-acme.com","provider":"google","app_password":"<Google mailbox app password>"}]}',
|
|
11753
|
-
"
|
|
12045
|
+
" Only Google app passwords enter the encrypted seven-day transfer vault. Microsoft rows remain identity-only. Generic SMTP passwords and OAuth/MFA/delegation secrets are rejected.",
|
|
12046
|
+
" Validation: add --validate-only to parse the complete real file and return safe aggregate counts without authentication, a network request, or a workspace write. Any parse, shape, provider, platform, tenant, secret-policy, or duplicate-conflict error rejects the entire file before the first mailbox write and names mailboxes[index]; validation-only never writes. A later import infrastructure failure may interrupt the upsert; re-run the same file because import is idempotent by address.",
|
|
11754
12047
|
" Docs: https://oxygen-agent.com/docs/providers/mailbox-compatibility",
|
|
11755
12048
|
" Skill: oxygen-email-infra (`oxygen skills install --skill oxygen-email-infra`).",
|
|
11756
12049
|
"",
|
|
11757
12050
|
].join("\n"))
|
|
11758
|
-
.option("--file <path>", "Local
|
|
11759
|
-
.option("--from <source>", "Import source: '
|
|
12051
|
+
.option("--file <path>", "Local CSV/JSON/JSONL/XLSX file. Common mailbox-vendor headers are normalized locally; only canonical allowlisted fields cross the network.")
|
|
12052
|
+
.option("--from <source>", "Import source: 'credentials' for a compatible Google app-password export, 'hypertide' as its provider shortcut, or 'zapmail' to pull a connected workspace.")
|
|
12053
|
+
.option("--vendor <slug>", "Non-secret source provenance (for example instantly, mailforge, or smartlead). Required with --from credentials; optional for identity files.")
|
|
11760
12054
|
.option("--connection <id>", "Zapmail connection id (--from zapmail). Defaults to the org's active Zapmail connection.")
|
|
11761
12055
|
.option("--provider <provider>", "Zapmail pool to pull (--from zapmail): google or microsoft. Zapmail's mailbox list is provider-scoped, so the Microsoft pool is only reachable with --provider microsoft; Microsoft mailboxes get their Entra tenant id stamped on import.")
|
|
12056
|
+
.option("--validate-only", "Parse, normalize, and policy-check a local file, then return non-secret aggregate counts without authentication, network access, provider calls, or workspace writes.")
|
|
11762
12057
|
.option("--json", "Print a JSON envelope.")
|
|
11763
12058
|
.action(async (options) => {
|
|
11764
12059
|
await handleAsyncAction("mailboxes import", options, async () => {
|
|
11765
12060
|
const from = readOption(options.from);
|
|
11766
12061
|
const filePath = readOption(options.file);
|
|
12062
|
+
const vendor = readOption(options.vendor);
|
|
11767
12063
|
const connection = readOption(options.connection);
|
|
11768
12064
|
const provider = readOption(options.provider);
|
|
11769
|
-
|
|
11770
|
-
|
|
12065
|
+
const validateOnly = options.validateOnly === true;
|
|
12066
|
+
if (from &&
|
|
12067
|
+
from !== "zapmail" &&
|
|
12068
|
+
from !== "hypertide" &&
|
|
12069
|
+
from !== "credentials") {
|
|
12070
|
+
throw new Error("--from must be credentials, hypertide, or zapmail.");
|
|
11771
12071
|
}
|
|
11772
12072
|
if (from === "zapmail") {
|
|
12073
|
+
if (validateOnly) {
|
|
12074
|
+
throw new Error("--validate-only applies to local --file imports and cannot be combined with --from zapmail.");
|
|
12075
|
+
}
|
|
11773
12076
|
if (filePath) {
|
|
11774
12077
|
throw new Error("--file cannot be combined with --from zapmail.");
|
|
11775
12078
|
}
|
|
12079
|
+
if (vendor) {
|
|
12080
|
+
throw new Error("--vendor cannot be combined with --from zapmail; the connected integration is authoritative.");
|
|
12081
|
+
}
|
|
11776
12082
|
return requestOxygen("/api/cli/mailboxes", {
|
|
11777
12083
|
method: "POST",
|
|
11778
12084
|
body: {
|
|
@@ -11784,13 +12090,36 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
11784
12090
|
}
|
|
11785
12091
|
if (provider)
|
|
11786
12092
|
throw new Error("--provider only applies with --from zapmail (inline files carry a per-mailbox provider).");
|
|
12093
|
+
if (connection) {
|
|
12094
|
+
throw new Error("--connection only applies with --from zapmail.");
|
|
12095
|
+
}
|
|
12096
|
+
const sourceProvider = normalizeMailboxImportVendor(vendor, from);
|
|
11787
12097
|
if (!filePath)
|
|
11788
|
-
throw new Error("Provide --file <path>, --from hypertide --file <path>, or --from zapmail.");
|
|
11789
|
-
const mailboxes = normalizeMailboxImportFile(await readMailboxImportFile(resolve(filePath)), from === "hypertide"
|
|
12098
|
+
throw new Error("Provide --file <path>, --from credentials --vendor <source> --file <path>, --from hypertide --file <path>, or --from zapmail.");
|
|
12099
|
+
const mailboxes = normalizeMailboxImportFile(await readMailboxImportFile(resolve(filePath)), from === "hypertide" || from === "credentials"
|
|
12100
|
+
? "credential"
|
|
12101
|
+
: "identity");
|
|
12102
|
+
if (validateOnly) {
|
|
12103
|
+
return summarizeMailboxImportValidation(mailboxes, {
|
|
12104
|
+
source: from === "hypertide"
|
|
12105
|
+
? "hypertide_file"
|
|
12106
|
+
: from === "credentials"
|
|
12107
|
+
? "credential_file"
|
|
12108
|
+
: "identity_file",
|
|
12109
|
+
sourceProvider,
|
|
12110
|
+
});
|
|
12111
|
+
}
|
|
11790
12112
|
return requestOxygen("/api/cli/mailboxes", {
|
|
11791
12113
|
method: "POST",
|
|
11792
12114
|
body: {
|
|
11793
|
-
...(from === "hypertide"
|
|
12115
|
+
...(from === "hypertide"
|
|
12116
|
+
? { source: "hypertide" }
|
|
12117
|
+
: from === "credentials"
|
|
12118
|
+
? { source: "credential_file" }
|
|
12119
|
+
: {}),
|
|
12120
|
+
...(sourceProvider
|
|
12121
|
+
? { source_provider: sourceProvider }
|
|
12122
|
+
: {}),
|
|
11794
12123
|
mailboxes,
|
|
11795
12124
|
},
|
|
11796
12125
|
});
|
|
@@ -11913,40 +12242,60 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
11913
12242
|
});
|
|
11914
12243
|
}))
|
|
11915
12244
|
.addCommand(new Command("connect-oauth")
|
|
11916
|
-
.description("Zapmail-provisioned pools
|
|
12245
|
+
.description("Zapmail- or InboxKit-provisioned pools — not Hypertide imports. Preview native OAuth provisioning by default; pass --approved after reviewing the mailbox/domain scope to start external vendor writes. A successful preview describes the requested scope and safety envelope; it can still contain zero eligible mailboxes or only already-authorized no-ops. For InboxKit, first confirm mailboxes oauth-health returns remedy connect_oauth_inboxkit, then use mailboxes compatibility --mailboxes <list> to verify origin and auth state. --vendor zapmail (default) hands the pool to Zapmail's Custom OAuth export; --vendor inboxkit requests Workspace-admin consent per mailbox, but only for domains where Oxygen's OAuth client id is already approved — an unapproved domain sends no mailbox consent, and a domain that has never connected a mailbox fires one canary until it lands (--no-canary to fan out immediately). Manual InboxKit live requests are capped at 10 potential mailbox consent writes; use --mailboxes to split wider proven-domain or no-canary scopes. Newly provisioned InboxKit mailboxes are retried by the durable worker. This authorization action uses 0 Oxygen credits; existing vendor or subscription billing is unchanged. Pass --status <id> to poll.")
|
|
11917
12246
|
.option("--provider <provider>", "Mailbox provider to provision: google or microsoft.")
|
|
11918
|
-
.option("--
|
|
12247
|
+
.option("--vendor <vendor>", "Which provisioning path to use: zapmail (default) or inboxkit.")
|
|
12248
|
+
.option("--mailboxes <list>", "Comma-separated mailbox addresses to provision. Omit for the whole pool; manual InboxKit live runs must resolve to at most 10 potential consent writes.")
|
|
12249
|
+
.option("--domains <list>", "Comma-separated sending domains to limit an inboxkit run to. Omit to cover every domain in the pool.")
|
|
12250
|
+
.option("--no-canary", "inboxkit only: fan out to every eligible mailbox on a domain that has never connected one, instead of firing a single canary first.")
|
|
11919
12251
|
.option("--connection <id>", "Zapmail connection id. Defaults to the org's active Zapmail connection.")
|
|
11920
|
-
.option("--status <
|
|
12252
|
+
.option("--status <id>", "Poll a previously started run (Zapmail export id or InboxKit run id) instead of starting a new one.")
|
|
12253
|
+
.option("--dry-run", "Preview the requested mailbox/domain scope and external writes without changing vendor or ledger state. This is the default for starts; a successful preview can still have zero eligible mailboxes, so check InboxKit candidates with oauth-health before approval.")
|
|
12254
|
+
.option("--approved", "Start live vendor OAuth provisioning after inspecting the dry-run preview.")
|
|
11921
12255
|
.option("--json", "Print a JSON envelope.")
|
|
11922
12256
|
.action(async (options) => {
|
|
11923
12257
|
await handleAsyncAction("mailboxes connect-oauth", options, () => {
|
|
12258
|
+
const vendor = readOption(options.vendor);
|
|
11924
12259
|
const exportId = readOption(options.status);
|
|
11925
12260
|
if (exportId) {
|
|
11926
12261
|
const params = new URLSearchParams({ export_id: exportId });
|
|
11927
12262
|
const pollConnection = readOption(options.connection);
|
|
11928
12263
|
if (pollConnection)
|
|
11929
12264
|
params.set("connection_id", pollConnection);
|
|
12265
|
+
// Omitted, the server reads the vendor off the id's shape (numeric
|
|
12266
|
+
// export id vs run uuid); this is the override for the rare id that
|
|
12267
|
+
// does not look like either.
|
|
12268
|
+
if (vendor)
|
|
12269
|
+
params.set("vendor", vendor);
|
|
11930
12270
|
return requestOxygen(`/api/cli/mailboxes/connect-oauth?${params.toString()}`);
|
|
11931
12271
|
}
|
|
11932
12272
|
const provider = readOption(options.provider);
|
|
11933
12273
|
if (!provider) {
|
|
11934
|
-
throw new Error("--provider <google|microsoft> is required (or pass --status <
|
|
12274
|
+
throw new Error("--provider <google|microsoft> is required (or pass --status <id> to poll a run).");
|
|
11935
12275
|
}
|
|
11936
12276
|
const mailboxes = readCsvOption(options.mailboxes);
|
|
12277
|
+
const domains = readCsvOption(options.domains);
|
|
11937
12278
|
const connection = readOption(options.connection);
|
|
11938
12279
|
return requestOxygen("/api/cli/mailboxes/connect-oauth", {
|
|
11939
12280
|
method: "POST",
|
|
11940
12281
|
body: {
|
|
11941
12282
|
provider,
|
|
12283
|
+
// Every key stays absent unless it was asked for, so a run
|
|
12284
|
+
// without the new flags is the request this command has always
|
|
12285
|
+
// sent — the server reads an absent vendor as zapmail.
|
|
12286
|
+
...(vendor ? { vendor } : {}),
|
|
11942
12287
|
...(mailboxes.length > 0 ? { mailboxes } : {}),
|
|
12288
|
+
...(domains.length > 0 ? { domains } : {}),
|
|
12289
|
+
...(options.canary === false ? { canary: false } : {}),
|
|
11943
12290
|
...(connection ? { connection_id: connection } : {}),
|
|
12291
|
+
...(options.dryRun ? { dry_run: true } : {}),
|
|
12292
|
+
...(options.approved ? { approved: true } : {}),
|
|
11944
12293
|
},
|
|
11945
12294
|
});
|
|
11946
12295
|
});
|
|
11947
12296
|
}))
|
|
11948
12297
|
.addCommand(new Command("oauth-health")
|
|
11949
|
-
.description("Show the delegation-stuck sending mailboxes
|
|
12298
|
+
.description("Show the delegation-stuck sending mailboxes across both transports (google and microsoft): every inbox still on auth_mode=delegation with no OAuth grant, each naming the remedy that applies (connect_oauth for Zapmail-linked rows, connect_oauth_inboxkit for InboxKit-provisioned ones, domain_delegation for the rest) and, for Zapmail rows, its Custom OAuth budget (attempts in the last 7 days, remaining re-export posts before Zapmail's 3-per-mailbox-per-7-day cap, last error, and next safe retry time). Read-only — 0 Oxygen credits.")
|
|
11950
12299
|
.option("--json", "Print a JSON envelope.")
|
|
11951
12300
|
.action(async (options) => {
|
|
11952
12301
|
await handleAsyncAction("mailboxes oauth-health", options, () => requestOxygen("/api/cli/mailboxes/oauth-health"));
|
|
@@ -12774,7 +13123,7 @@ Run completion:
|
|
|
12774
13123
|
.description("Deprecated alias for `oxygen blueprints apply`. Creates a disabled workflow (plus its tables, columns, and prompts) from a blueprint.")
|
|
12775
13124
|
.argument("<template_id>", "Blueprint slug (formerly workflow template id).")
|
|
12776
13125
|
.requiredOption("--input-json <json>", "Blueprint input as a JSON object.")
|
|
12777
|
-
.option("--workflow-id <workflow_id>", "
|
|
13126
|
+
.option("--workflow-id <workflow_id>", "Set the new workflow's manifest id/slug; this does not update or reuse an existing workflow.")
|
|
12778
13127
|
.option("--workflow-name <workflow_name>", "Override the resulting workflow name.")
|
|
12779
13128
|
.option("--mode <mode>", "Deprecated and ignored: the workflow is created disabled.")
|
|
12780
13129
|
.option("--max-credits <credits>", "Credit ceiling; folded into inputs.max_credits.")
|
|
@@ -13400,7 +13749,7 @@ Run completion:
|
|
|
13400
13749
|
await handleAsyncAction("skills doctor", options, () => doctorAgentSkills(options));
|
|
13401
13750
|
}))
|
|
13402
13751
|
.addCommand(new Command("install")
|
|
13403
|
-
.description("Install Oxygen agent skills into local agent skill directories.")
|
|
13752
|
+
.description("Install Oxygen agent skills into local agent skill directories only; uses 0 credits and does not change the Oxygen workspace or call a provider.")
|
|
13404
13753
|
.option("--api-url <url>", "Oxygen app URL. Defaults to OXYGEN_API_URL or https://oxygen-agent.com.")
|
|
13405
13754
|
.option("--agents <agents...>", "Space or comma separated agents. Defaults to codex, claude-code, and cursor.")
|
|
13406
13755
|
.option("--skill <skill>", "Skill name or '*'. Defaults to '*'.")
|
|
@@ -66,8 +66,9 @@ export function parseRowsText(text, format) {
|
|
|
66
66
|
if (format === "jsonl") {
|
|
67
67
|
return normalizeRowObjects(text
|
|
68
68
|
.split(/\r?\n/)
|
|
69
|
-
.
|
|
70
|
-
.
|
|
69
|
+
.map((line, index) => ({ line, lineNumber: index + 1 }))
|
|
70
|
+
.filter(({ line }) => line.trim())
|
|
71
|
+
.map(({ line, lineNumber }) => parseJsonImportLine(line, lineNumber)));
|
|
71
72
|
}
|
|
72
73
|
return parseCsvRows(text);
|
|
73
74
|
}
|
|
@@ -162,12 +163,26 @@ function normalizeXlsxImportCell(value) {
|
|
|
162
163
|
return String(value);
|
|
163
164
|
}
|
|
164
165
|
function parseJsonArray(text) {
|
|
165
|
-
|
|
166
|
+
let parsed;
|
|
167
|
+
try {
|
|
168
|
+
parsed = JSON.parse(text);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
throw new OxygenError("invalid_json", "Import file must contain valid JSON. Input contents are omitted from this error.", { exitCode: 1 });
|
|
172
|
+
}
|
|
166
173
|
if (!Array.isArray(parsed)) {
|
|
167
174
|
throw new OxygenError("invalid_json", "Expected a JSON array.", { exitCode: 1 });
|
|
168
175
|
}
|
|
169
176
|
return parsed;
|
|
170
177
|
}
|
|
178
|
+
function parseJsonImportLine(line, lineNumber) {
|
|
179
|
+
try {
|
|
180
|
+
return JSON.parse(line);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
throw new OxygenError("invalid_jsonl", `Import file contains invalid JSON on line ${lineNumber}. Input contents are omitted from this error.`, { exitCode: 1 });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
171
186
|
function normalizeRowObjects(rows) {
|
|
172
187
|
return rows.map((row) => normalizeRowObject(row));
|
|
173
188
|
}
|
|
@@ -182,6 +197,7 @@ function parseCsvRows(text) {
|
|
|
182
197
|
const [header, ...rows] = records;
|
|
183
198
|
if (!header || header.length === 0)
|
|
184
199
|
return [];
|
|
200
|
+
assertUniqueCsvHeaders(header);
|
|
185
201
|
return rows
|
|
186
202
|
.filter((row) => row.some((cell) => cell.trim()))
|
|
187
203
|
.map((row) => Object.fromEntries(header.map((key, index) => [key, normalizeCsvImportCell(row[index])])));
|
|
@@ -209,6 +225,8 @@ function* iterateCsvRowBatches(text, batchSize) {
|
|
|
209
225
|
if (ready)
|
|
210
226
|
yield ready;
|
|
211
227
|
}
|
|
228
|
+
if (state.inQuotes)
|
|
229
|
+
throw unterminatedCsvError();
|
|
212
230
|
if (state.field || state.record.length > 0) {
|
|
213
231
|
const ready = appendCsvRecordToBatch(state, finishCsvRecord(state), batchSize);
|
|
214
232
|
if (ready)
|
|
@@ -249,6 +267,8 @@ async function* iterateCsvRowStreamBatches(chunks, batchSize) {
|
|
|
249
267
|
if (ready.batch)
|
|
250
268
|
yield ready.batch;
|
|
251
269
|
}
|
|
270
|
+
if (state.inQuotes)
|
|
271
|
+
throw unterminatedCsvError();
|
|
252
272
|
if (state.field || state.record.length > 0) {
|
|
253
273
|
const ready = appendCsvRecordToBatch(state, finishCsvRecord(state), batchSize);
|
|
254
274
|
if (ready)
|
|
@@ -313,8 +333,10 @@ function finishCsvRecord(state) {
|
|
|
313
333
|
}
|
|
314
334
|
function appendCsvRecordToBatch(state, record, batchSize) {
|
|
315
335
|
const row = csvRecordToRow(record, state.header);
|
|
316
|
-
if (state.header === null)
|
|
336
|
+
if (state.header === null) {
|
|
337
|
+
assertUniqueCsvHeaders(record);
|
|
317
338
|
state.header = record;
|
|
339
|
+
}
|
|
318
340
|
if (row)
|
|
319
341
|
state.batch.push(row);
|
|
320
342
|
if (state.batch.length < batchSize)
|
|
@@ -333,22 +355,27 @@ function csvRecordToRow(record, header) {
|
|
|
333
355
|
function* iterateJsonlRowBatches(text, batchSize) {
|
|
334
356
|
let batch = [];
|
|
335
357
|
let lineStart = 0;
|
|
358
|
+
let lineNumber = 1;
|
|
336
359
|
for (let index = 0; index < text.length; index += 1) {
|
|
337
360
|
if (text.charAt(index) !== "\n")
|
|
338
361
|
continue;
|
|
339
362
|
const line = text.slice(lineStart, index).replace(/\r$/, "").trim();
|
|
340
363
|
lineStart = index + 1;
|
|
364
|
+
if (line) {
|
|
365
|
+
batch.push(normalizeRowObject(parseJsonImportLine(line, lineNumber)));
|
|
366
|
+
}
|
|
367
|
+
lineNumber += 1;
|
|
341
368
|
if (!line)
|
|
342
369
|
continue;
|
|
343
|
-
batch.push(normalizeRowObject(JSON.parse(line)));
|
|
344
370
|
if (batch.length >= batchSize) {
|
|
345
371
|
yield batch;
|
|
346
372
|
batch = [];
|
|
347
373
|
}
|
|
348
374
|
}
|
|
349
375
|
const trailingLine = text.slice(lineStart).replace(/\r$/, "").trim();
|
|
350
|
-
if (trailingLine)
|
|
351
|
-
batch.push(normalizeRowObject(
|
|
376
|
+
if (trailingLine) {
|
|
377
|
+
batch.push(normalizeRowObject(parseJsonImportLine(trailingLine, lineNumber)));
|
|
378
|
+
}
|
|
352
379
|
if (batch.length > 0)
|
|
353
380
|
yield batch;
|
|
354
381
|
}
|
|
@@ -356,14 +383,17 @@ async function* iterateJsonlRowStreamBatches(chunks, batchSize) {
|
|
|
356
383
|
const decoder = new TextDecoder();
|
|
357
384
|
let pending = "";
|
|
358
385
|
let batch = [];
|
|
386
|
+
let lineNumber = 1;
|
|
359
387
|
for await (const chunk of chunks) {
|
|
360
388
|
pending += decoder.decode(toUint8Array(chunk), { stream: true });
|
|
361
389
|
let newline = pending.indexOf("\n");
|
|
362
390
|
while (newline >= 0) {
|
|
363
391
|
const line = pending.slice(0, newline).replace(/\r$/, "").trim();
|
|
364
392
|
pending = pending.slice(newline + 1);
|
|
365
|
-
if (line)
|
|
366
|
-
batch.push(normalizeRowObject(
|
|
393
|
+
if (line) {
|
|
394
|
+
batch.push(normalizeRowObject(parseJsonImportLine(line, lineNumber)));
|
|
395
|
+
}
|
|
396
|
+
lineNumber += 1;
|
|
367
397
|
if (batch.length >= batchSize) {
|
|
368
398
|
yield batch;
|
|
369
399
|
batch = [];
|
|
@@ -372,8 +402,9 @@ async function* iterateJsonlRowStreamBatches(chunks, batchSize) {
|
|
|
372
402
|
}
|
|
373
403
|
}
|
|
374
404
|
const tail = (pending + decoder.decode()).replace(/\r$/, "").trim();
|
|
375
|
-
if (tail)
|
|
376
|
-
batch.push(normalizeRowObject(
|
|
405
|
+
if (tail) {
|
|
406
|
+
batch.push(normalizeRowObject(parseJsonImportLine(tail, lineNumber)));
|
|
407
|
+
}
|
|
377
408
|
if (batch.length > 0)
|
|
378
409
|
yield batch;
|
|
379
410
|
}
|
|
@@ -459,12 +490,28 @@ function parseCsvRecords(text) {
|
|
|
459
490
|
field += char;
|
|
460
491
|
}
|
|
461
492
|
}
|
|
493
|
+
if (inQuotes) {
|
|
494
|
+
throw unterminatedCsvError();
|
|
495
|
+
}
|
|
462
496
|
if (field || record.length > 0) {
|
|
463
497
|
record.push(field);
|
|
464
498
|
records.push(record);
|
|
465
499
|
}
|
|
466
500
|
return records;
|
|
467
501
|
}
|
|
502
|
+
function unterminatedCsvError() {
|
|
503
|
+
return new OxygenError("invalid_csv", "CSV contains an unterminated quoted field.", { exitCode: 1 });
|
|
504
|
+
}
|
|
505
|
+
function assertUniqueCsvHeaders(header) {
|
|
506
|
+
const seen = new Set();
|
|
507
|
+
for (const value of header) {
|
|
508
|
+
const normalized = value.normalize("NFKC").trim().toLowerCase();
|
|
509
|
+
if (!normalized || seen.has(normalized)) {
|
|
510
|
+
throw new OxygenError("invalid_csv", "CSV headers must be non-empty and unique (case-insensitive).", { exitCode: 1 });
|
|
511
|
+
}
|
|
512
|
+
seen.add(normalized);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
468
515
|
function makeUniqueImportKey(label, taken) {
|
|
469
516
|
return makeUniqueIdentifier(label, taken, {
|
|
470
517
|
exhaustedMessage: "Unable to create a unique column key.",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const OXYGEN_VERSION = "1.
|
|
1
|
+
export const OXYGEN_VERSION = "1.591.1";
|
|
2
2
|
// The GLOBAL CLI compatibility floor: the oldest CLI allowed to call any
|
|
3
3
|
// operational route. Raising it hard-rejects every older CLI from the entire
|
|
4
4
|
// product, so it obeys one law, enforced by scripts/ci/cli-min-version-gate.mjs:
|