@shadowob/cli 1.1.4 → 1.1.5
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/dist/index.js
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
import {
|
|
3
3
|
configManager,
|
|
4
4
|
getClient,
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
getClientWithToken,
|
|
6
|
+
getSocket,
|
|
7
|
+
parsePositiveInt
|
|
8
|
+
} from "./chunk-E364BDQO.js";
|
|
7
9
|
|
|
8
10
|
// src/index.ts
|
|
9
|
-
import { Command as
|
|
11
|
+
import { Command as Command28 } from "commander";
|
|
10
12
|
|
|
11
13
|
// src/commands/agents.ts
|
|
12
14
|
import { Command } from "commander";
|
|
@@ -755,10 +757,217 @@ function spawnCloudCli(args) {
|
|
|
755
757
|
}
|
|
756
758
|
}
|
|
757
759
|
|
|
758
|
-
// src/commands/
|
|
760
|
+
// src/commands/commerce.ts
|
|
761
|
+
import { randomUUID } from "crypto";
|
|
759
762
|
import { Command as Command7 } from "commander";
|
|
763
|
+
function buildIdempotencyKey(value, prefix) {
|
|
764
|
+
return value?.trim() || `${prefix}-${randomUUID()}`;
|
|
765
|
+
}
|
|
766
|
+
function parsePositiveInteger(value, name) {
|
|
767
|
+
if (value == null) return void 0;
|
|
768
|
+
const parsed = Number.parseInt(value, 10);
|
|
769
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
770
|
+
throw new Error(`${name} must be a positive integer`);
|
|
771
|
+
}
|
|
772
|
+
return parsed;
|
|
773
|
+
}
|
|
774
|
+
function parseMetadata(value) {
|
|
775
|
+
if (!value) return void 0;
|
|
776
|
+
const parsed = JSON.parse(value);
|
|
777
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
778
|
+
throw new Error("--metadata must be a JSON object");
|
|
779
|
+
}
|
|
780
|
+
return parsed;
|
|
781
|
+
}
|
|
782
|
+
async function runCommand(options, task) {
|
|
783
|
+
try {
|
|
784
|
+
const result = await task();
|
|
785
|
+
output(result, { json: options.json });
|
|
786
|
+
} catch (error) {
|
|
787
|
+
outputError(error instanceof Error ? error.message : String(error), { json: options.json });
|
|
788
|
+
process.exit(1);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
function createCommerceCommand() {
|
|
792
|
+
const commerce = new Command7("commerce").description("Commerce, purchases, delivery, and assets");
|
|
793
|
+
const products = commerce.command("products").description("Buyer-facing product commands");
|
|
794
|
+
products.command("context").description("Get buyer-facing product context").argument("<product-id>", "Product ID").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
795
|
+
(productId, options) => runCommand(options, async () => {
|
|
796
|
+
const client = await getClient(options.profile);
|
|
797
|
+
return client.getCommerceProductContext(productId);
|
|
798
|
+
})
|
|
799
|
+
);
|
|
800
|
+
const offers = commerce.command("offers").description("Commerce offer commands");
|
|
801
|
+
offers.command("preview").description("Preview checkout for an offer").argument("<offer-id>", "Offer ID").option("--sku-id <id>", "SKU ID").option("--viewer-user-id <id>", "Viewer user ID").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
802
|
+
(offerId, options) => runCommand(options, async () => {
|
|
803
|
+
const client = await getClient(options.profile);
|
|
804
|
+
return client.getCommerceOfferCheckoutPreview(offerId, {
|
|
805
|
+
skuId: options.skuId,
|
|
806
|
+
viewerUserId: options.viewerUserId
|
|
807
|
+
});
|
|
808
|
+
})
|
|
809
|
+
);
|
|
810
|
+
offers.command("purchase").description("Purchase an offer").argument("<offer-id>", "Offer ID").option("--sku-id <id>", "SKU ID").option("--destination-kind <kind>", "Delivery destination kind, currently channel").option("--destination-id <id>", "Delivery destination ID").option("--idempotency-key <key>", "Idempotency key, generated if omitted").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
811
|
+
(offerId, options) => runCommand(options, async () => {
|
|
812
|
+
const client = await getClient(options.profile);
|
|
813
|
+
return client.purchaseCommerceOffer(offerId, {
|
|
814
|
+
skuId: options.skuId,
|
|
815
|
+
idempotencyKey: buildIdempotencyKey(options.idempotencyKey, "cli-offer-purchase"),
|
|
816
|
+
destinationKind: options.destinationId ? options.destinationKind ?? "channel" : void 0,
|
|
817
|
+
destinationId: options.destinationId
|
|
818
|
+
});
|
|
819
|
+
})
|
|
820
|
+
);
|
|
821
|
+
const cards = commerce.command("cards").description("Chat commerce card commands");
|
|
822
|
+
cards.command("list").description("List commerce product cards available for a channel").requiredOption("--channel-id <id>", "Channel ID").option("--keyword <keyword>", "Search keyword").option("--limit <n>", "Maximum cards").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
823
|
+
(options) => runCommand(options, async () => {
|
|
824
|
+
const client = await getClient(options.profile);
|
|
825
|
+
return client.listCommerceProductCards({
|
|
826
|
+
target: "channel",
|
|
827
|
+
channelId: options.channelId,
|
|
828
|
+
keyword: options.keyword,
|
|
829
|
+
limit: parsePositiveInteger(options.limit, "--limit")
|
|
830
|
+
});
|
|
831
|
+
})
|
|
832
|
+
);
|
|
833
|
+
cards.command("purchase").description("Purchase a chat commerce card").argument("<message-id>", "Message ID").argument("<card-id>", "Commerce card ID").option("--sku-id <id>", "SKU ID").option("--idempotency-key <key>", "Idempotency key, generated if omitted").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
834
|
+
(messageId, cardId, options) => runCommand(options, async () => {
|
|
835
|
+
const client = await getClient(options.profile);
|
|
836
|
+
return client.purchaseMessageCommerceCard(messageId, cardId, {
|
|
837
|
+
skuId: options.skuId,
|
|
838
|
+
idempotencyKey: buildIdempotencyKey(options.idempotencyKey, "cli-card-purchase")
|
|
839
|
+
});
|
|
840
|
+
})
|
|
841
|
+
);
|
|
842
|
+
const entitlements = commerce.command("entitlements").description("Purchase entitlement commands");
|
|
843
|
+
entitlements.command("list").description("List my purchase entitlements").option("--server-id <id>", "Limit to a server shop entitlement list").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
844
|
+
(options) => runCommand(options, async () => {
|
|
845
|
+
const client = await getClient(options.profile);
|
|
846
|
+
return options.serverId ? client.getEntitlements(options.serverId) : client.getAllEntitlements();
|
|
847
|
+
})
|
|
848
|
+
);
|
|
849
|
+
entitlements.command("get").description("Get purchase delivery detail").argument("<entitlement-id>", "Entitlement ID").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
850
|
+
(entitlementId, options) => runCommand(options, async () => {
|
|
851
|
+
const client = await getClient(options.profile);
|
|
852
|
+
return client.getEntitlement(entitlementId);
|
|
853
|
+
})
|
|
854
|
+
);
|
|
855
|
+
entitlements.command("verify").description("Verify entitlement provisioning/access").argument("<entitlement-id>", "Entitlement ID").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
856
|
+
(entitlementId, options) => runCommand(options, async () => {
|
|
857
|
+
const client = await getClient(options.profile);
|
|
858
|
+
return client.verifyEntitlement(entitlementId);
|
|
859
|
+
})
|
|
860
|
+
);
|
|
861
|
+
entitlements.command("cancel").description("Cancel an entitlement and request any available refund").argument("<entitlement-id>", "Entitlement ID").option("--reason <reason>", "Cancellation reason").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
862
|
+
(entitlementId, options) => runCommand(options, async () => {
|
|
863
|
+
const client = await getClient(options.profile);
|
|
864
|
+
return client.cancelEntitlement(entitlementId, options.reason);
|
|
865
|
+
})
|
|
866
|
+
);
|
|
867
|
+
entitlements.command("cancel-renewal").description("Stop subscription renewal while keeping current access").argument("<entitlement-id>", "Entitlement ID").option("--reason <reason>", "Cancellation reason").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
868
|
+
(entitlementId, options) => runCommand(options, async () => {
|
|
869
|
+
const client = await getClient(options.profile);
|
|
870
|
+
return client.cancelEntitlementRenewal(entitlementId, options.reason);
|
|
871
|
+
})
|
|
872
|
+
);
|
|
873
|
+
const assets = commerce.command("assets").description("Community asset commands");
|
|
874
|
+
assets.command("list").description("List my community assets").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
875
|
+
(options) => runCommand(options, async () => {
|
|
876
|
+
const client = await getClient(options.profile);
|
|
877
|
+
return client.listCommunityAssets();
|
|
878
|
+
})
|
|
879
|
+
);
|
|
880
|
+
assets.command("get").description("Get a community asset grant").argument("<grant-id>", "Asset grant ID").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
881
|
+
(grantId, options) => runCommand(options, async () => {
|
|
882
|
+
const client = await getClient(options.profile);
|
|
883
|
+
return client.getCommunityAsset(grantId);
|
|
884
|
+
})
|
|
885
|
+
);
|
|
886
|
+
for (const action of ["consume", "lock", "unlock"]) {
|
|
887
|
+
assets.command(action).description(`${action} a community asset grant`).argument("<grant-id>", "Asset grant ID").option("--idempotency-key <key>", "Idempotency key, generated if omitted").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
888
|
+
(grantId, options) => runCommand(options, async () => {
|
|
889
|
+
const client = await getClient(options.profile);
|
|
890
|
+
const data = {
|
|
891
|
+
idempotencyKey: buildIdempotencyKey(options.idempotencyKey, `cli-asset-${action}`)
|
|
892
|
+
};
|
|
893
|
+
if (action === "consume") return client.consumeCommunityAsset(grantId, data);
|
|
894
|
+
if (action === "lock") return client.lockCommunityAsset(grantId, data);
|
|
895
|
+
return client.unlockCommunityAsset(grantId, data);
|
|
896
|
+
})
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
assets.command("revoke").description("Revoke a community asset grant").argument("<grant-id>", "Asset grant ID").option("--reason <reason>", "Revocation reason").option("--idempotency-key <key>", "Idempotency key, generated if omitted").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
900
|
+
(grantId, options) => runCommand(options, async () => {
|
|
901
|
+
const client = await getClient(options.profile);
|
|
902
|
+
return client.revokeCommunityAsset(grantId, {
|
|
903
|
+
reason: options.reason,
|
|
904
|
+
idempotencyKey: buildIdempotencyKey(options.idempotencyKey, "cli-asset-revoke")
|
|
905
|
+
});
|
|
906
|
+
})
|
|
907
|
+
);
|
|
908
|
+
const paidFiles = commerce.command("paid-files").description("Protected paid file commands");
|
|
909
|
+
paidFiles.command("open").description("Open a paid file with entitlement authorization").argument("<file-id>", "Paid file/workspace file ID").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
910
|
+
(fileId, options) => runCommand(options, async () => {
|
|
911
|
+
const client = await getClient(options.profile);
|
|
912
|
+
return client.openPaidFile(fileId);
|
|
913
|
+
})
|
|
914
|
+
);
|
|
915
|
+
const settlements = commerce.command("settlements").description("Settlement commands");
|
|
916
|
+
settlements.command("list").description("List settlement lines").option("--limit <n>", "Maximum settlement lines").option("--offset <n>", "Offset").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
917
|
+
(options) => runCommand(options, async () => {
|
|
918
|
+
const client = await getClient(options.profile);
|
|
919
|
+
return client.listSettlements({
|
|
920
|
+
limit: parsePositiveInteger(options.limit, "--limit"),
|
|
921
|
+
offset: options.offset ? Number.parseInt(options.offset, 10) : void 0
|
|
922
|
+
});
|
|
923
|
+
})
|
|
924
|
+
);
|
|
925
|
+
settlements.command("settle").description("Settle currently available settlement lines").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
926
|
+
(options) => runCommand(options, async () => {
|
|
927
|
+
const client = await getClient(options.profile);
|
|
928
|
+
return client.settleAvailableSettlements();
|
|
929
|
+
})
|
|
930
|
+
);
|
|
931
|
+
const tips = commerce.command("tips").description("Tip commands");
|
|
932
|
+
tips.command("send").description("Send a tip").requiredOption("--recipient-user-id <id>", "Recipient user ID").requiredOption("--amount <amount>", "Amount").option("--message <message>", "Message").option("--context <json>", "Context JSON object").option("--idempotency-key <key>", "Idempotency key, generated if omitted").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
933
|
+
(options) => runCommand(options, async () => {
|
|
934
|
+
const client = await getClient(options.profile);
|
|
935
|
+
const amount = parsePositiveInteger(options.amount, "--amount");
|
|
936
|
+
return client.sendTip({
|
|
937
|
+
recipientUserId: options.recipientUserId,
|
|
938
|
+
amount: amount ?? 0,
|
|
939
|
+
message: options.message,
|
|
940
|
+
context: parseMetadata(options.context),
|
|
941
|
+
idempotencyKey: buildIdempotencyKey(options.idempotencyKey, "cli-tip")
|
|
942
|
+
});
|
|
943
|
+
})
|
|
944
|
+
);
|
|
945
|
+
const gifts = commerce.command("gifts").description("Gift commands");
|
|
946
|
+
gifts.command("list").description("List gifts").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
947
|
+
(options) => runCommand(options, async () => {
|
|
948
|
+
const client = await getClient(options.profile);
|
|
949
|
+
return client.listGifts();
|
|
950
|
+
})
|
|
951
|
+
);
|
|
952
|
+
gifts.command("send").description("Send a gift").requiredOption("--recipient-user-id <id>", "Recipient user ID").option("--assets <json>", "Assets JSON array").option("--currencies <json>", "Currencies JSON array").option("--message <message>", "Message").option("--idempotency-key <key>", "Idempotency key, generated if omitted").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
953
|
+
(options) => runCommand(options, async () => {
|
|
954
|
+
const client = await getClient(options.profile);
|
|
955
|
+
return client.sendGift({
|
|
956
|
+
recipientUserId: options.recipientUserId,
|
|
957
|
+
assets: options.assets ? JSON.parse(options.assets) : void 0,
|
|
958
|
+
currencies: options.currencies ? JSON.parse(options.currencies) : void 0,
|
|
959
|
+
message: options.message,
|
|
960
|
+
idempotencyKey: buildIdempotencyKey(options.idempotencyKey, "cli-gift")
|
|
961
|
+
});
|
|
962
|
+
})
|
|
963
|
+
);
|
|
964
|
+
return commerce;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// src/commands/config.ts
|
|
968
|
+
import { Command as Command8 } from "commander";
|
|
760
969
|
function createConfigCommand() {
|
|
761
|
-
const config = new
|
|
970
|
+
const config = new Command8("config").description("Configuration management commands");
|
|
762
971
|
config.command("path").description("Show configuration file path").action(() => {
|
|
763
972
|
console.log(configManager.getConfigPath());
|
|
764
973
|
});
|
|
@@ -829,9 +1038,9 @@ function createConfigCommand() {
|
|
|
829
1038
|
}
|
|
830
1039
|
|
|
831
1040
|
// src/commands/discover.ts
|
|
832
|
-
import { Command as
|
|
1041
|
+
import { Command as Command9 } from "commander";
|
|
833
1042
|
function createDiscoverCommand() {
|
|
834
|
-
const discover = new
|
|
1043
|
+
const discover = new Command9("discover").description("Discover popular servers and channels");
|
|
835
1044
|
discover.command("feed").description("Get the discovery feed").option("--type <type>", "Filter by type (all, servers, channels, rentals)", "all").option("--limit <n>", "Number of results", "20").option("--offset <n>", "Offset for pagination", "0").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(async (options) => {
|
|
836
1045
|
try {
|
|
837
1046
|
const client = await getClient(options.profile);
|
|
@@ -868,9 +1077,9 @@ function createDiscoverCommand() {
|
|
|
868
1077
|
}
|
|
869
1078
|
|
|
870
1079
|
// src/commands/dms.ts
|
|
871
|
-
import { Command as
|
|
1080
|
+
import { Command as Command10 } from "commander";
|
|
872
1081
|
function createDirectMessagesCommand() {
|
|
873
|
-
const dms = new
|
|
1082
|
+
const dms = new Command10("dms").description("Direct message commands");
|
|
874
1083
|
dms.command("list").description("List direct channels").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(async (options) => {
|
|
875
1084
|
try {
|
|
876
1085
|
const client = await getClient(options.profile);
|
|
@@ -948,9 +1157,9 @@ function createDirectMessagesCommand() {
|
|
|
948
1157
|
}
|
|
949
1158
|
|
|
950
1159
|
// src/commands/friends.ts
|
|
951
|
-
import { Command as
|
|
1160
|
+
import { Command as Command11 } from "commander";
|
|
952
1161
|
function createFriendsCommand() {
|
|
953
|
-
const friends = new
|
|
1162
|
+
const friends = new Command11("friends").description("Friendship management commands");
|
|
954
1163
|
friends.command("list").description("List friends").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(async (options) => {
|
|
955
1164
|
try {
|
|
956
1165
|
const client = await getClient(options.profile);
|
|
@@ -1034,9 +1243,9 @@ function createFriendsCommand() {
|
|
|
1034
1243
|
}
|
|
1035
1244
|
|
|
1036
1245
|
// src/commands/invites.ts
|
|
1037
|
-
import { Command as
|
|
1246
|
+
import { Command as Command12 } from "commander";
|
|
1038
1247
|
function createInvitesCommand() {
|
|
1039
|
-
const invites = new
|
|
1248
|
+
const invites = new Command12("invites").description("Invite code management commands");
|
|
1040
1249
|
invites.command("list").description("List your invite codes").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(async (options) => {
|
|
1041
1250
|
try {
|
|
1042
1251
|
const client = await getClient(options.profile);
|
|
@@ -1087,15 +1296,15 @@ function createInvitesCommand() {
|
|
|
1087
1296
|
}
|
|
1088
1297
|
|
|
1089
1298
|
// src/commands/listen.ts
|
|
1090
|
-
import { Command as
|
|
1299
|
+
import { Command as Command13 } from "commander";
|
|
1091
1300
|
function createListenCommand() {
|
|
1092
|
-
const listen = new
|
|
1301
|
+
const listen = new Command13("listen").description("Listen to real-time events");
|
|
1093
1302
|
listen.command("channel").description("Listen to events in a channel").argument("<channel-id>", "Channel ID").option("--mode <mode>", "Listen mode: stream or poll", "stream").option("--timeout <seconds>", "Timeout in seconds (stream mode)", "60").option("--count <n>", "Stop after N events (stream mode)").option("--since <duration>", "Poll events since duration (e.g., 5m, 1h)", "5m").option("--last <n>", "Poll last N messages", "50").option("--event-type <type>", "Filter by event type (comma-separated)").option("--profile <name>", "Profile to use").option("--json", "Output as JSON (one per line)").action(
|
|
1094
1303
|
async (channelId, options) => {
|
|
1095
1304
|
try {
|
|
1096
1305
|
const eventTypes = options.eventType?.split(",").map((t) => t.trim());
|
|
1097
1306
|
if (options.mode === "poll") {
|
|
1098
|
-
const { getClient: getClient2 } = await import("./client-
|
|
1307
|
+
const { getClient: getClient2 } = await import("./client-ZIUDIQPZ.js");
|
|
1099
1308
|
const client = await getClient2(options.profile);
|
|
1100
1309
|
const limit = parseInt(options.last ?? "50", 10);
|
|
1101
1310
|
const result = await client.getMessages(channelId, limit);
|
|
@@ -1209,9 +1418,9 @@ function createListenCommand() {
|
|
|
1209
1418
|
}
|
|
1210
1419
|
|
|
1211
1420
|
// src/commands/marketplace.ts
|
|
1212
|
-
import { Command as
|
|
1421
|
+
import { Command as Command14 } from "commander";
|
|
1213
1422
|
function createMarketplaceCommand() {
|
|
1214
|
-
const marketplace = new
|
|
1423
|
+
const marketplace = new Command14("marketplace").description("Marketplace commands");
|
|
1215
1424
|
const listings = marketplace.command("listings").description("Listing commands");
|
|
1216
1425
|
listings.command("list").description("Browse marketplace listings").option("--search <text>", "Search query").option("--tags <tags>", "Comma-separated tags").option("--min-price <n>", "Minimum price per hour").option("--max-price <n>", "Maximum price per hour").option("--limit <n>", "Number of results", "20").option("--offset <n>", "Pagination offset", "0").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
1217
1426
|
async (options) => {
|
|
@@ -1356,9 +1565,9 @@ function createMarketplaceCommand() {
|
|
|
1356
1565
|
|
|
1357
1566
|
// src/commands/media.ts
|
|
1358
1567
|
import { readFileSync } from "fs";
|
|
1359
|
-
import { Command as
|
|
1568
|
+
import { Command as Command15 } from "commander";
|
|
1360
1569
|
function createMediaCommand() {
|
|
1361
|
-
const media = new
|
|
1570
|
+
const media = new Command15("media").description("Media management commands");
|
|
1362
1571
|
media.command("upload").description("Upload a file").requiredOption("--file <path>", "File path to upload").option("--message-id <id>", "Associate with message").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
1363
1572
|
async (options) => {
|
|
1364
1573
|
try {
|
|
@@ -1424,9 +1633,9 @@ function createMediaCommand() {
|
|
|
1424
1633
|
}
|
|
1425
1634
|
|
|
1426
1635
|
// src/commands/notifications.ts
|
|
1427
|
-
import { Command as
|
|
1636
|
+
import { Command as Command16 } from "commander";
|
|
1428
1637
|
function createNotificationsCommand() {
|
|
1429
|
-
const notifications = new
|
|
1638
|
+
const notifications = new Command16("notifications").description("Notification commands");
|
|
1430
1639
|
notifications.command("list").description("List notifications").option("--unread-only", "Show only unread notifications").option("--limit <n>", "Number of notifications", "20").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
1431
1640
|
async (options) => {
|
|
1432
1641
|
try {
|
|
@@ -1505,9 +1714,24 @@ function splitIds(value) {
|
|
|
1505
1714
|
}
|
|
1506
1715
|
|
|
1507
1716
|
// src/commands/oauth.ts
|
|
1508
|
-
import { Command as
|
|
1717
|
+
import { Command as Command17 } from "commander";
|
|
1718
|
+
function resolveOAuthAccessToken(options) {
|
|
1719
|
+
const token = options.accessToken || process.env.SHADOWOB_OAUTH_TOKEN;
|
|
1720
|
+
if (!token) {
|
|
1721
|
+
throw new Error("Provide --access-token or SHADOWOB_OAUTH_TOKEN for OAuth commerce APIs");
|
|
1722
|
+
}
|
|
1723
|
+
return token;
|
|
1724
|
+
}
|
|
1725
|
+
function parseMetadata2(value) {
|
|
1726
|
+
if (!value) return void 0;
|
|
1727
|
+
const parsed = JSON.parse(value);
|
|
1728
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1729
|
+
throw new Error("--metadata must be a JSON object");
|
|
1730
|
+
}
|
|
1731
|
+
return parsed;
|
|
1732
|
+
}
|
|
1509
1733
|
function createOAuthCommand() {
|
|
1510
|
-
const oauth = new
|
|
1734
|
+
const oauth = new Command17("oauth").description("OAuth management commands");
|
|
1511
1735
|
oauth.command("list").description("List OAuth apps").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(async (options) => {
|
|
1512
1736
|
try {
|
|
1513
1737
|
const client = await getClient(options.profile);
|
|
@@ -1597,13 +1821,52 @@ function createOAuthCommand() {
|
|
|
1597
1821
|
process.exit(1);
|
|
1598
1822
|
}
|
|
1599
1823
|
});
|
|
1824
|
+
const commerce = oauth.command("commerce").description("OAuth commerce entitlement commands");
|
|
1825
|
+
commerce.command("check").description("Check the OAuth token user entitlement for the calling app").option("--resource-type <type>", "Resource type, defaults to external_app").option("--resource-id <id>", "App resource ID or app-id:feature").option("--capability <capability>", "Capability, defaults to use").option("--access-token <token>", "OAuth access token; defaults to SHADOWOB_OAUTH_TOKEN").option("--profile <name>", "Profile to use for server URL").option("--json", "Output as JSON").action(
|
|
1826
|
+
async (options) => {
|
|
1827
|
+
try {
|
|
1828
|
+
const client = await getClientWithToken(resolveOAuthAccessToken(options), options.profile);
|
|
1829
|
+
const result = await client.getOAuthCommerceEntitlementAccess({
|
|
1830
|
+
resourceType: options.resourceType,
|
|
1831
|
+
resourceId: options.resourceId,
|
|
1832
|
+
capability: options.capability
|
|
1833
|
+
});
|
|
1834
|
+
output(result, { json: options.json });
|
|
1835
|
+
} catch (error) {
|
|
1836
|
+
outputError(error instanceof Error ? error.message : String(error), {
|
|
1837
|
+
json: options.json
|
|
1838
|
+
});
|
|
1839
|
+
process.exit(1);
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
);
|
|
1843
|
+
commerce.command("redeem").description("Redeem the OAuth token user entitlement for the calling app").requiredOption("--idempotency-key <key>", "Provider idempotency key").option("--resource-type <type>", "Resource type, defaults to external_app").option("--resource-id <id>", "App resource ID or app-id:feature").option("--capability <capability>", "Capability, defaults to use").option("--metadata <json>", "Flat provider metadata JSON object").option("--access-token <token>", "OAuth access token; defaults to SHADOWOB_OAUTH_TOKEN").option("--profile <name>", "Profile to use for server URL").option("--json", "Output as JSON").action(
|
|
1844
|
+
async (options) => {
|
|
1845
|
+
try {
|
|
1846
|
+
const client = await getClientWithToken(resolveOAuthAccessToken(options), options.profile);
|
|
1847
|
+
const result = await client.redeemOAuthCommerceEntitlement({
|
|
1848
|
+
idempotencyKey: options.idempotencyKey,
|
|
1849
|
+
resourceType: options.resourceType,
|
|
1850
|
+
resourceId: options.resourceId,
|
|
1851
|
+
capability: options.capability,
|
|
1852
|
+
metadata: parseMetadata2(options.metadata)
|
|
1853
|
+
});
|
|
1854
|
+
output(result, { json: options.json });
|
|
1855
|
+
} catch (error) {
|
|
1856
|
+
outputError(error instanceof Error ? error.message : String(error), {
|
|
1857
|
+
json: options.json
|
|
1858
|
+
});
|
|
1859
|
+
process.exit(1);
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
);
|
|
1600
1863
|
return oauth;
|
|
1601
1864
|
}
|
|
1602
1865
|
|
|
1603
1866
|
// src/commands/ping.ts
|
|
1604
|
-
import { Command as
|
|
1867
|
+
import { Command as Command18 } from "commander";
|
|
1605
1868
|
function createPingCommand() {
|
|
1606
|
-
const ping = new
|
|
1869
|
+
const ping = new Command18("ping").description("Test connection to Shadow server");
|
|
1607
1870
|
ping.option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(async (options) => {
|
|
1608
1871
|
const startTime = Date.now();
|
|
1609
1872
|
const outputOpts = { json: options.json };
|
|
@@ -1652,9 +1915,9 @@ function createPingCommand() {
|
|
|
1652
1915
|
}
|
|
1653
1916
|
|
|
1654
1917
|
// src/commands/profile-comments.ts
|
|
1655
|
-
import { Command as
|
|
1918
|
+
import { Command as Command19 } from "commander";
|
|
1656
1919
|
function createProfileCommentsCommand() {
|
|
1657
|
-
const comments = new
|
|
1920
|
+
const comments = new Command19("profile-comments").description(
|
|
1658
1921
|
"Profile comment management commands"
|
|
1659
1922
|
);
|
|
1660
1923
|
comments.command("get").description("Get comments for a user profile").argument("<user-id>", "Profile user ID").option("--limit <n>", "Number of results", "20").option("--offset <n>", "Offset for pagination", "0").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(async (userId, options) => {
|
|
@@ -1704,9 +1967,9 @@ function createProfileCommentsCommand() {
|
|
|
1704
1967
|
}
|
|
1705
1968
|
|
|
1706
1969
|
// src/commands/search.ts
|
|
1707
|
-
import { Command as
|
|
1970
|
+
import { Command as Command20 } from "commander";
|
|
1708
1971
|
function createSearchCommand() {
|
|
1709
|
-
const search = new
|
|
1972
|
+
const search = new Command20("search").description("Search commands");
|
|
1710
1973
|
search.command("messages").description("Search messages").requiredOption("--query <text>", "Search query").option("--server-id <id>", "Limit to server").option("--channel-id <id>", "Limit to channel").option("--limit <n>", "Number of results (1-100)", "20").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
1711
1974
|
async (options) => {
|
|
1712
1975
|
try {
|
|
@@ -1731,9 +1994,9 @@ function createSearchCommand() {
|
|
|
1731
1994
|
}
|
|
1732
1995
|
|
|
1733
1996
|
// src/commands/servers.ts
|
|
1734
|
-
import { Command as
|
|
1997
|
+
import { Command as Command21 } from "commander";
|
|
1735
1998
|
function createServersCommand() {
|
|
1736
|
-
const servers = new
|
|
1999
|
+
const servers = new Command21("servers").description("Server management commands");
|
|
1737
2000
|
servers.command("list").description("List all servers you have joined").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(async (options) => {
|
|
1738
2001
|
try {
|
|
1739
2002
|
const client = await getClient(options.profile);
|
|
@@ -1827,9 +2090,25 @@ function createServersCommand() {
|
|
|
1827
2090
|
}
|
|
1828
2091
|
|
|
1829
2092
|
// src/commands/shop.ts
|
|
1830
|
-
import { Command as
|
|
2093
|
+
import { Command as Command22 } from "commander";
|
|
2094
|
+
function parseJsonObject(value, optionName) {
|
|
2095
|
+
if (!value) return {};
|
|
2096
|
+
const parsed = JSON.parse(value);
|
|
2097
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2098
|
+
throw new Error(`${optionName} must be a JSON object`);
|
|
2099
|
+
}
|
|
2100
|
+
return parsed;
|
|
2101
|
+
}
|
|
2102
|
+
function parseOptionalNumber(value, optionName) {
|
|
2103
|
+
if (value == null) return void 0;
|
|
2104
|
+
const parsed = Number.parseInt(value, 10);
|
|
2105
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
2106
|
+
throw new Error(`${optionName} must be a positive integer`);
|
|
2107
|
+
}
|
|
2108
|
+
return parsed;
|
|
2109
|
+
}
|
|
1831
2110
|
function createShopCommand() {
|
|
1832
|
-
const shop = new
|
|
2111
|
+
const shop = new Command22("shop").description("Shop commands");
|
|
1833
2112
|
shop.command("get").description("Get shop info").argument("<server-id>", "Server ID").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(async (serverId, options) => {
|
|
1834
2113
|
try {
|
|
1835
2114
|
const client = await getClient(options.profile);
|
|
@@ -1840,12 +2119,54 @@ function createShopCommand() {
|
|
|
1840
2119
|
process.exit(1);
|
|
1841
2120
|
}
|
|
1842
2121
|
});
|
|
2122
|
+
shop.command("get-by-id").description("Get shop info by shop ID").argument("<shop-id>", "Shop ID").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(async (shopId, options) => {
|
|
2123
|
+
try {
|
|
2124
|
+
const client = await getClient(options.profile);
|
|
2125
|
+
const shopData = await client.getShopById(shopId);
|
|
2126
|
+
output(shopData, { json: options.json });
|
|
2127
|
+
} catch (error) {
|
|
2128
|
+
outputError(error instanceof Error ? error.message : String(error), { json: options.json });
|
|
2129
|
+
process.exit(1);
|
|
2130
|
+
}
|
|
2131
|
+
});
|
|
2132
|
+
const me = shop.command("me").description("Personal shop commands");
|
|
2133
|
+
me.command("get").description("Get my personal shop").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(async (options) => {
|
|
2134
|
+
try {
|
|
2135
|
+
const client = await getClient(options.profile);
|
|
2136
|
+
output(await client.getMyShop(), { json: options.json });
|
|
2137
|
+
} catch (error) {
|
|
2138
|
+
outputError(error instanceof Error ? error.message : String(error), { json: options.json });
|
|
2139
|
+
process.exit(1);
|
|
2140
|
+
}
|
|
2141
|
+
});
|
|
2142
|
+
me.command("upsert").description("Create or update my personal shop").option("--data <json>", "Shop JSON payload").option("--name <name>", "Shop name").option("--description <description>", "Shop description").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
2143
|
+
async (options) => {
|
|
2144
|
+
try {
|
|
2145
|
+
const client = await getClient(options.profile);
|
|
2146
|
+
const payload = parseJsonObject(options.data, "--data");
|
|
2147
|
+
if (options.name) payload.name = options.name;
|
|
2148
|
+
if (options.description) payload.description = options.description;
|
|
2149
|
+
output(await client.upsertMyShop(payload), { json: options.json });
|
|
2150
|
+
} catch (error) {
|
|
2151
|
+
outputError(error instanceof Error ? error.message : String(error), {
|
|
2152
|
+
json: options.json
|
|
2153
|
+
});
|
|
2154
|
+
process.exit(1);
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
);
|
|
1843
2158
|
const products = shop.command("products").description("Product commands");
|
|
1844
|
-
products.command("list").description("List products").argument("<server-id>", "Server ID").option("--category-id <id>", "Filter by category").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
2159
|
+
products.command("list").description("List products").argument("<server-id>", "Server ID").option("--category-id <id>", "Filter by category").option("--status <status>", "Filter by status").option("--keyword <keyword>", "Search keyword").option("--limit <n>", "Maximum products").option("--offset <n>", "Offset").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
1845
2160
|
async (serverId, options) => {
|
|
1846
2161
|
try {
|
|
1847
2162
|
const client = await getClient(options.profile);
|
|
1848
|
-
const products2 = await client.listProducts(serverId, {
|
|
2163
|
+
const products2 = await client.listProducts(serverId, {
|
|
2164
|
+
categoryId: options.categoryId,
|
|
2165
|
+
status: options.status,
|
|
2166
|
+
keyword: options.keyword,
|
|
2167
|
+
limit: parseOptionalNumber(options.limit, "--limit"),
|
|
2168
|
+
offset: options.offset ? Number.parseInt(options.offset, 10) : void 0
|
|
2169
|
+
});
|
|
1849
2170
|
output(products2, { json: options.json });
|
|
1850
2171
|
} catch (error) {
|
|
1851
2172
|
outputError(error instanceof Error ? error.message : String(error), {
|
|
@@ -1855,6 +2176,24 @@ function createShopCommand() {
|
|
|
1855
2176
|
}
|
|
1856
2177
|
}
|
|
1857
2178
|
);
|
|
2179
|
+
products.command("list-by-shop").description("List products by shop ID").argument("<shop-id>", "Shop ID").option("--keyword <keyword>", "Search keyword").option("--limit <n>", "Maximum products").option("--offset <n>", "Offset").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
2180
|
+
async (shopId, options) => {
|
|
2181
|
+
try {
|
|
2182
|
+
const client = await getClient(options.profile);
|
|
2183
|
+
const result = await client.listShopProducts(shopId, {
|
|
2184
|
+
keyword: options.keyword,
|
|
2185
|
+
limit: parseOptionalNumber(options.limit, "--limit"),
|
|
2186
|
+
offset: options.offset ? Number.parseInt(options.offset, 10) : void 0
|
|
2187
|
+
});
|
|
2188
|
+
output(result, { json: options.json });
|
|
2189
|
+
} catch (error) {
|
|
2190
|
+
outputError(error instanceof Error ? error.message : String(error), {
|
|
2191
|
+
json: options.json
|
|
2192
|
+
});
|
|
2193
|
+
process.exit(1);
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
);
|
|
1858
2197
|
products.command("get").description("Get product details").argument("<server-id>", "Server ID").argument("<product-id>", "Product ID").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
1859
2198
|
async (serverId, productId, options) => {
|
|
1860
2199
|
try {
|
|
@@ -1869,34 +2208,42 @@ function createShopCommand() {
|
|
|
1869
2208
|
}
|
|
1870
2209
|
}
|
|
1871
2210
|
);
|
|
1872
|
-
|
|
1873
|
-
cart.command("list").description("List cart items").argument("<server-id>", "Server ID").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(async (serverId, options) => {
|
|
2211
|
+
products.command("context").description("Get buyer-facing product context").argument("<product-id>", "Product ID").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(async (productId, options) => {
|
|
1874
2212
|
try {
|
|
1875
2213
|
const client = await getClient(options.profile);
|
|
1876
|
-
|
|
1877
|
-
output(cartData, { json: options.json });
|
|
2214
|
+
output(await client.getCommerceProductContext(productId), { json: options.json });
|
|
1878
2215
|
} catch (error) {
|
|
1879
|
-
outputError(error instanceof Error ? error.message : String(error), {
|
|
2216
|
+
outputError(error instanceof Error ? error.message : String(error), {
|
|
2217
|
+
json: options.json
|
|
2218
|
+
});
|
|
1880
2219
|
process.exit(1);
|
|
1881
2220
|
}
|
|
1882
2221
|
});
|
|
1883
|
-
|
|
1884
|
-
orders.command("list").description("List orders").argument("<server-id>", "Server ID").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(async (serverId, options) => {
|
|
2222
|
+
products.command("create-by-shop").description("Create a product by shop ID using a JSON payload").argument("<shop-id>", "Shop ID").requiredOption("--data <json>", "Product JSON payload").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(async (shopId, options) => {
|
|
1885
2223
|
try {
|
|
1886
2224
|
const client = await getClient(options.profile);
|
|
1887
|
-
|
|
1888
|
-
|
|
2225
|
+
output(await client.createShopProduct(shopId, parseJsonObject(options.data, "--data")), {
|
|
2226
|
+
json: options.json
|
|
2227
|
+
});
|
|
1889
2228
|
} catch (error) {
|
|
1890
|
-
outputError(error instanceof Error ? error.message : String(error), {
|
|
2229
|
+
outputError(error instanceof Error ? error.message : String(error), {
|
|
2230
|
+
json: options.json
|
|
2231
|
+
});
|
|
1891
2232
|
process.exit(1);
|
|
1892
2233
|
}
|
|
1893
2234
|
});
|
|
1894
|
-
|
|
1895
|
-
async (
|
|
2235
|
+
products.command("update-by-shop").description("Update a product by shop ID using a JSON payload").argument("<shop-id>", "Shop ID").argument("<product-id>", "Product ID").requiredOption("--data <json>", "Product JSON payload").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
2236
|
+
async (shopId, productId, options) => {
|
|
1896
2237
|
try {
|
|
1897
2238
|
const client = await getClient(options.profile);
|
|
1898
|
-
|
|
1899
|
-
|
|
2239
|
+
output(
|
|
2240
|
+
await client.updateShopProduct(
|
|
2241
|
+
shopId,
|
|
2242
|
+
productId,
|
|
2243
|
+
parseJsonObject(options.data, "--data")
|
|
2244
|
+
),
|
|
2245
|
+
{ json: options.json }
|
|
2246
|
+
);
|
|
1900
2247
|
} catch (error) {
|
|
1901
2248
|
outputError(error instanceof Error ? error.message : String(error), {
|
|
1902
2249
|
json: options.json
|
|
@@ -1905,49 +2252,243 @@ function createShopCommand() {
|
|
|
1905
2252
|
}
|
|
1906
2253
|
}
|
|
1907
2254
|
);
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
2255
|
+
products.command("purchase").description("Purchase a product by shop ID").argument("<shop-id>", "Shop ID").argument("<product-id>", "Product ID").option("--sku-id <id>", "SKU ID").requiredOption("--idempotency-key <key>", "Idempotency key").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
2256
|
+
async (shopId, productId, options) => {
|
|
2257
|
+
try {
|
|
2258
|
+
const client = await getClient(options.profile);
|
|
2259
|
+
output(
|
|
2260
|
+
await client.purchaseShopProduct(shopId, productId, {
|
|
2261
|
+
skuId: options.skuId,
|
|
2262
|
+
idempotencyKey: options.idempotencyKey
|
|
2263
|
+
}),
|
|
2264
|
+
{ json: options.json }
|
|
2265
|
+
);
|
|
2266
|
+
} catch (error) {
|
|
2267
|
+
outputError(error instanceof Error ? error.message : String(error), {
|
|
2268
|
+
json: options.json
|
|
2269
|
+
});
|
|
2270
|
+
process.exit(1);
|
|
2271
|
+
}
|
|
1917
2272
|
}
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
const profileName = options.profile ?? await configManager.getCurrentProfileName();
|
|
1930
|
-
const profile = await configManager.getProfile(options.profile);
|
|
1931
|
-
if (!profile) {
|
|
1932
|
-
outputError(
|
|
1933
|
-
profileName ? `Profile "${profileName}" not found` : "Not authenticated. Run: shadowob auth login",
|
|
2273
|
+
);
|
|
2274
|
+
const offers = shop.command("offers").description("Commerce offer commands");
|
|
2275
|
+
offers.command("list").description("List commerce offers for a shop").argument("<shop-id>", "Shop ID").option("--keyword <keyword>", "Search keyword").option("--limit <n>", "Maximum offers").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
2276
|
+
async (shopId, options) => {
|
|
2277
|
+
try {
|
|
2278
|
+
const client = await getClient(options.profile);
|
|
2279
|
+
output(
|
|
2280
|
+
await client.listCommerceOffers(shopId, {
|
|
2281
|
+
keyword: options.keyword,
|
|
2282
|
+
limit: parseOptionalNumber(options.limit, "--limit")
|
|
2283
|
+
}),
|
|
1934
2284
|
{ json: options.json }
|
|
1935
2285
|
);
|
|
2286
|
+
} catch (error) {
|
|
2287
|
+
outputError(error instanceof Error ? error.message : String(error), {
|
|
2288
|
+
json: options.json
|
|
2289
|
+
});
|
|
1936
2290
|
process.exit(1);
|
|
1937
2291
|
}
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
2292
|
+
}
|
|
2293
|
+
);
|
|
2294
|
+
offers.command("create").description("Create a commerce offer for a shop").argument("<shop-id>", "Shop ID").requiredOption("--product-id <id>", "Product ID").option("--allowed-surfaces <list>", "Comma-separated surfaces, e.g. channel,dm").option("--price-override <amount>", "Price override").option("--seller-buddy-user-id <id>", "Seller Buddy user ID").option("--status <status>", "Offer status").option("--metadata <json>", "Metadata JSON object").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
2295
|
+
async (shopId, options) => {
|
|
2296
|
+
try {
|
|
2297
|
+
const client = await getClient(options.profile);
|
|
2298
|
+
output(
|
|
2299
|
+
await client.createCommerceOffer(shopId, {
|
|
2300
|
+
productId: options.productId,
|
|
2301
|
+
allowedSurfaces: options.allowedSurfaces ? options.allowedSurfaces.split(",").map((item) => item.trim()) : void 0,
|
|
2302
|
+
priceOverride: options.priceOverride ? Number(options.priceOverride) : void 0,
|
|
2303
|
+
sellerBuddyUserId: options.sellerBuddyUserId,
|
|
2304
|
+
status: options.status,
|
|
2305
|
+
metadata: options.metadata ? parseJsonObject(options.metadata, "--metadata") : void 0
|
|
2306
|
+
}),
|
|
2307
|
+
{ json: options.json }
|
|
2308
|
+
);
|
|
2309
|
+
} catch (error) {
|
|
2310
|
+
outputError(error instanceof Error ? error.message : String(error), {
|
|
2311
|
+
json: options.json
|
|
2312
|
+
});
|
|
2313
|
+
process.exit(1);
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
);
|
|
2317
|
+
const deliverables = offers.command("deliverables").description("Commerce deliverable commands");
|
|
2318
|
+
deliverables.command("create").description("Create a deliverable for an offer").argument("<shop-id>", "Shop ID").argument("<offer-id>", "Offer ID").requiredOption("--resource-id <id>", "Resource ID").option("--kind <kind>", "Deliverable kind").option("--resource-type <type>", "Resource type").option("--sender-buddy-user-id <id>", "Sender Buddy user ID").option("--message-template-key <key>", "Message template key").option("--metadata <json>", "Metadata JSON object").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
2319
|
+
async (shopId, offerId, options) => {
|
|
2320
|
+
try {
|
|
2321
|
+
const client = await getClient(options.profile);
|
|
2322
|
+
output(
|
|
2323
|
+
await client.createCommerceDeliverable(shopId, offerId, {
|
|
2324
|
+
resourceId: options.resourceId,
|
|
2325
|
+
kind: options.kind,
|
|
2326
|
+
resourceType: options.resourceType,
|
|
2327
|
+
senderBuddyUserId: options.senderBuddyUserId,
|
|
2328
|
+
messageTemplateKey: options.messageTemplateKey,
|
|
2329
|
+
metadata: options.metadata ? parseJsonObject(options.metadata, "--metadata") : void 0
|
|
2330
|
+
}),
|
|
2331
|
+
{ json: options.json }
|
|
2332
|
+
);
|
|
2333
|
+
} catch (error) {
|
|
2334
|
+
outputError(error instanceof Error ? error.message : String(error), {
|
|
2335
|
+
json: options.json
|
|
2336
|
+
});
|
|
2337
|
+
process.exit(1);
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
);
|
|
2341
|
+
const assetDefinitions = shop.command("assets").description("Shop asset definition commands");
|
|
2342
|
+
assetDefinitions.command("list").description("List shop asset definitions").argument("<shop-id>", "Shop ID").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(async (shopId, options) => {
|
|
2343
|
+
try {
|
|
2344
|
+
const client = await getClient(options.profile);
|
|
2345
|
+
output(await client.listShopAssetDefinitions(shopId), { json: options.json });
|
|
2346
|
+
} catch (error) {
|
|
2347
|
+
outputError(error instanceof Error ? error.message : String(error), {
|
|
2348
|
+
json: options.json
|
|
2349
|
+
});
|
|
2350
|
+
process.exit(1);
|
|
2351
|
+
}
|
|
2352
|
+
});
|
|
2353
|
+
assetDefinitions.command("create").description("Create a shop asset definition").argument("<shop-id>", "Shop ID").requiredOption("--asset-type <type>", "Asset type").requiredOption("--name <name>", "Asset name").option("--data <json>", "Additional asset definition JSON").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
2354
|
+
async (shopId, options) => {
|
|
2355
|
+
try {
|
|
2356
|
+
const client = await getClient(options.profile);
|
|
2357
|
+
output(
|
|
2358
|
+
await client.createShopAssetDefinition(shopId, {
|
|
2359
|
+
...parseJsonObject(options.data, "--data"),
|
|
2360
|
+
assetType: options.assetType,
|
|
2361
|
+
name: options.name
|
|
2362
|
+
}),
|
|
2363
|
+
{ json: options.json }
|
|
2364
|
+
);
|
|
2365
|
+
} catch (error) {
|
|
2366
|
+
outputError(error instanceof Error ? error.message : String(error), {
|
|
2367
|
+
json: options.json
|
|
2368
|
+
});
|
|
2369
|
+
process.exit(1);
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
);
|
|
2373
|
+
assetDefinitions.command("update").description("Update a shop asset definition").argument("<shop-id>", "Shop ID").argument("<asset-definition-id>", "Asset definition ID").requiredOption("--data <json>", "Asset definition JSON payload").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
2374
|
+
async (shopId, assetDefinitionId, options) => {
|
|
2375
|
+
try {
|
|
2376
|
+
const client = await getClient(options.profile);
|
|
2377
|
+
output(
|
|
2378
|
+
await client.updateShopAssetDefinition(
|
|
2379
|
+
shopId,
|
|
2380
|
+
assetDefinitionId,
|
|
2381
|
+
parseJsonObject(options.data, "--data")
|
|
2382
|
+
),
|
|
2383
|
+
{ json: options.json }
|
|
2384
|
+
);
|
|
2385
|
+
} catch (error) {
|
|
2386
|
+
outputError(error instanceof Error ? error.message : String(error), {
|
|
2387
|
+
json: options.json
|
|
2388
|
+
});
|
|
2389
|
+
process.exit(1);
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
);
|
|
2393
|
+
const entitlements = shop.command("entitlements").description("Shop entitlement commands");
|
|
2394
|
+
entitlements.command("list").description("List entitlements for a shop").argument("<shop-id>", "Shop ID").option("--limit <n>", "Maximum entitlements").option("--offset <n>", "Offset").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
2395
|
+
async (shopId, options) => {
|
|
2396
|
+
try {
|
|
2397
|
+
const client = await getClient(options.profile);
|
|
2398
|
+
output(
|
|
2399
|
+
await client.listShopEntitlements(shopId, {
|
|
2400
|
+
limit: parseOptionalNumber(options.limit, "--limit"),
|
|
2401
|
+
offset: options.offset ? Number.parseInt(options.offset, 10) : void 0
|
|
2402
|
+
}),
|
|
2403
|
+
{ json: options.json }
|
|
2404
|
+
);
|
|
2405
|
+
} catch (error) {
|
|
2406
|
+
outputError(error instanceof Error ? error.message : String(error), {
|
|
2407
|
+
json: options.json
|
|
2408
|
+
});
|
|
2409
|
+
process.exit(1);
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
);
|
|
2413
|
+
const cart = shop.command("cart").description("Cart commands");
|
|
2414
|
+
cart.command("list").description("List cart items").argument("<server-id>", "Server ID").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(async (serverId, options) => {
|
|
2415
|
+
try {
|
|
2416
|
+
const client = await getClient(options.profile);
|
|
2417
|
+
const cartData = await client.getCart(serverId);
|
|
2418
|
+
output(cartData, { json: options.json });
|
|
2419
|
+
} catch (error) {
|
|
2420
|
+
outputError(error instanceof Error ? error.message : String(error), { json: options.json });
|
|
2421
|
+
process.exit(1);
|
|
2422
|
+
}
|
|
2423
|
+
});
|
|
2424
|
+
const orders = shop.command("orders").description("Order commands");
|
|
2425
|
+
orders.command("list").description("List orders").argument("<server-id>", "Server ID").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(async (serverId, options) => {
|
|
2426
|
+
try {
|
|
2427
|
+
const client = await getClient(options.profile);
|
|
2428
|
+
const orders2 = await client.listOrders(serverId);
|
|
2429
|
+
output(orders2, { json: options.json });
|
|
2430
|
+
} catch (error) {
|
|
2431
|
+
outputError(error instanceof Error ? error.message : String(error), { json: options.json });
|
|
2432
|
+
process.exit(1);
|
|
2433
|
+
}
|
|
2434
|
+
});
|
|
2435
|
+
orders.command("get").description("Get order details").argument("<server-id>", "Server ID").argument("<order-id>", "Order ID").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
2436
|
+
async (serverId, orderId, options) => {
|
|
2437
|
+
try {
|
|
2438
|
+
const client = await getClient(options.profile);
|
|
2439
|
+
const order = await client.getOrder(serverId, orderId);
|
|
2440
|
+
output(order, { json: options.json });
|
|
2441
|
+
} catch (error) {
|
|
2442
|
+
outputError(error instanceof Error ? error.message : String(error), {
|
|
2443
|
+
json: options.json
|
|
2444
|
+
});
|
|
2445
|
+
process.exit(1);
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
);
|
|
2449
|
+
const wallet = shop.command("wallet").description("Wallet commands");
|
|
2450
|
+
wallet.command("balance").description("Get wallet balance").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(async (options) => {
|
|
2451
|
+
try {
|
|
2452
|
+
const client = await getClient(options.profile);
|
|
2453
|
+
const wallet2 = await client.getWallet();
|
|
2454
|
+
output(wallet2, { json: options.json });
|
|
2455
|
+
} catch (error) {
|
|
2456
|
+
outputError(error instanceof Error ? error.message : String(error), { json: options.json });
|
|
2457
|
+
process.exit(1);
|
|
2458
|
+
}
|
|
2459
|
+
});
|
|
2460
|
+
return shop;
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
// src/commands/status.ts
|
|
2464
|
+
import { Command as Command23 } from "commander";
|
|
2465
|
+
function createStatusCommand() {
|
|
2466
|
+
const status = new Command23("status").description("Show detailed status information");
|
|
2467
|
+
status.option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(async (options) => {
|
|
2468
|
+
const outputOpts = { json: options.json };
|
|
2469
|
+
try {
|
|
2470
|
+
const profileName = options.profile ?? await configManager.getCurrentProfileName();
|
|
2471
|
+
const profile = await configManager.getProfile(options.profile);
|
|
2472
|
+
if (!profile) {
|
|
2473
|
+
outputError(
|
|
2474
|
+
profileName ? `Profile "${profileName}" not found` : "Not authenticated. Run: shadowob auth login",
|
|
2475
|
+
{ json: options.json }
|
|
2476
|
+
);
|
|
2477
|
+
process.exit(1);
|
|
2478
|
+
}
|
|
2479
|
+
const client = await getClient(options.profile);
|
|
2480
|
+
const user = await client.getMe();
|
|
2481
|
+
const unread = await client.getUnreadCount().catch(() => ({ count: 0 }));
|
|
2482
|
+
const statusInfo = {
|
|
2483
|
+
profile: {
|
|
2484
|
+
name: profileName,
|
|
2485
|
+
serverUrl: profile.serverUrl
|
|
2486
|
+
},
|
|
2487
|
+
user: {
|
|
2488
|
+
id: user.id,
|
|
2489
|
+
username: user.username,
|
|
2490
|
+
displayName: user.displayName,
|
|
2491
|
+
avatarUrl: user.avatarUrl
|
|
1951
2492
|
},
|
|
1952
2493
|
stats: {
|
|
1953
2494
|
unreadNotifications: unread.count
|
|
@@ -2000,9 +2541,9 @@ function createStatusCommand() {
|
|
|
2000
2541
|
}
|
|
2001
2542
|
|
|
2002
2543
|
// src/commands/threads.ts
|
|
2003
|
-
import { Command as
|
|
2544
|
+
import { Command as Command24 } from "commander";
|
|
2004
2545
|
function createThreadsCommand() {
|
|
2005
|
-
const threads = new
|
|
2546
|
+
const threads = new Command24("threads").description("Thread commands");
|
|
2006
2547
|
threads.command("list").description("List threads in a channel").argument("<channel-id>", "Channel ID").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(async (channelId, options) => {
|
|
2007
2548
|
try {
|
|
2008
2549
|
const client = await getClient(options.profile);
|
|
@@ -2083,10 +2624,1317 @@ function createThreadsCommand() {
|
|
|
2083
2624
|
return threads;
|
|
2084
2625
|
}
|
|
2085
2626
|
|
|
2627
|
+
// src/commands/voice.ts
|
|
2628
|
+
import { join as join2 } from "path";
|
|
2629
|
+
import { Command as Command25 } from "commander";
|
|
2630
|
+
|
|
2631
|
+
// src/utils/voice-media-bridge.ts
|
|
2632
|
+
import { execFileSync as execFileSync2, spawn } from "child_process";
|
|
2633
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
2634
|
+
import { createWriteStream, existsSync } from "fs";
|
|
2635
|
+
import { mkdir, mkdtemp, open, readdir, readFile as readFile2, rm, stat, writeFile as writeFile2 } from "fs/promises";
|
|
2636
|
+
import { createServer } from "http";
|
|
2637
|
+
import { createRequire } from "module";
|
|
2638
|
+
import { homedir, tmpdir } from "os";
|
|
2639
|
+
import { basename as basename2, dirname, join } from "path";
|
|
2640
|
+
var require2 = createRequire(import.meta.url);
|
|
2641
|
+
var DEFAULT_SCREEN_INTERVAL_MS = 1e3;
|
|
2642
|
+
var MAX_POST_BYTES = 20 * 1024 * 1024;
|
|
2643
|
+
var PLAYWRIGHT_VERSION = "1.59.1";
|
|
2644
|
+
var defaultScreenIntervalMs = DEFAULT_SCREEN_INTERVAL_MS;
|
|
2645
|
+
async function runVoiceMediaBridge(options) {
|
|
2646
|
+
const chromeExecutable = await findBrowserExecutable(options.browser, {
|
|
2647
|
+
installBrowser: options.installBrowser,
|
|
2648
|
+
json: options.json
|
|
2649
|
+
});
|
|
2650
|
+
const agoraScriptPath = resolveAgoraBrowserScript(options.agoraSdk);
|
|
2651
|
+
const bridgeClientId = `shadowob-cli-media-bridge-${randomUUID2()}`;
|
|
2652
|
+
const joinResult = await options.client.joinVoiceChannel(options.channelId, {
|
|
2653
|
+
muted: options.muted,
|
|
2654
|
+
clientId: bridgeClientId
|
|
2655
|
+
});
|
|
2656
|
+
const state = createRuntimeState(options, joinResult, agoraScriptPath, bridgeClientId);
|
|
2657
|
+
let chrome = null;
|
|
2658
|
+
try {
|
|
2659
|
+
const server = createServer((req, res) => {
|
|
2660
|
+
void handleBridgeRequest(state, req, res);
|
|
2661
|
+
});
|
|
2662
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
2663
|
+
const address = server.address();
|
|
2664
|
+
if (!address || typeof address === "string") throw new Error("Failed to start media bridge");
|
|
2665
|
+
state.baseUrl = `http://127.0.0.1:${address.port}/${state.token}`;
|
|
2666
|
+
emitBridgeEvent(state, {
|
|
2667
|
+
type: "bridge:started",
|
|
2668
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2669
|
+
detail: {
|
|
2670
|
+
channelId: options.channelId,
|
|
2671
|
+
audioOutDir: options.audioOutDir ?? null,
|
|
2672
|
+
videoOutDir: options.videoOutDir ?? null,
|
|
2673
|
+
screenOutDir: options.screenOutDir ?? null,
|
|
2674
|
+
input: options.stdinPcm ? "stdin-pcm" : options.inputFile ? "file" : null
|
|
2675
|
+
}
|
|
2676
|
+
});
|
|
2677
|
+
chrome = await launchChrome(chromeExecutable, `${state.baseUrl}/`, options);
|
|
2678
|
+
attachConsoleInspector(chrome.port, state);
|
|
2679
|
+
if (options.stdinPcm) {
|
|
2680
|
+
startReadingStdinPcm(state);
|
|
2681
|
+
}
|
|
2682
|
+
await waitForBridgeStop(options.durationSeconds, chrome);
|
|
2683
|
+
await shutdownBridge(state, server, chrome, options);
|
|
2684
|
+
} catch (error) {
|
|
2685
|
+
await shutdownBridge(state, null, chrome, options).catch(() => void 0);
|
|
2686
|
+
throw error;
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
function createRuntimeState(options, joinResult, agoraScriptPath, clientId) {
|
|
2690
|
+
return {
|
|
2691
|
+
options,
|
|
2692
|
+
joinResult,
|
|
2693
|
+
agoraScriptPath,
|
|
2694
|
+
clientId,
|
|
2695
|
+
token: randomUUID2(),
|
|
2696
|
+
baseUrl: "",
|
|
2697
|
+
screenSeq: /* @__PURE__ */ new Map(),
|
|
2698
|
+
audioSinks: /* @__PURE__ */ new Map(),
|
|
2699
|
+
videoSinks: /* @__PURE__ */ new Map(),
|
|
2700
|
+
pcmChunks: [],
|
|
2701
|
+
pcmNextIndex: 0,
|
|
2702
|
+
pendingPcmRequests: /* @__PURE__ */ new Set(),
|
|
2703
|
+
stdinEnded: false,
|
|
2704
|
+
shuttingDown: false
|
|
2705
|
+
};
|
|
2706
|
+
}
|
|
2707
|
+
async function handleBridgeRequest(state, req, res) {
|
|
2708
|
+
try {
|
|
2709
|
+
const url = new URL(req.url ?? "/", state.baseUrl || "http://127.0.0.1");
|
|
2710
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
2711
|
+
if (parts[0] !== state.token) {
|
|
2712
|
+
sendText(res, 404, "Not found");
|
|
2713
|
+
return;
|
|
2714
|
+
}
|
|
2715
|
+
const route = parts[1] ?? "";
|
|
2716
|
+
if (req.method === "GET" && route === "") {
|
|
2717
|
+
sendHtml(res, bridgeHtml(state.token));
|
|
2718
|
+
return;
|
|
2719
|
+
}
|
|
2720
|
+
if (req.method === "GET" && route === "bridge.js") {
|
|
2721
|
+
sendJavaScript(res, bridgeClientScript());
|
|
2722
|
+
return;
|
|
2723
|
+
}
|
|
2724
|
+
if (req.method === "GET" && route === "agora.js") {
|
|
2725
|
+
sendBuffer(res, await readFile2(state.agoraScriptPath), "application/javascript");
|
|
2726
|
+
return;
|
|
2727
|
+
}
|
|
2728
|
+
if (req.method === "GET" && route === "config") {
|
|
2729
|
+
sendJson(res, {
|
|
2730
|
+
credentials: state.joinResult.credentials,
|
|
2731
|
+
options: {
|
|
2732
|
+
muted: Boolean(state.options.muted),
|
|
2733
|
+
recordAudio: Boolean(state.options.audioOutDir),
|
|
2734
|
+
recordVideo: Boolean(state.options.videoOutDir),
|
|
2735
|
+
recordScreen: Boolean(state.options.screenOutDir),
|
|
2736
|
+
screenIntervalMs: state.options.screenIntervalMs,
|
|
2737
|
+
input: state.options.stdinPcm ? {
|
|
2738
|
+
mode: "stdin-pcm",
|
|
2739
|
+
sampleRate: state.options.stdinSampleRate,
|
|
2740
|
+
channels: state.options.stdinChannels
|
|
2741
|
+
} : state.options.inputFile ? { mode: "file" } : null
|
|
2742
|
+
}
|
|
2743
|
+
});
|
|
2744
|
+
return;
|
|
2745
|
+
}
|
|
2746
|
+
if (req.method === "POST" && route === "token") {
|
|
2747
|
+
const result = await state.options.client.renewVoiceCredentials(state.options.channelId, {
|
|
2748
|
+
clientId: state.clientId
|
|
2749
|
+
});
|
|
2750
|
+
state.joinResult = {
|
|
2751
|
+
...state.joinResult,
|
|
2752
|
+
credentials: result.credentials,
|
|
2753
|
+
state: result.state
|
|
2754
|
+
};
|
|
2755
|
+
sendJson(res, result);
|
|
2756
|
+
return;
|
|
2757
|
+
}
|
|
2758
|
+
if (req.method === "GET" && route === "input-file") {
|
|
2759
|
+
if (!state.options.inputFile) {
|
|
2760
|
+
sendText(res, 404, "No input file configured");
|
|
2761
|
+
return;
|
|
2762
|
+
}
|
|
2763
|
+
sendBuffer(
|
|
2764
|
+
res,
|
|
2765
|
+
await readFile2(state.options.inputFile),
|
|
2766
|
+
mediaTypeForPath(state.options.inputFile)
|
|
2767
|
+
);
|
|
2768
|
+
return;
|
|
2769
|
+
}
|
|
2770
|
+
if (req.method === "GET" && route === "input-pcm") {
|
|
2771
|
+
handleInputPcmRequest(state, Number(url.searchParams.get("cursor") ?? "0"), res);
|
|
2772
|
+
return;
|
|
2773
|
+
}
|
|
2774
|
+
if (req.method === "POST" && route === "event") {
|
|
2775
|
+
const body = await readRequestBody(req, MAX_POST_BYTES);
|
|
2776
|
+
emitBridgeEvent(state, JSON.parse(body.toString("utf8")));
|
|
2777
|
+
sendJson(res, { ok: true });
|
|
2778
|
+
return;
|
|
2779
|
+
}
|
|
2780
|
+
if (req.method === "POST" && route === "audio") {
|
|
2781
|
+
const uid = sanitizeSegment(url.searchParams.get("uid") ?? "unknown");
|
|
2782
|
+
const sampleRate = Number(url.searchParams.get("sampleRate") ?? "48000");
|
|
2783
|
+
const channels = Number(url.searchParams.get("channels") ?? "1");
|
|
2784
|
+
const body = await readRequestBody(req, MAX_POST_BYTES);
|
|
2785
|
+
await writeAudioChunk(state, uid, body, sampleRate, channels);
|
|
2786
|
+
sendJson(res, { ok: true });
|
|
2787
|
+
return;
|
|
2788
|
+
}
|
|
2789
|
+
if (req.method === "POST" && route === "screen") {
|
|
2790
|
+
const uid = sanitizeSegment(url.searchParams.get("uid") ?? "unknown");
|
|
2791
|
+
const body = await readRequestBody(req, MAX_POST_BYTES);
|
|
2792
|
+
await writeScreenFrame(state, uid, body);
|
|
2793
|
+
sendJson(res, { ok: true });
|
|
2794
|
+
return;
|
|
2795
|
+
}
|
|
2796
|
+
if (req.method === "POST" && route === "video") {
|
|
2797
|
+
const uid = sanitizeSegment(url.searchParams.get("uid") ?? "unknown");
|
|
2798
|
+
const body = await readRequestBody(req, MAX_POST_BYTES);
|
|
2799
|
+
await writeVideoChunk(state, uid, body);
|
|
2800
|
+
sendJson(res, { ok: true });
|
|
2801
|
+
return;
|
|
2802
|
+
}
|
|
2803
|
+
sendText(res, 404, "Not found");
|
|
2804
|
+
} catch (error) {
|
|
2805
|
+
sendJson(res, { ok: false, error: error instanceof Error ? error.message : String(error) }, 500);
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
function bridgeHtml(token) {
|
|
2809
|
+
return `<!doctype html>
|
|
2810
|
+
<html>
|
|
2811
|
+
<head>
|
|
2812
|
+
<meta charset="utf-8" />
|
|
2813
|
+
<title>Shadow Voice Bridge</title>
|
|
2814
|
+
<style>
|
|
2815
|
+
body { margin: 0; background: #050607; color: #f3f4f6; font: 14px system-ui, sans-serif; }
|
|
2816
|
+
main { padding: 18px; }
|
|
2817
|
+
video { width: 320px; max-width: 100%; background: #000; }
|
|
2818
|
+
</style>
|
|
2819
|
+
</head>
|
|
2820
|
+
<body>
|
|
2821
|
+
<main>
|
|
2822
|
+
<strong>Shadow Voice Bridge</strong>
|
|
2823
|
+
<div id="status">starting</div>
|
|
2824
|
+
<div id="screens"></div>
|
|
2825
|
+
</main>
|
|
2826
|
+
<script>window.__SHADOW_BRIDGE_TOKEN__ = ${JSON.stringify(token)};</script>
|
|
2827
|
+
<script src="./agora.js"></script>
|
|
2828
|
+
<script type="module" src="./bridge.js"></script>
|
|
2829
|
+
</body>
|
|
2830
|
+
</html>`;
|
|
2831
|
+
}
|
|
2832
|
+
function bridgeClientScript() {
|
|
2833
|
+
return `
|
|
2834
|
+
const token = window.__SHADOW_BRIDGE_TOKEN__;
|
|
2835
|
+
const base = '/' + token;
|
|
2836
|
+
const statusEl = document.getElementById('status');
|
|
2837
|
+
const screenRoot = document.getElementById('screens');
|
|
2838
|
+
const config = await fetch(base + '/config').then((res) => res.json());
|
|
2839
|
+
const AgoraRTC = window.AgoraRTC;
|
|
2840
|
+
AgoraRTC.disableLogUpload?.();
|
|
2841
|
+
AgoraRTC.setLogLevel?.(3);
|
|
2842
|
+
|
|
2843
|
+
const client = AgoraRTC.createClient({ mode: 'rtc', codec: 'vp8' });
|
|
2844
|
+
let credentials = config.credentials;
|
|
2845
|
+
let tokenRenewTimer = null;
|
|
2846
|
+
const audioRecorders = new Map();
|
|
2847
|
+
const screenRecorders = new Map();
|
|
2848
|
+
const videoRecorders = new Map();
|
|
2849
|
+
let inputAudioTrack = null;
|
|
2850
|
+
|
|
2851
|
+
function setStatus(value) {
|
|
2852
|
+
statusEl.textContent = value;
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
async function emit(type, detail = {}) {
|
|
2856
|
+
const payload = { type, timestamp: new Date().toISOString(), ...detail };
|
|
2857
|
+
console.log('[shadow-voice-bridge]', payload);
|
|
2858
|
+
try {
|
|
2859
|
+
await fetch(base + '/event', {
|
|
2860
|
+
method: 'POST',
|
|
2861
|
+
headers: { 'content-type': 'application/json' },
|
|
2862
|
+
body: JSON.stringify(payload),
|
|
2863
|
+
});
|
|
2864
|
+
} catch (error) {
|
|
2865
|
+
console.error('failed to emit bridge event', error);
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
function clearTokenRenewal() {
|
|
2870
|
+
if (!tokenRenewTimer) return;
|
|
2871
|
+
clearTimeout(tokenRenewTimer);
|
|
2872
|
+
tokenRenewTimer = null;
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
function scheduleTokenRenewal() {
|
|
2876
|
+
clearTokenRenewal();
|
|
2877
|
+
if (!credentials.expiresAt) return;
|
|
2878
|
+
const renewAt = new Date(credentials.expiresAt).getTime() - 5 * 60_000;
|
|
2879
|
+
const delay = Math.max(30_000, renewAt - Date.now());
|
|
2880
|
+
tokenRenewTimer = setTimeout(() => {
|
|
2881
|
+
void renewTokens();
|
|
2882
|
+
}, delay);
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
async function renewTokens() {
|
|
2886
|
+
const result = await fetch(base + '/token', { method: 'POST' }).then((res) => {
|
|
2887
|
+
if (!res.ok) throw new Error('token renewal failed: ' + res.status);
|
|
2888
|
+
return res.json();
|
|
2889
|
+
});
|
|
2890
|
+
credentials = result.credentials;
|
|
2891
|
+
if (credentials.token) await client.renewToken(credentials.token);
|
|
2892
|
+
scheduleTokenRenewal();
|
|
2893
|
+
void emit('token:renewed', { detail: { expiresAt: credentials.expiresAt } });
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
function int16FromFloat32(input) {
|
|
2897
|
+
const output = new Int16Array(input.length);
|
|
2898
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
2899
|
+
const sample = Math.max(-1, Math.min(1, input[index]));
|
|
2900
|
+
output[index] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
|
|
2901
|
+
}
|
|
2902
|
+
return output;
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
function startAudioRecorder(uid, track) {
|
|
2906
|
+
if (!config.options.recordAudio || audioRecorders.has(String(uid))) return;
|
|
2907
|
+
const mediaTrack = track.getMediaStreamTrack?.();
|
|
2908
|
+
if (!mediaTrack) {
|
|
2909
|
+
void emit('audio:unsupported', { uid: String(uid), message: 'remote audio track has no MediaStreamTrack' });
|
|
2910
|
+
return;
|
|
2911
|
+
}
|
|
2912
|
+
const context = new AudioContext({ sampleRate: 48000 });
|
|
2913
|
+
const source = context.createMediaStreamSource(new MediaStream([mediaTrack]));
|
|
2914
|
+
const processor = context.createScriptProcessor(4096, 1, 1);
|
|
2915
|
+
const mute = context.createGain();
|
|
2916
|
+
mute.gain.value = 0;
|
|
2917
|
+
processor.onaudioprocess = (event) => {
|
|
2918
|
+
const channel = event.inputBuffer.getChannelData(0);
|
|
2919
|
+
const pcm = int16FromFloat32(channel);
|
|
2920
|
+
void fetch(base + '/audio?uid=' + encodeURIComponent(String(uid)) + '&sampleRate=' + context.sampleRate + '&channels=1', {
|
|
2921
|
+
method: 'POST',
|
|
2922
|
+
body: pcm.buffer,
|
|
2923
|
+
}).catch(() => undefined);
|
|
2924
|
+
};
|
|
2925
|
+
source.connect(processor);
|
|
2926
|
+
processor.connect(mute);
|
|
2927
|
+
mute.connect(context.destination);
|
|
2928
|
+
audioRecorders.set(String(uid), { context, source, processor, mute });
|
|
2929
|
+
void emit('audio:recording-started', { uid: String(uid) });
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
function stopAudioRecorder(uid) {
|
|
2933
|
+
const recorder = audioRecorders.get(String(uid));
|
|
2934
|
+
if (!recorder) return;
|
|
2935
|
+
recorder.processor.disconnect();
|
|
2936
|
+
recorder.source.disconnect();
|
|
2937
|
+
recorder.mute.disconnect();
|
|
2938
|
+
void recorder.context.close();
|
|
2939
|
+
audioRecorders.delete(String(uid));
|
|
2940
|
+
void emit('audio:recording-stopped', { uid: String(uid) });
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
async function startScreenRecorder(uid, track) {
|
|
2944
|
+
if ((!config.options.recordScreen && !config.options.recordVideo) || screenRecorders.has(String(uid))) return;
|
|
2945
|
+
const mediaTrack = track.getMediaStreamTrack?.();
|
|
2946
|
+
if (!mediaTrack) {
|
|
2947
|
+
void emit('screen:unsupported', { uid: String(uid), message: 'remote video track has no MediaStreamTrack' });
|
|
2948
|
+
return;
|
|
2949
|
+
}
|
|
2950
|
+
if (config.options.recordVideo && !videoRecorders.has(String(uid))) {
|
|
2951
|
+
if (typeof MediaRecorder === 'undefined') {
|
|
2952
|
+
void emit('video:unsupported', { uid: String(uid), message: 'MediaRecorder is not available' });
|
|
2953
|
+
} else {
|
|
2954
|
+
const stream = new MediaStream([mediaTrack.clone()]);
|
|
2955
|
+
const mimeTypes = [
|
|
2956
|
+
'video/webm;codecs=vp9',
|
|
2957
|
+
'video/webm;codecs=vp8',
|
|
2958
|
+
'video/webm',
|
|
2959
|
+
];
|
|
2960
|
+
const mimeType = mimeTypes.find((candidate) => MediaRecorder.isTypeSupported(candidate)) || '';
|
|
2961
|
+
try {
|
|
2962
|
+
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
|
|
2963
|
+
recorder.ondataavailable = (event) => {
|
|
2964
|
+
if (!event.data || event.data.size === 0) return;
|
|
2965
|
+
void fetch(base + '/video?uid=' + encodeURIComponent(String(uid)), {
|
|
2966
|
+
method: 'POST',
|
|
2967
|
+
body: event.data,
|
|
2968
|
+
}).catch(() => undefined);
|
|
2969
|
+
};
|
|
2970
|
+
recorder.onerror = (event) => {
|
|
2971
|
+
void emit('video:error', { uid: String(uid), message: String(event.error?.message || event.type) });
|
|
2972
|
+
};
|
|
2973
|
+
recorder.start(1000);
|
|
2974
|
+
videoRecorders.set(String(uid), { recorder, stream });
|
|
2975
|
+
void emit('video:recording-started', { uid: String(uid), detail: { mimeType: recorder.mimeType } });
|
|
2976
|
+
} catch (error) {
|
|
2977
|
+
stream.getTracks().forEach((item) => item.stop());
|
|
2978
|
+
void emit('video:unsupported', { uid: String(uid), message: error?.message || String(error) });
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
if (!config.options.recordScreen) return;
|
|
2983
|
+
const video = document.createElement('video');
|
|
2984
|
+
video.muted = true;
|
|
2985
|
+
video.autoplay = true;
|
|
2986
|
+
video.playsInline = true;
|
|
2987
|
+
video.srcObject = new MediaStream([mediaTrack]);
|
|
2988
|
+
screenRoot.append(video);
|
|
2989
|
+
await video.play().catch(() => undefined);
|
|
2990
|
+
const canvas = document.createElement('canvas');
|
|
2991
|
+
const context = canvas.getContext('2d');
|
|
2992
|
+
const timer = setInterval(() => {
|
|
2993
|
+
const width = video.videoWidth || 1280;
|
|
2994
|
+
const height = video.videoHeight || 720;
|
|
2995
|
+
canvas.width = width;
|
|
2996
|
+
canvas.height = height;
|
|
2997
|
+
context.drawImage(video, 0, 0, width, height);
|
|
2998
|
+
canvas.toBlob((blob) => {
|
|
2999
|
+
if (!blob) return;
|
|
3000
|
+
void fetch(base + '/screen?uid=' + encodeURIComponent(String(uid)), {
|
|
3001
|
+
method: 'POST',
|
|
3002
|
+
body: blob,
|
|
3003
|
+
}).catch(() => undefined);
|
|
3004
|
+
}, 'image/png');
|
|
3005
|
+
}, Math.max(250, config.options.screenIntervalMs || 1000));
|
|
3006
|
+
screenRecorders.set(String(uid), { video, timer });
|
|
3007
|
+
void emit('screen:recording-started', { uid: String(uid) });
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
function stopScreenRecorder(uid) {
|
|
3011
|
+
const recorder = screenRecorders.get(String(uid));
|
|
3012
|
+
if (recorder) {
|
|
3013
|
+
clearInterval(recorder.timer);
|
|
3014
|
+
recorder.video.remove();
|
|
3015
|
+
screenRecorders.delete(String(uid));
|
|
3016
|
+
void emit('screen:recording-stopped', { uid: String(uid) });
|
|
3017
|
+
}
|
|
3018
|
+
const videoRecorder = videoRecorders.get(String(uid));
|
|
3019
|
+
if (videoRecorder) {
|
|
3020
|
+
try {
|
|
3021
|
+
if (videoRecorder.recorder.state !== 'inactive') videoRecorder.recorder.stop();
|
|
3022
|
+
} catch {
|
|
3023
|
+
// Best effort shutdown; chunks already emitted are still kept.
|
|
3024
|
+
}
|
|
3025
|
+
videoRecorder.stream.getTracks().forEach((item) => item.stop());
|
|
3026
|
+
videoRecorders.delete(String(uid));
|
|
3027
|
+
void emit('video:recording-stopped', { uid: String(uid) });
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
async function publishInputFile() {
|
|
3032
|
+
const response = await fetch(base + '/input-file');
|
|
3033
|
+
const data = await response.arrayBuffer();
|
|
3034
|
+
const context = new AudioContext();
|
|
3035
|
+
const buffer = await context.decodeAudioData(data.slice(0));
|
|
3036
|
+
await publishAudioBuffer(context, buffer);
|
|
3037
|
+
void emit('input:file-published');
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
async function publishAudioBuffer(context, buffer) {
|
|
3041
|
+
const destination = context.createMediaStreamDestination();
|
|
3042
|
+
inputAudioTrack = AgoraRTC.createCustomAudioTrack({
|
|
3043
|
+
mediaStreamTrack: destination.stream.getAudioTracks()[0],
|
|
3044
|
+
encoderConfig: 'music_standard',
|
|
3045
|
+
});
|
|
3046
|
+
await client.publish([inputAudioTrack]);
|
|
3047
|
+
const source = context.createBufferSource();
|
|
3048
|
+
source.buffer = buffer;
|
|
3049
|
+
source.connect(destination);
|
|
3050
|
+
source.start();
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
async function publishStdinPcm() {
|
|
3054
|
+
const input = config.options.input;
|
|
3055
|
+
const context = new AudioContext({ sampleRate: input.sampleRate });
|
|
3056
|
+
const destination = context.createMediaStreamDestination();
|
|
3057
|
+
inputAudioTrack = AgoraRTC.createCustomAudioTrack({
|
|
3058
|
+
mediaStreamTrack: destination.stream.getAudioTracks()[0],
|
|
3059
|
+
encoderConfig: 'music_standard',
|
|
3060
|
+
});
|
|
3061
|
+
await client.publish([inputAudioTrack]);
|
|
3062
|
+
let cursor = 0;
|
|
3063
|
+
let playAt = context.currentTime + 0.2;
|
|
3064
|
+
void emit('input:stdin-pcm-published', { detail: { sampleRate: input.sampleRate, channels: input.channels } });
|
|
3065
|
+
while (true) {
|
|
3066
|
+
const response = await fetch(base + '/input-pcm?cursor=' + cursor);
|
|
3067
|
+
if (response.status === 204) {
|
|
3068
|
+
if (response.headers.get('x-input-ended') === 'true') break;
|
|
3069
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
3070
|
+
continue;
|
|
3071
|
+
}
|
|
3072
|
+
if (!response.ok) throw new Error('input-pcm failed: ' + response.status);
|
|
3073
|
+
cursor = Number(response.headers.get('x-next-cursor') || cursor + 1);
|
|
3074
|
+
const sampleRate = Number(response.headers.get('x-sample-rate') || input.sampleRate);
|
|
3075
|
+
const channels = Number(response.headers.get('x-channels') || input.channels);
|
|
3076
|
+
const pcm = new Int16Array(await response.arrayBuffer());
|
|
3077
|
+
const frames = Math.floor(pcm.length / channels);
|
|
3078
|
+
const buffer = context.createBuffer(1, frames, sampleRate);
|
|
3079
|
+
const channel = buffer.getChannelData(0);
|
|
3080
|
+
for (let frame = 0; frame < frames; frame += 1) {
|
|
3081
|
+
channel[frame] = pcm[frame * channels] / 32768;
|
|
3082
|
+
}
|
|
3083
|
+
const source = context.createBufferSource();
|
|
3084
|
+
source.buffer = buffer;
|
|
3085
|
+
source.connect(destination);
|
|
3086
|
+
playAt = Math.max(playAt, context.currentTime + 0.05);
|
|
3087
|
+
source.start(playAt);
|
|
3088
|
+
playAt += buffer.duration;
|
|
3089
|
+
}
|
|
3090
|
+
void emit('input:stdin-ended');
|
|
3091
|
+
}
|
|
3092
|
+
|
|
3093
|
+
client.on('user-published', async (user, mediaType) => {
|
|
3094
|
+
await client.subscribe(user, mediaType);
|
|
3095
|
+
if (mediaType === 'audio' && user.audioTrack) {
|
|
3096
|
+
startAudioRecorder(user.uid, user.audioTrack);
|
|
3097
|
+
void emit('remote-audio:subscribed', { uid: String(user.uid) });
|
|
3098
|
+
}
|
|
3099
|
+
if (mediaType === 'video' && user.videoTrack) {
|
|
3100
|
+
await startScreenRecorder(user.uid, user.videoTrack);
|
|
3101
|
+
void emit('remote-screen:subscribed', { uid: String(user.uid) });
|
|
3102
|
+
}
|
|
3103
|
+
});
|
|
3104
|
+
|
|
3105
|
+
client.on('token-privilege-will-expire', () => {
|
|
3106
|
+
void renewTokens().catch((error) => emit('token:error', { message: error.message }));
|
|
3107
|
+
});
|
|
3108
|
+
|
|
3109
|
+
client.on('token-privilege-did-expire', () => {
|
|
3110
|
+
void renewTokens().catch((error) => emit('token:error', { message: error.message }));
|
|
3111
|
+
});
|
|
3112
|
+
|
|
3113
|
+
client.on('user-unpublished', (user, mediaType) => {
|
|
3114
|
+
if (mediaType === 'audio') stopAudioRecorder(user.uid);
|
|
3115
|
+
if (mediaType === 'video') stopScreenRecorder(user.uid);
|
|
3116
|
+
void emit('remote-unpublished', { uid: String(user.uid), detail: { mediaType } });
|
|
3117
|
+
});
|
|
3118
|
+
|
|
3119
|
+
window.addEventListener('error', (event) => {
|
|
3120
|
+
void emit('browser:error', { message: event.message, detail: { filename: event.filename, lineno: event.lineno } });
|
|
3121
|
+
});
|
|
3122
|
+
window.addEventListener('unhandledrejection', (event) => {
|
|
3123
|
+
void emit('browser:unhandled-rejection', { message: String(event.reason?.message || event.reason) });
|
|
3124
|
+
});
|
|
3125
|
+
|
|
3126
|
+
window.__shadowVoiceBridgeStop = async () => {
|
|
3127
|
+
clearTokenRenewal();
|
|
3128
|
+
for (const uid of [...screenRecorders.keys(), ...videoRecorders.keys()]) {
|
|
3129
|
+
stopScreenRecorder(uid);
|
|
3130
|
+
}
|
|
3131
|
+
if (inputAudioTrack) {
|
|
3132
|
+
inputAudioTrack.stop?.();
|
|
3133
|
+
inputAudioTrack.close?.();
|
|
3134
|
+
inputAudioTrack = null;
|
|
3135
|
+
}
|
|
3136
|
+
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
3137
|
+
await client.leave().catch(() => undefined);
|
|
3138
|
+
void emit('bridge:page-stopped');
|
|
3139
|
+
};
|
|
3140
|
+
|
|
3141
|
+
try {
|
|
3142
|
+
setStatus('joining');
|
|
3143
|
+
await client.join(credentials.appId, credentials.agoraChannelName, credentials.token, credentials.uid);
|
|
3144
|
+
setStatus('joined');
|
|
3145
|
+
void emit('bridge:joined', { detail: { uid: credentials.uid, screenUid: credentials.screenUid } });
|
|
3146
|
+
scheduleTokenRenewal();
|
|
3147
|
+
if (config.options.input?.mode === 'file') await publishInputFile();
|
|
3148
|
+
if (config.options.input?.mode === 'stdin-pcm') void publishStdinPcm().catch((error) => emit('input:error', { message: error.message }));
|
|
3149
|
+
} catch (error) {
|
|
3150
|
+
setStatus('error');
|
|
3151
|
+
void emit('bridge:error', { message: error?.message || String(error) });
|
|
3152
|
+
}
|
|
3153
|
+
`;
|
|
3154
|
+
}
|
|
3155
|
+
function resolveAgoraBrowserScript(explicit) {
|
|
3156
|
+
if (explicit) {
|
|
3157
|
+
if (!existsSync(explicit)) throw new Error(`Agora Web SDK script not found: ${explicit}`);
|
|
3158
|
+
return explicit;
|
|
3159
|
+
}
|
|
3160
|
+
if (process.env.SHADOWOB_AGORA_WEB_SDK) {
|
|
3161
|
+
if (!existsSync(process.env.SHADOWOB_AGORA_WEB_SDK)) {
|
|
3162
|
+
throw new Error(`Agora Web SDK script not found: ${process.env.SHADOWOB_AGORA_WEB_SDK}`);
|
|
3163
|
+
}
|
|
3164
|
+
return process.env.SHADOWOB_AGORA_WEB_SDK;
|
|
3165
|
+
}
|
|
3166
|
+
const direct = tryRequireResolve("agora-rtc-sdk-ng/AgoraRTC_N-production.js");
|
|
3167
|
+
if (direct) return direct;
|
|
3168
|
+
const sdkEntry = tryRequireResolve("@shadowob/sdk");
|
|
3169
|
+
if (sdkEntry) {
|
|
3170
|
+
let current = dirname(sdkEntry);
|
|
3171
|
+
for (let depth = 0; depth < 6; depth += 1) {
|
|
3172
|
+
const candidate = join(
|
|
3173
|
+
current,
|
|
3174
|
+
"node_modules",
|
|
3175
|
+
"agora-rtc-sdk-ng",
|
|
3176
|
+
"AgoraRTC_N-production.js"
|
|
3177
|
+
);
|
|
3178
|
+
if (existsSync(candidate)) return candidate;
|
|
3179
|
+
current = dirname(current);
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
throw new Error(
|
|
3183
|
+
"Agora Web SDK is not available. Install agora-rtc-sdk-ng next to the CLI, or pass --agora-sdk / set SHADOWOB_AGORA_WEB_SDK to AgoraRTC_N-production.js."
|
|
3184
|
+
);
|
|
3185
|
+
}
|
|
3186
|
+
function tryRequireResolve(specifier) {
|
|
3187
|
+
try {
|
|
3188
|
+
return require2.resolve(specifier);
|
|
3189
|
+
} catch {
|
|
3190
|
+
return null;
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
async function installVoiceTestBrowser(options = {}) {
|
|
3194
|
+
const existing = await resolveVoiceTestBrowserPath();
|
|
3195
|
+
if (existing) return existing;
|
|
3196
|
+
const root = managedBrowserRoot();
|
|
3197
|
+
await mkdir(root, { recursive: true });
|
|
3198
|
+
const executable = process.platform === "win32" ? "npx.cmd" : "npx";
|
|
3199
|
+
const args = ["--yes", `playwright@${PLAYWRIGHT_VERSION}`, "install", "chromium"];
|
|
3200
|
+
const output2 = [];
|
|
3201
|
+
await new Promise((resolve, reject) => {
|
|
3202
|
+
const child = spawn(executable, args, {
|
|
3203
|
+
env: {
|
|
3204
|
+
...process.env,
|
|
3205
|
+
PLAYWRIGHT_BROWSERS_PATH: root
|
|
3206
|
+
},
|
|
3207
|
+
stdio: options.json ? ["ignore", "pipe", "pipe"] : "inherit"
|
|
3208
|
+
});
|
|
3209
|
+
if (options.json) {
|
|
3210
|
+
child.stdout?.on("data", (chunk) => output2.push(Buffer.from(chunk)));
|
|
3211
|
+
child.stderr?.on("data", (chunk) => output2.push(Buffer.from(chunk)));
|
|
3212
|
+
}
|
|
3213
|
+
child.on("error", reject);
|
|
3214
|
+
child.on("exit", (code) => {
|
|
3215
|
+
if (code === 0) resolve();
|
|
3216
|
+
else {
|
|
3217
|
+
const detail = Buffer.concat(output2).toString("utf8").trim();
|
|
3218
|
+
reject(
|
|
3219
|
+
new Error(
|
|
3220
|
+
`Failed to install test Chromium with npx playwright install chromium${detail ? `: ${detail}` : ""}`
|
|
3221
|
+
)
|
|
3222
|
+
);
|
|
3223
|
+
}
|
|
3224
|
+
});
|
|
3225
|
+
});
|
|
3226
|
+
const installed = await resolveVoiceTestBrowserPath();
|
|
3227
|
+
if (!installed)
|
|
3228
|
+
throw new Error(`Chromium was installed under ${root}, but no executable was found`);
|
|
3229
|
+
return installed;
|
|
3230
|
+
}
|
|
3231
|
+
async function resolveVoiceTestBrowserPath() {
|
|
3232
|
+
const root = managedBrowserRoot();
|
|
3233
|
+
if (!existsSync(root)) return null;
|
|
3234
|
+
return findExecutableUnder(root, managedBrowserExecutableNames());
|
|
3235
|
+
}
|
|
3236
|
+
function managedBrowserRoot() {
|
|
3237
|
+
return process.env.SHADOWOB_BROWSER_CACHE_DIR ? join(process.env.SHADOWOB_BROWSER_CACHE_DIR, "playwright") : join(homedir(), ".cache", "shadowob", "browsers", "playwright");
|
|
3238
|
+
}
|
|
3239
|
+
function managedBrowserExecutableNames() {
|
|
3240
|
+
if (process.platform === "darwin") {
|
|
3241
|
+
return ["Chromium.app/Contents/MacOS/Chromium", "Chrome.app/Contents/MacOS/Chrome"];
|
|
3242
|
+
}
|
|
3243
|
+
if (process.platform === "win32") return ["chrome.exe"];
|
|
3244
|
+
return ["chrome", "chromium"];
|
|
3245
|
+
}
|
|
3246
|
+
async function findExecutableUnder(root, names) {
|
|
3247
|
+
const queue = [root];
|
|
3248
|
+
while (queue.length > 0) {
|
|
3249
|
+
const current = queue.shift();
|
|
3250
|
+
let entries;
|
|
3251
|
+
try {
|
|
3252
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
3253
|
+
} catch {
|
|
3254
|
+
continue;
|
|
3255
|
+
}
|
|
3256
|
+
for (const entry of entries) {
|
|
3257
|
+
const full = join(current, entry.name);
|
|
3258
|
+
if (entry.isDirectory()) {
|
|
3259
|
+
for (const name of names) {
|
|
3260
|
+
const candidate = join(full, name);
|
|
3261
|
+
if (existsSync(candidate)) return candidate;
|
|
3262
|
+
}
|
|
3263
|
+
queue.push(full);
|
|
3264
|
+
} else if (entry.isFile() && names.includes(entry.name)) {
|
|
3265
|
+
return full;
|
|
3266
|
+
}
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
3269
|
+
return null;
|
|
3270
|
+
}
|
|
3271
|
+
async function findBrowserExecutable(explicit, options = {}) {
|
|
3272
|
+
if (explicit) {
|
|
3273
|
+
if (!existsSync(explicit)) throw new Error(`Browser executable not found: ${explicit}`);
|
|
3274
|
+
return explicit;
|
|
3275
|
+
}
|
|
3276
|
+
if (process.env.SHADOWOB_BROWSER && existsSync(process.env.SHADOWOB_BROWSER)) {
|
|
3277
|
+
return process.env.SHADOWOB_BROWSER;
|
|
3278
|
+
}
|
|
3279
|
+
const managed = await resolveVoiceTestBrowserPath();
|
|
3280
|
+
if (managed) return managed;
|
|
3281
|
+
if (options.installBrowser) {
|
|
3282
|
+
return installVoiceTestBrowser({ json: options.json });
|
|
3283
|
+
}
|
|
3284
|
+
const candidates = process.platform === "darwin" ? [
|
|
3285
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
3286
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium"
|
|
3287
|
+
] : process.platform === "win32" ? [
|
|
3288
|
+
`${process.env.PROGRAMFILES ?? "C:\\Program Files"}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
3289
|
+
`${process.env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)"}\\Google\\Chrome\\Application\\chrome.exe`
|
|
3290
|
+
] : ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"];
|
|
3291
|
+
for (const candidate of candidates) {
|
|
3292
|
+
if (candidate.includes("/") || candidate.includes("\\")) {
|
|
3293
|
+
if (existsSync(candidate)) return candidate;
|
|
3294
|
+
continue;
|
|
3295
|
+
}
|
|
3296
|
+
try {
|
|
3297
|
+
return execFileSync2("which", [candidate], { encoding: "utf8" }).trim();
|
|
3298
|
+
} catch {
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3301
|
+
throw new Error(
|
|
3302
|
+
"No Chrome/Chromium executable found. Run shadowob voice browser install, pass --install-browser, or set SHADOWOB_BROWSER=/path/to/chrome."
|
|
3303
|
+
);
|
|
3304
|
+
}
|
|
3305
|
+
async function launchChrome(executable, url, options) {
|
|
3306
|
+
const port = await findFreePort();
|
|
3307
|
+
const userDataDir = await mkdtemp(join(tmpdir(), "shadowob-voice-bridge-"));
|
|
3308
|
+
const args = [
|
|
3309
|
+
`--remote-debugging-port=${port}`,
|
|
3310
|
+
"--remote-debugging-address=127.0.0.1",
|
|
3311
|
+
`--user-data-dir=${userDataDir}`,
|
|
3312
|
+
"--no-first-run",
|
|
3313
|
+
"--no-default-browser-check",
|
|
3314
|
+
"--disable-background-networking",
|
|
3315
|
+
"--autoplay-policy=no-user-gesture-required",
|
|
3316
|
+
"--disable-background-timer-throttling",
|
|
3317
|
+
"--disable-renderer-backgrounding",
|
|
3318
|
+
"--disable-sync",
|
|
3319
|
+
"--disable-component-update",
|
|
3320
|
+
"--disable-breakpad",
|
|
3321
|
+
"--disable-gpu",
|
|
3322
|
+
"--password-store=basic",
|
|
3323
|
+
"--use-mock-keychain"
|
|
3324
|
+
];
|
|
3325
|
+
if (!options.headful) args.push("--headless=new");
|
|
3326
|
+
args.push(url);
|
|
3327
|
+
const child = spawn(executable, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
3328
|
+
child.port = port;
|
|
3329
|
+
child.userDataDir = userDataDir;
|
|
3330
|
+
let stderrBuffer = "";
|
|
3331
|
+
child.stderr?.on("data", (chunk) => {
|
|
3332
|
+
stderrBuffer += chunk.toString("utf8");
|
|
3333
|
+
const lines = stderrBuffer.split(/\r?\n/);
|
|
3334
|
+
stderrBuffer = lines.pop() ?? "";
|
|
3335
|
+
for (const line of lines) {
|
|
3336
|
+
if (/ERROR|ERR_|Exception/i.test(line) && !isIgnorableChromeStderr(line)) {
|
|
3337
|
+
process.stderr.write(`${line}
|
|
3338
|
+
`);
|
|
3339
|
+
}
|
|
3340
|
+
}
|
|
3341
|
+
});
|
|
3342
|
+
child.on("exit", (code) => {
|
|
3343
|
+
if (code !== 0 && code !== null) {
|
|
3344
|
+
process.stderr.write(`Chrome exited with code ${code}
|
|
3345
|
+
`);
|
|
3346
|
+
}
|
|
3347
|
+
});
|
|
3348
|
+
await waitForChrome(port);
|
|
3349
|
+
return child;
|
|
3350
|
+
}
|
|
3351
|
+
async function attachConsoleInspector(port, state) {
|
|
3352
|
+
try {
|
|
3353
|
+
const pages = await fetchJson(`http://127.0.0.1:${port}/json/list`);
|
|
3354
|
+
const page = pages.find((item) => item.type === "page" && item.url.startsWith(state.baseUrl));
|
|
3355
|
+
if (!page?.webSocketDebuggerUrl) return;
|
|
3356
|
+
const socket = new WebSocket(page.webSocketDebuggerUrl);
|
|
3357
|
+
const requestUrls = /* @__PURE__ */ new Map();
|
|
3358
|
+
let id = 0;
|
|
3359
|
+
socket.addEventListener("open", () => {
|
|
3360
|
+
for (const method of ["Runtime.enable", "Log.enable", "Network.enable"]) {
|
|
3361
|
+
socket.send(JSON.stringify({ id: ++id, method }));
|
|
3362
|
+
}
|
|
3363
|
+
});
|
|
3364
|
+
socket.addEventListener("message", (message) => {
|
|
3365
|
+
const payload = JSON.parse(String(message.data));
|
|
3366
|
+
if (payload.method === "Runtime.exceptionThrown") {
|
|
3367
|
+
emitBridgeEvent(state, {
|
|
3368
|
+
type: "browser:exception",
|
|
3369
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3370
|
+
detail: payload.params
|
|
3371
|
+
});
|
|
3372
|
+
}
|
|
3373
|
+
if (payload.method === "Log.entryAdded") {
|
|
3374
|
+
const entry = payload.params?.entry;
|
|
3375
|
+
if (entry?.level === "error" && !isIgnorableBrowserDiagnosticUrl(entry.url)) {
|
|
3376
|
+
emitBridgeEvent(state, {
|
|
3377
|
+
type: "browser:console-error",
|
|
3378
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3379
|
+
message: entry.text,
|
|
3380
|
+
detail: { url: entry.url }
|
|
3381
|
+
});
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
if (payload.method === "Network.requestWillBeSent") {
|
|
3385
|
+
const params = payload.params;
|
|
3386
|
+
if (params?.requestId && params.request?.url) {
|
|
3387
|
+
requestUrls.set(params.requestId, params.request.url);
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
3390
|
+
if (payload.method === "Network.loadingFailed") {
|
|
3391
|
+
const params = payload.params;
|
|
3392
|
+
const url = params?.requestId ? requestUrls.get(params.requestId) : void 0;
|
|
3393
|
+
if (params?.errorText && !params.canceled && !isIgnorableBrowserDiagnosticUrl(url)) {
|
|
3394
|
+
emitBridgeEvent(state, {
|
|
3395
|
+
type: "browser:network-failed",
|
|
3396
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3397
|
+
message: params.errorText,
|
|
3398
|
+
detail: url ? { url } : void 0
|
|
3399
|
+
});
|
|
3400
|
+
}
|
|
3401
|
+
}
|
|
3402
|
+
});
|
|
3403
|
+
} catch {
|
|
3404
|
+
}
|
|
3405
|
+
}
|
|
3406
|
+
function isIgnorableBrowserDiagnosticUrl(url) {
|
|
3407
|
+
if (!url) return false;
|
|
3408
|
+
return url.endsWith("/favicon.ico") || url.includes("statscollector") || url.includes("update.googleapis.com") || url.includes("clients4.google.com");
|
|
3409
|
+
}
|
|
3410
|
+
function isIgnorableChromeStderr(line) {
|
|
3411
|
+
return line.includes("ssl_client_socket_impl.cc") || line.includes("google_apis/gcm") || line.includes("video_capture_service_impl.cc") || line.includes("A BUNDLE group contains a codec collision") || line.includes("Inconsistent congestion control feedback types") || line.includes("task_policy_set TASK_CATEGORY_POLICY") || line.includes("task_policy_set TASK_SUPPRESSION_POLICY");
|
|
3412
|
+
}
|
|
3413
|
+
async function waitForChrome(port) {
|
|
3414
|
+
const deadline = Date.now() + 3e4;
|
|
3415
|
+
while (Date.now() < deadline) {
|
|
3416
|
+
try {
|
|
3417
|
+
await fetchJson(`http://127.0.0.1:${port}/json/version`);
|
|
3418
|
+
return;
|
|
3419
|
+
} catch {
|
|
3420
|
+
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
3421
|
+
}
|
|
3422
|
+
}
|
|
3423
|
+
throw new Error("Timed out waiting for Chrome remote debugging");
|
|
3424
|
+
}
|
|
3425
|
+
async function fetchJson(url) {
|
|
3426
|
+
const response = await fetch(url);
|
|
3427
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
3428
|
+
return response.json();
|
|
3429
|
+
}
|
|
3430
|
+
async function findFreePort() {
|
|
3431
|
+
const server = createServer();
|
|
3432
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
3433
|
+
const address = server.address();
|
|
3434
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
3435
|
+
if (!address || typeof address === "string") throw new Error("Failed to allocate port");
|
|
3436
|
+
return address.port;
|
|
3437
|
+
}
|
|
3438
|
+
function startReadingStdinPcm(state) {
|
|
3439
|
+
process.stdin.on("data", (chunk) => {
|
|
3440
|
+
const entry = { index: state.pcmNextIndex++, data: Buffer.from(chunk) };
|
|
3441
|
+
state.pcmChunks.push(entry);
|
|
3442
|
+
if (state.pcmChunks.length > 512) state.pcmChunks.shift();
|
|
3443
|
+
flushPendingPcmRequests(state);
|
|
3444
|
+
});
|
|
3445
|
+
process.stdin.on("end", () => {
|
|
3446
|
+
state.stdinEnded = true;
|
|
3447
|
+
flushPendingPcmRequests(state);
|
|
3448
|
+
});
|
|
3449
|
+
process.stdin.resume();
|
|
3450
|
+
}
|
|
3451
|
+
function handleInputPcmRequest(state, cursor, res) {
|
|
3452
|
+
const next = state.pcmChunks.find((chunk) => chunk.index >= cursor);
|
|
3453
|
+
if (next) {
|
|
3454
|
+
sendPcmChunk(state, res, next);
|
|
3455
|
+
return;
|
|
3456
|
+
}
|
|
3457
|
+
if (state.stdinEnded) {
|
|
3458
|
+
res.writeHead(204, { "x-input-ended": "true" });
|
|
3459
|
+
res.end();
|
|
3460
|
+
return;
|
|
3461
|
+
}
|
|
3462
|
+
const pending = {
|
|
3463
|
+
cursor,
|
|
3464
|
+
res,
|
|
3465
|
+
timer: setTimeout(() => {
|
|
3466
|
+
state.pendingPcmRequests.delete(pending);
|
|
3467
|
+
if (!res.writableEnded) {
|
|
3468
|
+
res.writeHead(204);
|
|
3469
|
+
res.end();
|
|
3470
|
+
}
|
|
3471
|
+
}, 15e3)
|
|
3472
|
+
};
|
|
3473
|
+
state.pendingPcmRequests.add(pending);
|
|
3474
|
+
}
|
|
3475
|
+
function flushPendingPcmRequests(state) {
|
|
3476
|
+
for (const pending of [...state.pendingPcmRequests]) {
|
|
3477
|
+
const next = state.pcmChunks.find((chunk) => chunk.index >= pending.cursor);
|
|
3478
|
+
if (!next && !state.stdinEnded) continue;
|
|
3479
|
+
clearTimeout(pending.timer);
|
|
3480
|
+
state.pendingPcmRequests.delete(pending);
|
|
3481
|
+
if (pending.res.writableEnded) continue;
|
|
3482
|
+
if (next) sendPcmChunk(state, pending.res, next);
|
|
3483
|
+
else {
|
|
3484
|
+
pending.res.writeHead(204, { "x-input-ended": "true" });
|
|
3485
|
+
pending.res.end();
|
|
3486
|
+
}
|
|
3487
|
+
}
|
|
3488
|
+
}
|
|
3489
|
+
function sendPcmChunk(state, res, chunk) {
|
|
3490
|
+
res.writeHead(200, {
|
|
3491
|
+
"content-type": "application/octet-stream",
|
|
3492
|
+
"x-next-cursor": String(chunk.index + 1),
|
|
3493
|
+
"x-sample-rate": String(state.options.stdinSampleRate),
|
|
3494
|
+
"x-channels": String(state.options.stdinChannels)
|
|
3495
|
+
});
|
|
3496
|
+
res.end(chunk.data);
|
|
3497
|
+
}
|
|
3498
|
+
async function writeAudioChunk(state, uid, chunk, sampleRate, channels) {
|
|
3499
|
+
if (!state.options.audioOutDir || chunk.length === 0) return;
|
|
3500
|
+
await mkdir(state.options.audioOutDir, { recursive: true });
|
|
3501
|
+
let sink = state.audioSinks.get(uid);
|
|
3502
|
+
if (!sink) {
|
|
3503
|
+
const path = join(state.options.audioOutDir, `${uid}-${Date.now()}.wav`);
|
|
3504
|
+
const stream = createWriteStream(path);
|
|
3505
|
+
stream.write(wavHeader(sampleRate, channels, 0));
|
|
3506
|
+
sink = { stream, path, sampleRate, channels, bytes: 0 };
|
|
3507
|
+
state.audioSinks.set(uid, sink);
|
|
3508
|
+
emitBridgeEvent(state, {
|
|
3509
|
+
type: "audio:file-started",
|
|
3510
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3511
|
+
uid,
|
|
3512
|
+
path
|
|
3513
|
+
});
|
|
3514
|
+
}
|
|
3515
|
+
sink.stream.write(chunk);
|
|
3516
|
+
sink.bytes += chunk.length;
|
|
3517
|
+
}
|
|
3518
|
+
async function writeScreenFrame(state, uid, chunk) {
|
|
3519
|
+
if (!state.options.screenOutDir || chunk.length === 0) return;
|
|
3520
|
+
await mkdir(state.options.screenOutDir, { recursive: true });
|
|
3521
|
+
const seq = (state.screenSeq.get(uid) ?? 0) + 1;
|
|
3522
|
+
state.screenSeq.set(uid, seq);
|
|
3523
|
+
const path = join(state.options.screenOutDir, `${uid}-${String(seq).padStart(6, "0")}.png`);
|
|
3524
|
+
await writeFile2(path, chunk);
|
|
3525
|
+
emitBridgeEvent(state, {
|
|
3526
|
+
type: "screen:frame",
|
|
3527
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3528
|
+
uid,
|
|
3529
|
+
path
|
|
3530
|
+
});
|
|
3531
|
+
}
|
|
3532
|
+
async function writeVideoChunk(state, uid, chunk) {
|
|
3533
|
+
if (!state.options.videoOutDir || chunk.length === 0) return;
|
|
3534
|
+
await mkdir(state.options.videoOutDir, { recursive: true });
|
|
3535
|
+
let sink = state.videoSinks.get(uid);
|
|
3536
|
+
if (!sink) {
|
|
3537
|
+
const path = join(state.options.videoOutDir, `${uid}-${Date.now()}.webm`);
|
|
3538
|
+
const stream = createWriteStream(path);
|
|
3539
|
+
sink = { stream, path, bytes: 0 };
|
|
3540
|
+
state.videoSinks.set(uid, sink);
|
|
3541
|
+
emitBridgeEvent(state, {
|
|
3542
|
+
type: "video:file-started",
|
|
3543
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3544
|
+
uid,
|
|
3545
|
+
path
|
|
3546
|
+
});
|
|
3547
|
+
}
|
|
3548
|
+
sink.stream.write(chunk);
|
|
3549
|
+
sink.bytes += chunk.length;
|
|
3550
|
+
}
|
|
3551
|
+
async function closeAudioSinks(state) {
|
|
3552
|
+
await Promise.all(
|
|
3553
|
+
[...state.audioSinks.values()].map(
|
|
3554
|
+
(sink) => new Promise((resolve) => {
|
|
3555
|
+
sink.stream.end(async () => {
|
|
3556
|
+
const file = await open(sink.path, "r+");
|
|
3557
|
+
try {
|
|
3558
|
+
await file.write(wavHeader(sink.sampleRate, sink.channels, sink.bytes), 0, 44, 0);
|
|
3559
|
+
} finally {
|
|
3560
|
+
await file.close();
|
|
3561
|
+
}
|
|
3562
|
+
emitBridgeEvent(state, {
|
|
3563
|
+
type: "audio:file-finished",
|
|
3564
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3565
|
+
path: sink.path,
|
|
3566
|
+
detail: { bytes: sink.bytes }
|
|
3567
|
+
});
|
|
3568
|
+
resolve();
|
|
3569
|
+
});
|
|
3570
|
+
})
|
|
3571
|
+
)
|
|
3572
|
+
);
|
|
3573
|
+
state.audioSinks.clear();
|
|
3574
|
+
}
|
|
3575
|
+
async function closeVideoSinks(state) {
|
|
3576
|
+
await Promise.all(
|
|
3577
|
+
[...state.videoSinks.values()].map(
|
|
3578
|
+
(sink) => new Promise((resolve) => {
|
|
3579
|
+
sink.stream.end(() => {
|
|
3580
|
+
emitBridgeEvent(state, {
|
|
3581
|
+
type: "video:file-finished",
|
|
3582
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3583
|
+
path: sink.path,
|
|
3584
|
+
detail: { bytes: sink.bytes }
|
|
3585
|
+
});
|
|
3586
|
+
resolve();
|
|
3587
|
+
});
|
|
3588
|
+
})
|
|
3589
|
+
)
|
|
3590
|
+
);
|
|
3591
|
+
state.videoSinks.clear();
|
|
3592
|
+
}
|
|
3593
|
+
function wavHeader(sampleRate, channels, dataBytes) {
|
|
3594
|
+
const header = Buffer.alloc(44);
|
|
3595
|
+
const byteRate = sampleRate * channels * 2;
|
|
3596
|
+
header.write("RIFF", 0);
|
|
3597
|
+
header.writeUInt32LE(36 + dataBytes, 4);
|
|
3598
|
+
header.write("WAVE", 8);
|
|
3599
|
+
header.write("fmt ", 12);
|
|
3600
|
+
header.writeUInt32LE(16, 16);
|
|
3601
|
+
header.writeUInt16LE(1, 20);
|
|
3602
|
+
header.writeUInt16LE(channels, 22);
|
|
3603
|
+
header.writeUInt32LE(sampleRate, 24);
|
|
3604
|
+
header.writeUInt32LE(byteRate, 28);
|
|
3605
|
+
header.writeUInt16LE(channels * 2, 32);
|
|
3606
|
+
header.writeUInt16LE(16, 34);
|
|
3607
|
+
header.write("data", 36);
|
|
3608
|
+
header.writeUInt32LE(dataBytes, 40);
|
|
3609
|
+
return header;
|
|
3610
|
+
}
|
|
3611
|
+
async function shutdownBridge(state, server, chrome, options) {
|
|
3612
|
+
if (state.shuttingDown) return;
|
|
3613
|
+
state.shuttingDown = true;
|
|
3614
|
+
if (chrome) await stopBridgePage(chrome.port, state).catch(() => void 0);
|
|
3615
|
+
await options.client.leaveVoiceChannel(options.channelId, { clientId: state.clientId }).catch(() => void 0);
|
|
3616
|
+
await closeAudioSinks(state).catch(() => void 0);
|
|
3617
|
+
await closeVideoSinks(state).catch(() => void 0);
|
|
3618
|
+
for (const pending of state.pendingPcmRequests) {
|
|
3619
|
+
clearTimeout(pending.timer);
|
|
3620
|
+
if (!pending.res.writableEnded) {
|
|
3621
|
+
pending.res.writeHead(204, { "x-input-ended": "true" });
|
|
3622
|
+
pending.res.end();
|
|
3623
|
+
}
|
|
3624
|
+
}
|
|
3625
|
+
state.pendingPcmRequests.clear();
|
|
3626
|
+
if (server) await new Promise((resolve) => server.close(() => resolve()));
|
|
3627
|
+
if (chrome && !options.keepBrowser) {
|
|
3628
|
+
chrome.kill("SIGTERM");
|
|
3629
|
+
if (chrome.userDataDir)
|
|
3630
|
+
await rm(chrome.userDataDir, { recursive: true, force: true }).catch(() => void 0);
|
|
3631
|
+
}
|
|
3632
|
+
}
|
|
3633
|
+
async function stopBridgePage(port, state) {
|
|
3634
|
+
const pages = await fetchJson(`http://127.0.0.1:${port}/json/list`);
|
|
3635
|
+
const page = pages.find((item) => item.type === "page" && item.url.startsWith(state.baseUrl));
|
|
3636
|
+
if (!page?.webSocketDebuggerUrl) return;
|
|
3637
|
+
const debuggerUrl = page.webSocketDebuggerUrl;
|
|
3638
|
+
await new Promise((resolve) => {
|
|
3639
|
+
const socket = new WebSocket(debuggerUrl);
|
|
3640
|
+
const timer = setTimeout(() => {
|
|
3641
|
+
socket.close();
|
|
3642
|
+
resolve();
|
|
3643
|
+
}, 3e3);
|
|
3644
|
+
const finish = () => {
|
|
3645
|
+
clearTimeout(timer);
|
|
3646
|
+
socket.close();
|
|
3647
|
+
resolve();
|
|
3648
|
+
};
|
|
3649
|
+
socket.addEventListener("open", () => {
|
|
3650
|
+
socket.send(
|
|
3651
|
+
JSON.stringify({
|
|
3652
|
+
id: 1,
|
|
3653
|
+
method: "Runtime.evaluate",
|
|
3654
|
+
params: {
|
|
3655
|
+
expression: "window.__shadowVoiceBridgeStop?.()",
|
|
3656
|
+
awaitPromise: true,
|
|
3657
|
+
returnByValue: true
|
|
3658
|
+
}
|
|
3659
|
+
})
|
|
3660
|
+
);
|
|
3661
|
+
});
|
|
3662
|
+
socket.addEventListener("message", (message) => {
|
|
3663
|
+
const payload = JSON.parse(String(message.data));
|
|
3664
|
+
if (payload.id === 1) finish();
|
|
3665
|
+
});
|
|
3666
|
+
socket.addEventListener("error", finish);
|
|
3667
|
+
socket.addEventListener("close", finish);
|
|
3668
|
+
});
|
|
3669
|
+
}
|
|
3670
|
+
async function waitForBridgeStop(durationSeconds, chrome) {
|
|
3671
|
+
await new Promise((resolve) => {
|
|
3672
|
+
let timer;
|
|
3673
|
+
const done = () => {
|
|
3674
|
+
if (timer) clearTimeout(timer);
|
|
3675
|
+
process.off("SIGINT", done);
|
|
3676
|
+
process.off("SIGTERM", done);
|
|
3677
|
+
chrome.off("exit", done);
|
|
3678
|
+
resolve();
|
|
3679
|
+
};
|
|
3680
|
+
if (durationSeconds && durationSeconds > 0) {
|
|
3681
|
+
timer = setTimeout(done, durationSeconds * 1e3);
|
|
3682
|
+
}
|
|
3683
|
+
process.once("SIGINT", done);
|
|
3684
|
+
process.once("SIGTERM", done);
|
|
3685
|
+
chrome.once("exit", done);
|
|
3686
|
+
});
|
|
3687
|
+
}
|
|
3688
|
+
function emitBridgeEvent(state, event) {
|
|
3689
|
+
if (state.options.json) {
|
|
3690
|
+
console.log(JSON.stringify(event));
|
|
3691
|
+
return;
|
|
3692
|
+
}
|
|
3693
|
+
const suffix = event.path ? ` ${event.path}` : event.message ? ` ${event.message}` : "";
|
|
3694
|
+
console.log(`[${event.timestamp}] ${event.type}${event.uid ? ` uid=${event.uid}` : ""}${suffix}`);
|
|
3695
|
+
}
|
|
3696
|
+
function readRequestBody(req, maxBytes) {
|
|
3697
|
+
return new Promise((resolve, reject) => {
|
|
3698
|
+
const chunks = [];
|
|
3699
|
+
let total = 0;
|
|
3700
|
+
req.on("data", (chunk) => {
|
|
3701
|
+
total += chunk.length;
|
|
3702
|
+
if (total > maxBytes) {
|
|
3703
|
+
reject(new Error("Request body is too large"));
|
|
3704
|
+
req.destroy();
|
|
3705
|
+
return;
|
|
3706
|
+
}
|
|
3707
|
+
chunks.push(chunk);
|
|
3708
|
+
});
|
|
3709
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
3710
|
+
req.on("error", reject);
|
|
3711
|
+
});
|
|
3712
|
+
}
|
|
3713
|
+
function sendJson(res, data, status = 200) {
|
|
3714
|
+
sendBuffer(res, Buffer.from(JSON.stringify(data)), "application/json", status);
|
|
3715
|
+
}
|
|
3716
|
+
function sendHtml(res, html) {
|
|
3717
|
+
sendBuffer(res, Buffer.from(html), "text/html; charset=utf-8");
|
|
3718
|
+
}
|
|
3719
|
+
function sendJavaScript(res, js) {
|
|
3720
|
+
sendBuffer(res, Buffer.from(js), "application/javascript; charset=utf-8");
|
|
3721
|
+
}
|
|
3722
|
+
function sendText(res, status, text) {
|
|
3723
|
+
sendBuffer(res, Buffer.from(text), "text/plain; charset=utf-8", status);
|
|
3724
|
+
}
|
|
3725
|
+
function sendBuffer(res, body, contentType, status = 200) {
|
|
3726
|
+
res.writeHead(status, {
|
|
3727
|
+
"content-type": contentType,
|
|
3728
|
+
"content-length": body.length,
|
|
3729
|
+
"cache-control": "no-store"
|
|
3730
|
+
});
|
|
3731
|
+
res.end(body);
|
|
3732
|
+
}
|
|
3733
|
+
function sanitizeSegment(value) {
|
|
3734
|
+
return value.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 80) || "unknown";
|
|
3735
|
+
}
|
|
3736
|
+
function mediaTypeForPath(path) {
|
|
3737
|
+
const name = basename2(path).toLowerCase();
|
|
3738
|
+
if (name.endsWith(".wav")) return "audio/wav";
|
|
3739
|
+
if (name.endsWith(".mp3")) return "audio/mpeg";
|
|
3740
|
+
if (name.endsWith(".ogg")) return "audio/ogg";
|
|
3741
|
+
return "application/octet-stream";
|
|
3742
|
+
}
|
|
3743
|
+
async function validateVoiceBridgeOptions(options) {
|
|
3744
|
+
if (options.inputFile) {
|
|
3745
|
+
const info = await stat(options.inputFile);
|
|
3746
|
+
if (!info.isFile()) throw new Error(`Input audio path is not a file: ${options.inputFile}`);
|
|
3747
|
+
}
|
|
3748
|
+
if (options.audioOutDir) await mkdir(options.audioOutDir, { recursive: true });
|
|
3749
|
+
if (options.videoOutDir) await mkdir(options.videoOutDir, { recursive: true });
|
|
3750
|
+
if (options.screenOutDir) await mkdir(options.screenOutDir, { recursive: true });
|
|
3751
|
+
if (!Number.isFinite(options.stdinSampleRate) || options.stdinSampleRate < 8e3) {
|
|
3752
|
+
throw new Error("--sample-rate must be at least 8000");
|
|
3753
|
+
}
|
|
3754
|
+
if (![1, 2].includes(options.stdinChannels)) {
|
|
3755
|
+
throw new Error("--channels must be 1 or 2");
|
|
3756
|
+
}
|
|
3757
|
+
}
|
|
3758
|
+
|
|
3759
|
+
// src/commands/voice.ts
|
|
3760
|
+
function resolveProfileOption(options, command) {
|
|
3761
|
+
return options.profile ?? command.optsWithGlobals().profile;
|
|
3762
|
+
}
|
|
3763
|
+
function createVoiceCommand() {
|
|
3764
|
+
const voice = new Command25("voice").description("Voice channel commands");
|
|
3765
|
+
voice.command("join").description("Join a voice channel and print Agora connection info").argument("<channel-id>", "Voice channel ID").option("--muted", "Join muted").option("--deafened", "Join deafened").option("--watch", "Keep the process attached and print voice events").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
3766
|
+
async (channelId, options, command) => {
|
|
3767
|
+
try {
|
|
3768
|
+
const profile = resolveProfileOption(options, command);
|
|
3769
|
+
const client = await getClient(profile);
|
|
3770
|
+
const clientId = options.watch ? `shadowob-cli-${Date.now()}-${Math.random().toString(36).slice(2)}` : "shadowob-cli";
|
|
3771
|
+
const result = await client.joinVoiceChannel(channelId, {
|
|
3772
|
+
muted: options.muted,
|
|
3773
|
+
deafened: options.deafened,
|
|
3774
|
+
clientId
|
|
3775
|
+
});
|
|
3776
|
+
output(result, { json: options.json });
|
|
3777
|
+
if (!options.watch) return;
|
|
3778
|
+
const socket = await getSocket(profile);
|
|
3779
|
+
socket.on("voice:participant-joined", (event) => output(event, { json: options.json }));
|
|
3780
|
+
socket.on("voice:participant-left", (event) => output(event, { json: options.json }));
|
|
3781
|
+
socket.on("voice:participant-updated", (event) => output(event, { json: options.json }));
|
|
3782
|
+
socket.connect();
|
|
3783
|
+
await socket.waitForConnect();
|
|
3784
|
+
await socket.joinVoiceChannel(channelId, {
|
|
3785
|
+
muted: options.muted,
|
|
3786
|
+
deafened: options.deafened,
|
|
3787
|
+
clientId
|
|
3788
|
+
});
|
|
3789
|
+
process.on("SIGINT", () => {
|
|
3790
|
+
void client.leaveVoiceChannel(channelId, { clientId }).finally(() => process.exit(0));
|
|
3791
|
+
});
|
|
3792
|
+
socket.raw.on("disconnect", () => process.exit(0));
|
|
3793
|
+
} catch (error) {
|
|
3794
|
+
outputError(error instanceof Error ? error.message : String(error), {
|
|
3795
|
+
json: options.json
|
|
3796
|
+
});
|
|
3797
|
+
process.exit(1);
|
|
3798
|
+
}
|
|
3799
|
+
}
|
|
3800
|
+
);
|
|
3801
|
+
voice.command("leave").description("Leave a voice channel").argument("<channel-id>", "Voice channel ID").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
3802
|
+
async (channelId, options, command) => {
|
|
3803
|
+
try {
|
|
3804
|
+
const client = await getClient(resolveProfileOption(options, command));
|
|
3805
|
+
await client.leaveVoiceChannel(channelId);
|
|
3806
|
+
outputSuccess("Left voice channel", { json: options.json });
|
|
3807
|
+
} catch (error) {
|
|
3808
|
+
outputError(error instanceof Error ? error.message : String(error), {
|
|
3809
|
+
json: options.json
|
|
3810
|
+
});
|
|
3811
|
+
process.exit(1);
|
|
3812
|
+
}
|
|
3813
|
+
}
|
|
3814
|
+
);
|
|
3815
|
+
voice.command("status").description("Show voice channel state").argument("<channel-id>", "Voice channel ID").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
3816
|
+
async (channelId, options, command) => {
|
|
3817
|
+
try {
|
|
3818
|
+
const client = await getClient(resolveProfileOption(options, command));
|
|
3819
|
+
output(await client.getVoiceState(channelId), { json: options.json });
|
|
3820
|
+
} catch (error) {
|
|
3821
|
+
outputError(error instanceof Error ? error.message : String(error), {
|
|
3822
|
+
json: options.json
|
|
3823
|
+
});
|
|
3824
|
+
process.exit(1);
|
|
3825
|
+
}
|
|
3826
|
+
}
|
|
3827
|
+
);
|
|
3828
|
+
voice.command("mute").description("Set local voice mute state").argument("<channel-id>", "Voice channel ID").option("--off", "Unmute instead of mute").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(
|
|
3829
|
+
async (channelId, options, command) => {
|
|
3830
|
+
try {
|
|
3831
|
+
const client = await getClient(resolveProfileOption(options, command));
|
|
3832
|
+
output(await client.updateVoiceState(channelId, { muted: !options.off }), {
|
|
3833
|
+
json: options.json
|
|
3834
|
+
});
|
|
3835
|
+
} catch (error) {
|
|
3836
|
+
outputError(error instanceof Error ? error.message : String(error), {
|
|
3837
|
+
json: options.json
|
|
3838
|
+
});
|
|
3839
|
+
process.exit(1);
|
|
3840
|
+
}
|
|
3841
|
+
}
|
|
3842
|
+
);
|
|
3843
|
+
voice.command("bridge").description(
|
|
3844
|
+
"Join a voice channel through a local Chrome/Chromium media bridge for audio and screen data"
|
|
3845
|
+
).argument("<channel-id>", "Voice channel ID").option("--record-out <dir>", "Record a full media archive with audio WAV and video WebM files").option("--audio-out <dir>", "Record remote audio tracks as per-user WAV files").option("--video-out <dir>", "Record remote video or screen-share tracks as WebM files").option("--screen-out <dir>", "Record remote screen shares as PNG frame sequences").option(
|
|
3846
|
+
"--screen-interval-ms <ms>",
|
|
3847
|
+
"Screen-share capture interval in milliseconds",
|
|
3848
|
+
String(defaultScreenIntervalMs)
|
|
3849
|
+
).option("--input <file>", "Publish an audio file into the voice channel").option("--stdin-pcm", "Publish raw signed 16-bit little-endian PCM from stdin").option("--sample-rate <hz>", "Sample rate for --stdin-pcm", "24000").option("--channels <count>", "Channel count for --stdin-pcm", "1").option("--browser <path>", "Chrome/Chromium executable path, or set SHADOWOB_BROWSER").option("--install-browser", "Install an isolated test Chromium when no managed browser exists").option(
|
|
3850
|
+
"--agora-sdk <path>",
|
|
3851
|
+
"Agora Web SDK browser bundle path, or set SHADOWOB_AGORA_WEB_SDK"
|
|
3852
|
+
).option("--headful", "Run Chrome/Chromium with a visible window").option("--keep-browser", "Keep the browser profile open after the bridge exits").option("--duration <seconds>", "Run for a fixed duration, then leave the voice channel").option("--muted", "Join Shadow voice presence as muted").option("--profile <name>", "Profile to use").option("--json", "Output bridge events as JSON lines").action(
|
|
3853
|
+
async (channelId, options, command) => {
|
|
3854
|
+
try {
|
|
3855
|
+
if (options.input && options.stdinPcm) {
|
|
3856
|
+
throw new Error("Use either --input or --stdin-pcm, not both");
|
|
3857
|
+
}
|
|
3858
|
+
const screenIntervalMs = parsePositiveInt(
|
|
3859
|
+
options.screenIntervalMs,
|
|
3860
|
+
"--screen-interval-ms"
|
|
3861
|
+
);
|
|
3862
|
+
const stdinSampleRate = parsePositiveInt(options.sampleRate, "--sample-rate");
|
|
3863
|
+
const stdinChannels = parsePositiveInt(options.channels, "--channels");
|
|
3864
|
+
const durationSeconds = options.duration ? parsePositiveInt(options.duration, "--duration") : void 0;
|
|
3865
|
+
const audioOutDir = options.audioOut ?? (options.recordOut ? join2(options.recordOut, "audio") : void 0);
|
|
3866
|
+
const videoOutDir = options.videoOut ?? (options.recordOut ? join2(options.recordOut, "video") : void 0);
|
|
3867
|
+
await validateVoiceBridgeOptions({
|
|
3868
|
+
audioOutDir,
|
|
3869
|
+
videoOutDir,
|
|
3870
|
+
screenOutDir: options.screenOut,
|
|
3871
|
+
inputFile: options.input,
|
|
3872
|
+
stdinSampleRate,
|
|
3873
|
+
stdinChannels
|
|
3874
|
+
});
|
|
3875
|
+
const client = await getClient(resolveProfileOption(options, command));
|
|
3876
|
+
await runVoiceMediaBridge({
|
|
3877
|
+
client,
|
|
3878
|
+
channelId,
|
|
3879
|
+
muted: options.muted,
|
|
3880
|
+
browser: options.browser,
|
|
3881
|
+
installBrowser: options.installBrowser,
|
|
3882
|
+
agoraSdk: options.agoraSdk,
|
|
3883
|
+
headful: options.headful,
|
|
3884
|
+
keepBrowser: options.keepBrowser,
|
|
3885
|
+
durationSeconds,
|
|
3886
|
+
audioOutDir,
|
|
3887
|
+
videoOutDir,
|
|
3888
|
+
screenOutDir: options.screenOut,
|
|
3889
|
+
screenIntervalMs,
|
|
3890
|
+
inputFile: options.input,
|
|
3891
|
+
stdinPcm: options.stdinPcm,
|
|
3892
|
+
stdinSampleRate,
|
|
3893
|
+
stdinChannels,
|
|
3894
|
+
json: options.json
|
|
3895
|
+
});
|
|
3896
|
+
} catch (error) {
|
|
3897
|
+
outputError(error instanceof Error ? error.message : String(error), {
|
|
3898
|
+
json: options.json
|
|
3899
|
+
});
|
|
3900
|
+
process.exit(1);
|
|
3901
|
+
}
|
|
3902
|
+
}
|
|
3903
|
+
);
|
|
3904
|
+
const browser = new Command25("browser").description("Voice bridge browser runtime commands");
|
|
3905
|
+
browser.command("install").description("Install an isolated Chromium runtime for voice bridge tests").option("--json", "Output as JSON").action(async (options) => {
|
|
3906
|
+
try {
|
|
3907
|
+
const executable = await installVoiceTestBrowser({ json: options.json });
|
|
3908
|
+
output({ executable }, { json: options.json });
|
|
3909
|
+
} catch (error) {
|
|
3910
|
+
outputError(error instanceof Error ? error.message : String(error), {
|
|
3911
|
+
json: options.json
|
|
3912
|
+
});
|
|
3913
|
+
process.exit(1);
|
|
3914
|
+
}
|
|
3915
|
+
});
|
|
3916
|
+
browser.command("path").description("Show the installed voice bridge test browser path").option("--json", "Output as JSON").action(async (options) => {
|
|
3917
|
+
try {
|
|
3918
|
+
const executable = await resolveVoiceTestBrowserPath();
|
|
3919
|
+
if (!executable) {
|
|
3920
|
+
throw new Error("No managed voice bridge browser is installed");
|
|
3921
|
+
}
|
|
3922
|
+
output({ executable }, { json: options.json });
|
|
3923
|
+
} catch (error) {
|
|
3924
|
+
outputError(error instanceof Error ? error.message : String(error), {
|
|
3925
|
+
json: options.json
|
|
3926
|
+
});
|
|
3927
|
+
process.exit(1);
|
|
3928
|
+
}
|
|
3929
|
+
});
|
|
3930
|
+
voice.addCommand(browser);
|
|
3931
|
+
return voice;
|
|
3932
|
+
}
|
|
3933
|
+
|
|
2086
3934
|
// src/commands/voice-enhance.ts
|
|
2087
|
-
import { Command as
|
|
3935
|
+
import { Command as Command26 } from "commander";
|
|
2088
3936
|
function createVoiceEnhanceCommand() {
|
|
2089
|
-
const voice = new
|
|
3937
|
+
const voice = new Command26("voice-enhance").description("Voice enhancement commands");
|
|
2090
3938
|
voice.command("enhance").description("Enhance a voice transcript").requiredOption("--transcript <text>", "Transcript text to enhance").option("--language <lang>", "Language code (e.g. zh-CN, en-US)").option("--no-self-correction", "Disable self-correction").option("--no-list-formatting", "Disable list formatting").option("--no-filler-removal", "Disable filler word removal").option("--tone-adjustment", "Enable tone adjustment").option("--target-tone <tone>", "Target tone (formal, casual, professional)").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(async (options) => {
|
|
2091
3939
|
try {
|
|
2092
3940
|
const client = await getClient(options.profile);
|
|
@@ -2125,10 +3973,10 @@ function createVoiceEnhanceCommand() {
|
|
|
2125
3973
|
}
|
|
2126
3974
|
|
|
2127
3975
|
// src/commands/workspace.ts
|
|
2128
|
-
import { readFile as
|
|
2129
|
-
import { Command as
|
|
3976
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
3977
|
+
import { Command as Command27 } from "commander";
|
|
2130
3978
|
function createWorkspaceCommand() {
|
|
2131
|
-
const workspace = new
|
|
3979
|
+
const workspace = new Command27("workspace").description("Workspace file management commands");
|
|
2132
3980
|
workspace.command("get").description("Get workspace info").argument("<server-id>", "Server ID or slug").option("--profile <name>", "Profile to use").option("--json", "Output as JSON").action(async (serverId, options) => {
|
|
2133
3981
|
try {
|
|
2134
3982
|
const client = await getClient(options.profile);
|
|
@@ -2224,7 +4072,7 @@ function createWorkspaceCommand() {
|
|
|
2224
4072
|
async (serverId, options) => {
|
|
2225
4073
|
try {
|
|
2226
4074
|
const client = await getClient(options.profile);
|
|
2227
|
-
const content = await
|
|
4075
|
+
const content = await readFile3(options.file);
|
|
2228
4076
|
const blob = new Blob([content]);
|
|
2229
4077
|
const name = options.name ?? options.file.split("/").pop() ?? "upload";
|
|
2230
4078
|
const result = await client.uploadWorkspaceFile(serverId, blob, name, options.parentId);
|
|
@@ -2310,7 +4158,7 @@ function createWorkspaceCommand() {
|
|
|
2310
4158
|
}
|
|
2311
4159
|
|
|
2312
4160
|
// src/index.ts
|
|
2313
|
-
var program = new
|
|
4161
|
+
var program = new Command28();
|
|
2314
4162
|
program.name("shadowob").description("Shadow CLI \u2014 command-line interface for Shadow servers").version("0.1.0").configureHelp({
|
|
2315
4163
|
sortSubcommands: true
|
|
2316
4164
|
});
|
|
@@ -2325,6 +4173,7 @@ program.addCommand(createListenCommand());
|
|
|
2325
4173
|
program.addCommand(createDirectMessagesCommand());
|
|
2326
4174
|
program.addCommand(createWorkspaceCommand());
|
|
2327
4175
|
program.addCommand(createShopCommand());
|
|
4176
|
+
program.addCommand(createCommerceCommand());
|
|
2328
4177
|
program.addCommand(createNotificationsCommand());
|
|
2329
4178
|
program.addCommand(createFriendsCommand());
|
|
2330
4179
|
program.addCommand(createInvitesCommand());
|
|
@@ -2339,5 +4188,6 @@ program.addCommand(createCloudCommand());
|
|
|
2339
4188
|
program.addCommand(createApiTokensCommand());
|
|
2340
4189
|
program.addCommand(createDiscoverCommand());
|
|
2341
4190
|
program.addCommand(createProfileCommentsCommand());
|
|
4191
|
+
program.addCommand(createVoiceCommand());
|
|
2342
4192
|
program.addCommand(createVoiceEnhanceCommand());
|
|
2343
4193
|
program.parse();
|