@fluid-app/fluid-cli-channels 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,1125 @@
1
+ import prompts from "prompts";
2
+ import { NAV_HELP, assertInteractive, createDomainCommand, terminalPrompter } from "@fluid-app/fluid-cli";
3
+ import { z } from "zod";
4
+ import { spawn } from "node:child_process";
5
+ //#region src/channels.ts
6
+ var ChannelsError = class extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = "ChannelsError";
10
+ }
11
+ };
12
+ const CHANNELS = {
13
+ tiktok: {
14
+ id: "tiktok",
15
+ label: "TikTok Shop",
16
+ base: "/api/v202506/tiktok",
17
+ listings: "product_listings",
18
+ listing: "product_listing",
19
+ accountIdField: "shop_id",
20
+ accountNameField: "shop_name",
21
+ channelProductIdField: "tiktok_product_id",
22
+ reviewFields: [],
23
+ detailFields: [
24
+ "region",
25
+ "seller_type",
26
+ "tiktok_warehouse_id"
27
+ ]
28
+ },
29
+ meta: {
30
+ id: "meta",
31
+ label: "Meta (Facebook and Instagram)",
32
+ base: "/api/v202506/meta",
33
+ listings: "catalog_listings",
34
+ listing: "catalog_listing",
35
+ accountIdField: "catalog_id",
36
+ accountNameField: "catalog_name",
37
+ channelProductIdField: "product_retailer_id",
38
+ reviewFields: [
39
+ "marketplace_review_status",
40
+ "shops_review_status",
41
+ "whatsapp_review_status",
42
+ "instagram_review_status"
43
+ ],
44
+ detailFields: [
45
+ "meta_business_id",
46
+ "meta_page_id",
47
+ "meta_instagram_business_account_id",
48
+ "waba_id",
49
+ "phone_number_id"
50
+ ]
51
+ },
52
+ google: {
53
+ id: "google",
54
+ label: "Google Merchant Center",
55
+ base: "/api/v202506/google",
56
+ listings: "product_listings",
57
+ listing: "product_listing",
58
+ accountIdField: "merchant_center_id",
59
+ accountNameField: "merchant_center_name",
60
+ channelProductIdField: "item_group_id",
61
+ reviewFields: [
62
+ "google_review_status",
63
+ "content_language",
64
+ "feed_label"
65
+ ],
66
+ detailFields: ["google_mca_id"]
67
+ }
68
+ };
69
+ const CHANNEL_IDS = Object.keys(CHANNELS);
70
+ function channelOf(id) {
71
+ const channel = CHANNELS[id.trim().toLowerCase()];
72
+ if (!channel) throw new ChannelsError(`"${id}" isn't a channel this plugin connects. Use one of: ${CHANNEL_IDS.join(", ")}.`);
73
+ return channel;
74
+ }
75
+ const str = (value) => value === void 0 || value === null ? null : String(value);
76
+ function summarizeConnection(channel, wire) {
77
+ const details = {};
78
+ for (const field of channel.detailFields) details[field] = wire[field] ?? null;
79
+ return {
80
+ channel: channel.id,
81
+ connected: wire["status"] === "connected",
82
+ id: Number(wire["id"]),
83
+ status: String(wire["status"] ?? "unknown"),
84
+ account: {
85
+ id: str(wire[channel.accountIdField]),
86
+ name: str(wire[channel.accountNameField])
87
+ },
88
+ connectedAt: str(wire["connected_at"]),
89
+ disconnectedAt: str(wire["disconnected_at"]),
90
+ accessTokenExpiresAt: str(wire["access_token_expires_at"]),
91
+ lastError: wire["last_error"] ?? null,
92
+ details
93
+ };
94
+ }
95
+ function summarizeListing(channel, wire) {
96
+ const review = {};
97
+ for (const field of channel.reviewFields) review[field] = wire[field] ?? null;
98
+ return {
99
+ id: Number(wire["id"]),
100
+ productId: Number(wire["product_id"]),
101
+ status: String(wire["status"] ?? "unknown"),
102
+ auditStatus: str(wire["audit_status"]),
103
+ channelProductId: str(wire[channel.channelProductIdField]),
104
+ review,
105
+ lastPublishedAt: str(wire["last_published_at"]),
106
+ lastSyncedAt: str(wire["last_synced_at"]),
107
+ lastError: wire["last_error"] ?? null
108
+ };
109
+ }
110
+ /** A listing that's no longer on the channel: removed by you (`deleted`),
111
+ * or, on TikTok, deactivated by you. Adding its product again re-lists it. */
112
+ function isRemoved(listing) {
113
+ return listing.status === "deleted" || listing.status === "seller_deactivated";
114
+ }
115
+ /** A listing whose product is on the channel now, or has been sent to it. */
116
+ function isListed(listing) {
117
+ return !isRemoved(listing);
118
+ }
119
+ //#endregion
120
+ //#region src/schemas.ts
121
+ /** A connection as the API returns it. Channel-specific fields pass through. */
122
+ const connectionWire = z.looseObject({
123
+ id: z.number(),
124
+ status: z.string()
125
+ });
126
+ const connectionResponse = z.object({ connection: connectionWire });
127
+ const authorizeUrlResponse = z.object({ url: z.string().min(1) });
128
+ /** A listing as the API returns it. Channel-specific fields pass through. */
129
+ const listingWire = z.looseObject({
130
+ id: z.number(),
131
+ product_id: z.number(),
132
+ status: z.string(),
133
+ audit_status: z.string().nullish()
134
+ });
135
+ /** The cursor block of a listings page; the rows are read with listingsResponse. */
136
+ const listingsPageMeta = z.object({ meta: z.object({ pagination: z.object({ next_cursor: z.string().nullish() }).optional() }).optional() });
137
+ const listingResponse = (key) => z.object({ [key]: listingWire });
138
+ const listingsResponse = (key) => z.object({ [key]: z.array(listingWire) });
139
+ const productsPage = z.object({
140
+ products: z.array(z.looseObject({
141
+ id: z.number(),
142
+ title: z.string().nullish()
143
+ })),
144
+ meta: z.object({ pagination: z.object({ total_pages: z.number().nullish() }).optional() }).optional()
145
+ });
146
+ const collectionsPage = z.object({
147
+ collections: z.array(z.looseObject({
148
+ id: z.number(),
149
+ title: z.string().nullish()
150
+ })),
151
+ meta: z.object({ pagination: z.object({ next_cursor: z.string().nullish() }).optional() }).optional()
152
+ });
153
+ const productIdsInput = z.object({ product_ids: z.array(z.number().int().positive()).min(1) });
154
+ const warehouseInput = z.object({ tiktok_warehouse_id: z.string().trim().min(1) });
155
+ const regionInput = z.object({ region: z.enum(["US", "GLOBAL"]) });
156
+ /** Validate a request body before it's sent, naming the bad input. */
157
+ function validateInput(schema, data, what) {
158
+ const result = schema.safeParse(data);
159
+ if (result.success) return result.data;
160
+ throw new ChannelsError(`Invalid ${what}: ${result.error.issues[0]?.message ?? "invalid value"}.`);
161
+ }
162
+ /** Validate a response against a schema, naming what was being read on failure. */
163
+ function parse(schema, data, what) {
164
+ const result = schema.safeParse(data);
165
+ if (result.success) return result.data;
166
+ const issue = result.error.issues[0];
167
+ throw new ChannelsError(`Fluid returned an unexpected ${what}${issue && issue.path.length > 0 ? ` at ${issue.path.join(".")}` : ""}: ${issue?.message ?? "invalid shape"}.`);
168
+ }
169
+ //#endregion
170
+ //#region src/api.ts
171
+ const LISTING_PAGE_SIZE = 100;
172
+ const MAX_LISTING_PAGES = 100;
173
+ const PRODUCT_PAGE_SIZE = 100;
174
+ const MAX_PRODUCT_PAGES = 100;
175
+ function statusOf(err) {
176
+ const status = err?.status;
177
+ return typeof status === "number" ? status : void 0;
178
+ }
179
+ /** The channel's connection, or a not-connected marker when the company has
180
+ * none (the API answers 404). */
181
+ async function getConnection(client, channel) {
182
+ try {
183
+ return summarizeConnection(channel, parse(connectionResponse, await client.get(`${channel.base}/connection`), `${channel.label} connection`).connection);
184
+ } catch (err) {
185
+ if (statusOf(err) === 404) return {
186
+ channel: channel.id,
187
+ connected: false,
188
+ status: "not_connected"
189
+ };
190
+ throw err;
191
+ }
192
+ }
193
+ /** Requires a connection; listings and disconnect have nothing to act on
194
+ * without one. */
195
+ async function requireConnection(client, channel) {
196
+ const connection = await getConnection(client, channel);
197
+ if (!("id" in connection)) throw new ChannelsError(`This company has no ${channel.label} connection. Run fluid channels connect ${channel.id} first.`);
198
+ return connection;
199
+ }
200
+ /** Ask Fluid for the channel's sign-in URL. A person opens it, approves
201
+ * Fluid's access on the channel, and the channel sends them back to Fluid,
202
+ * which saves the connection. */
203
+ async function authorizeUrl(client, channel, options = {}) {
204
+ const body = channel.id === "tiktok" ? validateInput(regionInput, { region: options.region ?? "US" }, "--region") : {};
205
+ return parse(authorizeUrlResponse, await client.post(`${channel.base}/connection/authorize_url`, body), "sign-in URL response").url;
206
+ }
207
+ async function disconnect(client, channel) {
208
+ return summarizeConnection(channel, parse(connectionResponse, await client.delete(`${channel.base}/connection`), "disconnect response").connection);
209
+ }
210
+ async function setTiktokWarehouse(client, channel, tiktokWarehouseId) {
211
+ const body = validateInput(warehouseInput, { tiktok_warehouse_id: tiktokWarehouseId }, "TikTok warehouse ID");
212
+ return summarizeConnection(channel, parse(connectionResponse, await client.patch(`${channel.base}/connection`, body), "warehouse update response").connection);
213
+ }
214
+ /** Every listing on the connection, newest first. */
215
+ async function listListings(client, channel) {
216
+ const all = [];
217
+ let cursor;
218
+ for (let page = 0; page < MAX_LISTING_PAGES; page += 1) {
219
+ const raw = await client.get(`${channel.base}/${channel.listings}`, { page: {
220
+ limit: LISTING_PAGE_SIZE,
221
+ ...cursor ? { cursor } : {}
222
+ } });
223
+ const what = `${channel.label} listings page`;
224
+ const rows = parse(listingsResponse(channel.listings), raw, what)[channel.listings];
225
+ all.push(...rows.map((row) => summarizeListing(channel, row)));
226
+ const next = parse(listingsPageMeta, raw, what).meta?.pagination?.next_cursor;
227
+ if (!next || rows.length === 0) return all;
228
+ cursor = next;
229
+ }
230
+ throw new ChannelsError(`This connection has more than ${MAX_LISTING_PAGES * LISTING_PAGE_SIZE} listings; refusing to act on a partial list.`);
231
+ }
232
+ /** The company's active products, optionally only those in the given
233
+ * collections. Draft and archived products are never sent to a channel. */
234
+ async function listActiveProducts(client, collectionIds = []) {
235
+ const byId = /* @__PURE__ */ new Map();
236
+ for (let page = 1; page <= MAX_PRODUCT_PAGES; page += 1) {
237
+ const data = parse(productsPage, await client.get("/api/v2025-06/products", {
238
+ page,
239
+ per_page: PRODUCT_PAGE_SIZE,
240
+ status: ["active"],
241
+ ...collectionIds.length > 0 ? { collection_ids: collectionIds } : {}
242
+ }), "product list page");
243
+ const products = data.products;
244
+ for (const p of products) byId.set(p.id, {
245
+ id: p.id,
246
+ title: p.title ?? `Product ${p.id}`
247
+ });
248
+ const totalPages = data.meta?.pagination?.total_pages;
249
+ if (totalPages != null ? page >= totalPages : products.length < PRODUCT_PAGE_SIZE) return [...byId.values()];
250
+ }
251
+ throw new ChannelsError(`This company has more than ${MAX_PRODUCT_PAGES * PRODUCT_PAGE_SIZE} active products; refusing to add a partial catalog.`);
252
+ }
253
+ /** IDs of the company's active products; see listActiveProducts. */
254
+ async function listActiveProductIds(client, collectionIds = []) {
255
+ return (await listActiveProducts(client, collectionIds)).map((p) => p.id);
256
+ }
257
+ /** Every collection on the company, for picking which to send. */
258
+ async function listCollections(client) {
259
+ const all = [];
260
+ let cursor;
261
+ for (let page = 0; page < MAX_LISTING_PAGES; page += 1) {
262
+ const data = parse(collectionsPage, await client.get("/api/v202604/company/collections", { page: {
263
+ limit: 100,
264
+ ...cursor ? { cursor } : {}
265
+ } }), "collection list page");
266
+ all.push(...data.collections.map((c) => ({
267
+ id: c.id,
268
+ title: c.title ?? `Collection ${c.id}`
269
+ })));
270
+ const next = data.meta?.pagination?.next_cursor;
271
+ if (!next || data.collections.length === 0) return all;
272
+ cursor = next;
273
+ }
274
+ throw new ChannelsError("This company has too many collections to list.");
275
+ }
276
+ /**
277
+ * Decide which products to send. A product already on the channel is
278
+ * skipped. One you removed earlier is skipped too, unless you ask to bring
279
+ * removed products back, so adding the whole catalog never undoes a
280
+ * deliberate removal.
281
+ */
282
+ function planAdd(productIds, listings, includeRemoved) {
283
+ const byProduct = new Map(listings.map((l) => [l.productId, l]));
284
+ const plan = {
285
+ toAdd: [],
286
+ alreadyListed: [],
287
+ previouslyRemoved: []
288
+ };
289
+ for (const id of new Set(productIds)) {
290
+ const listing = byProduct.get(id);
291
+ if (!listing) plan.toAdd.push(id);
292
+ else if (isListed(listing)) plan.alreadyListed.push(id);
293
+ else if (includeRemoved) plan.toAdd.push(id);
294
+ else plan.previouslyRemoved.push(id);
295
+ }
296
+ return plan;
297
+ }
298
+ /** Send products to the channel in batches. Each new listing starts as a
299
+ * draft and publishes in the background. */
300
+ async function addProducts(client, channel, productIds) {
301
+ const added = [];
302
+ for (let start = 0; start < productIds.length; start += 50) {
303
+ const batch = productIds.slice(start, start + 50);
304
+ try {
305
+ const body = validateInput(productIdsInput, { product_ids: batch }, "product IDs");
306
+ const rows = parse(listingsResponse(channel.listings), await client.post(`${channel.base}/${channel.listings}`, body), "publish response")[channel.listings];
307
+ added.push(...rows.map((row) => summarizeListing(channel, row)));
308
+ } catch (err) {
309
+ if (added.length === 0) throw err;
310
+ throw new ChannelsError(`Added ${added.length} product(s), then a batch failed: ${err instanceof Error ? err.message : String(err)}. Run the same command again to continue; products already added are skipped.`);
311
+ }
312
+ }
313
+ return added;
314
+ }
315
+ /** Find the listing for each product ID, refusing names that have none. */
316
+ function listingsForProducts(productIds, listings) {
317
+ const byProduct = new Map(listings.map((l) => [l.productId, l]));
318
+ const found = [];
319
+ const notListed = [];
320
+ for (const id of new Set(productIds)) {
321
+ const listing = byProduct.get(id);
322
+ if (listing && isListed(listing)) found.push(listing);
323
+ else notListed.push(id);
324
+ }
325
+ return {
326
+ found,
327
+ notListed
328
+ };
329
+ }
330
+ /** Take a listing off the channel. A published listing is withdrawn from
331
+ * the channel; one that never published is just marked removed. */
332
+ async function removeListing(client, channel, listingId) {
333
+ return summarizeListing(channel, parse(listingResponse(channel.listing), await client.delete(`${channel.base}/${channel.listings}/${encodeURIComponent(String(listingId))}`), "remove response")[channel.listing]);
334
+ }
335
+ /** Re-run a failed publish. Only listings whose status is publish_failed
336
+ * can be retried. */
337
+ async function retryListing(client, channel, listingId) {
338
+ return summarizeListing(channel, parse(listingResponse(channel.listing), await client.post(`${channel.base}/${channel.listings}/${encodeURIComponent(String(listingId))}/retry`), "retry response")[channel.listing]);
339
+ }
340
+ //#endregion
341
+ //#region src/browser.ts
342
+ /** Open a URL in the default browser. Returns false when there's no way to. */
343
+ function openInBrowser(url) {
344
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
345
+ try {
346
+ const child = spawn(command, args, {
347
+ detached: true,
348
+ stdio: "ignore"
349
+ });
350
+ child.on("error", () => void 0);
351
+ child.unref();
352
+ return true;
353
+ } catch {
354
+ return false;
355
+ }
356
+ }
357
+ //#endregion
358
+ //#region src/issues.ts
359
+ /** Fixes for the failure types Fluid records when it can't send a product. */
360
+ const HINTS = {
361
+ missing_description: "Give the product a description in Fluid, then retry.",
362
+ missing_package_dimensions: "Give at least one variant a positive package length, width, height, and weight in Fluid, then retry.",
363
+ missing_mandatory_attribute: "TikTok's category for this product requires the attributes named in the error. Check the product's category in TikTok Seller Center.",
364
+ warehouse_not_configured: "Choose the TikTok warehouse first: fluid channels warehouse tiktok <tiktok-warehouse-id> --yes. Then retry.",
365
+ warehouse_not_mapped: "No Fluid warehouse is linked to this TikTok warehouse ID yet. The CLI can't link one; ask Fluid support to, then run fluid channels warehouse tiktok <tiktok-warehouse-id> --yes again.",
366
+ missing_sku: "Give every active variant a SKU in Fluid, then retry.",
367
+ duplicate_sku: "Another listing on this channel already uses the SKU. Make each variant's SKU unique, then retry.",
368
+ no_variants: "The product has no active variants. Activate at least one variant, then retry.",
369
+ connection_inactive: "The connection isn't active. Reconnect with fluid channels connect, then retry."
370
+ };
371
+ function errorOf(listing) {
372
+ const error = listing.lastError;
373
+ if (!error || typeof error !== "object") return {
374
+ type: null,
375
+ message: typeof error === "string" ? error : null
376
+ };
377
+ const record = error;
378
+ return {
379
+ type: typeof record["type"] === "string" ? record["type"] : null,
380
+ message: typeof record["description"] === "string" ? record["description"] : typeof record["message"] === "string" ? record["message"] : null
381
+ };
382
+ }
383
+ function actionFor(listing) {
384
+ if (isRemoved(listing)) return null;
385
+ if (listing.status === "publish_failed") return "retry";
386
+ if (listing.status === "platform_deactivated" || listing.status === "frozen") return "resolve_on_channel";
387
+ if (listing.auditStatus === "audit_failed") return "fix_and_relist";
388
+ return null;
389
+ }
390
+ const ACTION_HINTS = {
391
+ retry: "Fix what the error names, then retry.",
392
+ fix_and_relist: "The channel's review rejected the product. Fix it in Fluid, then remove it and add it again so the corrected product is sent.",
393
+ resolve_on_channel: "The channel deactivated or froze the listing. Check the reason in the channel's seller tools."
394
+ };
395
+ function findIssues(listings) {
396
+ const issues = [];
397
+ for (const listing of listings) {
398
+ const action = actionFor(listing);
399
+ if (!action) continue;
400
+ const { type, message } = errorOf(listing);
401
+ issues.push({
402
+ listingId: listing.id,
403
+ productId: listing.productId,
404
+ status: listing.status,
405
+ auditStatus: listing.auditStatus,
406
+ action,
407
+ errorType: type,
408
+ error: message,
409
+ hint: action === "retry" && type && HINTS[type] || ACTION_HINTS[action]
410
+ });
411
+ }
412
+ return issues;
413
+ }
414
+ function countListings(listings) {
415
+ const byStatus = {};
416
+ const byAuditStatus = {};
417
+ let listed = 0;
418
+ for (const listing of listings) {
419
+ byStatus[listing.status] = (byStatus[listing.status] ?? 0) + 1;
420
+ const audit = listing.auditStatus ?? "none";
421
+ byAuditStatus[audit] = (byAuditStatus[audit] ?? 0) + 1;
422
+ if (isListed(listing)) listed += 1;
423
+ }
424
+ return {
425
+ total: listings.length,
426
+ listed,
427
+ issues: findIssues(listings).length,
428
+ byStatus,
429
+ byAuditStatus
430
+ };
431
+ }
432
+ //#endregion
433
+ //#region src/walk.ts
434
+ const STAGE_NAMES = {
435
+ channel: "Pick the channel",
436
+ connect: "Connect",
437
+ warehouse: "TikTok warehouse",
438
+ catalog: "Choose the products",
439
+ issues: "Fix issues"
440
+ };
441
+ const isConnected = (c) => c.connected === true;
442
+ /** A reply's navigation as a stage outcome; null when it's an answer. */
443
+ function navOutcome(reply) {
444
+ if (!("nav" in reply)) return null;
445
+ return {
446
+ back: "back",
447
+ skip: "skip",
448
+ menu: "menu",
449
+ quit: "quit"
450
+ }[reply.nav];
451
+ }
452
+ async function walkChannels(deps) {
453
+ const { api, prompter } = deps;
454
+ let channel = deps.channel ? channelOf(deps.channel) : null;
455
+ let connected = false;
456
+ let warehouseSet = false;
457
+ let added = 0;
458
+ let retried = 0;
459
+ const skipped = /* @__PURE__ */ new Set();
460
+ const history = [];
461
+ const stages = () => [
462
+ ...deps.channel ? [] : ["channel"],
463
+ "connect",
464
+ ...channel?.id === "tiktok" ? ["warehouse"] : [],
465
+ "catalog",
466
+ "issues"
467
+ ];
468
+ const ask = (q) => prompter.ask({
469
+ ...q,
470
+ canGoBack: history.length > 0
471
+ });
472
+ /** Every live change asks first. Navigating away counts as no. */
473
+ const confirm = async (message) => {
474
+ const reply = await ask({
475
+ section: "Confirm",
476
+ message,
477
+ kind: "select",
478
+ choices: [{
479
+ title: "Yes, do it",
480
+ value: "yes"
481
+ }, {
482
+ title: "No",
483
+ value: "no"
484
+ }]
485
+ });
486
+ return "value" in reply && reply.value === "yes";
487
+ };
488
+ const failed = (error) => prompter.note(`Not done: ${error instanceof Error ? error.message : String(error)}`);
489
+ /** The connection, or a note and a jump to Connect when there isn't one. */
490
+ const requireConnected = async (current) => {
491
+ const connection = await getConnection(api, current);
492
+ if (isConnected(connection)) return connection;
493
+ prompter.note(`${current.label} isn't connected yet. Connect it first.`);
494
+ return { jump: "connect" };
495
+ };
496
+ const pickChannel = async () => {
497
+ const choices = [];
498
+ for (const id of CHANNEL_IDS) {
499
+ const c = CHANNELS[id];
500
+ const connection = await getConnection(api, c);
501
+ choices.push({
502
+ title: c.label,
503
+ value: id,
504
+ description: isConnected(connection) ? `connected${connection.account.name ? ` to ${connection.account.name}` : ""}` : "not connected"
505
+ });
506
+ }
507
+ const reply = await ask({
508
+ section: STAGE_NAMES.channel,
509
+ message: "Which channel?",
510
+ kind: "select",
511
+ choices,
512
+ ...channel ? { current: channel.label } : {}
513
+ });
514
+ const nav = navOutcome(reply);
515
+ if (nav) return nav;
516
+ channel = channelOf(reply.value);
517
+ return "next";
518
+ };
519
+ const connect = async (current) => {
520
+ const existing = await getConnection(api, current);
521
+ if (isConnected(existing)) {
522
+ connected = true;
523
+ const reply = await ask({
524
+ section: STAGE_NAMES.connect,
525
+ message: `${current.label} is connected${existing.account.name ? ` to ${existing.account.name}` : ""}.`,
526
+ kind: "select",
527
+ choices: [{
528
+ title: "Continue",
529
+ value: "continue"
530
+ }, {
531
+ title: "Approve again",
532
+ value: "reconnect",
533
+ description: "for a new account, or after access expired"
534
+ }]
535
+ });
536
+ const nav = navOutcome(reply);
537
+ if (nav) return nav;
538
+ if (reply.value === "continue") return "next";
539
+ }
540
+ let url = await authorizeUrl(api, current);
541
+ const show = (link) => {
542
+ prompter.note(`Open this link in a browser signed in to the ${current.label} account and approve Fluid's access. It works once, for 10 minutes:\n${link}`);
543
+ if (deps.open?.(link)) prompter.note("Opened it in your browser.");
544
+ };
545
+ show(url);
546
+ for (;;) {
547
+ const reply = await ask({
548
+ section: STAGE_NAMES.connect,
549
+ message: "Done approving?",
550
+ kind: "select",
551
+ choices: [{
552
+ title: "Yes, check the connection",
553
+ value: "check"
554
+ }, {
555
+ title: "Get a new link",
556
+ value: "link"
557
+ }]
558
+ });
559
+ const nav = navOutcome(reply);
560
+ if (nav) return nav;
561
+ if (reply.value === "link") {
562
+ url = await authorizeUrl(api, current);
563
+ show(url);
564
+ continue;
565
+ }
566
+ const after = await getConnection(api, current);
567
+ if (isConnected(after)) {
568
+ connected = true;
569
+ prompter.note(`Connected${after.account.name ? ` to ${after.account.name}` : ""}.`);
570
+ return "next";
571
+ }
572
+ prompter.note(`Not connected yet (status: ${after.status}). Finish approving in the browser, or get a new link if it expired.`);
573
+ }
574
+ };
575
+ const warehouse = async (current) => {
576
+ const connection = await requireConnected(current);
577
+ if ("jump" in connection) return connection;
578
+ const saved = connection.details["tiktok_warehouse_id"];
579
+ const reply = await ask({
580
+ section: STAGE_NAMES.warehouse,
581
+ message: "TikTok warehouse ID to publish inventory from",
582
+ kind: "text",
583
+ hint: "from TikTok Seller Center",
584
+ ...typeof saved === "string" && saved ? { current: saved } : {}
585
+ });
586
+ const nav = navOutcome(reply);
587
+ if (nav) return nav;
588
+ const value = reply.value.trim();
589
+ if (value === "") {
590
+ if (typeof saved === "string" && saved) return "next";
591
+ prompter.note("Type the warehouse ID, or > to skip.");
592
+ return "stay";
593
+ }
594
+ if (value === saved) return "next";
595
+ if (!await confirm(`Publish inventory from TikTok warehouse ${value}?`)) return "stay";
596
+ try {
597
+ await setTiktokWarehouse(api, current, value);
598
+ } catch (error) {
599
+ failed(error);
600
+ prompter.note("Fluid only accepts a TikTok warehouse ID that a Fluid warehouse is linked to. If it isn't linked, ask Fluid support.");
601
+ return "stay";
602
+ }
603
+ warehouseSet = true;
604
+ prompter.note(`Inventory publishes from TikTok warehouse ${value}.`);
605
+ return "next";
606
+ };
607
+ /** Pick several items from a searchable list. Returns the picked IDs, or
608
+ * a navigation outcome. `<` with picks undoes the last one. */
609
+ const pickMany = async (message, items) => {
610
+ const picked = [];
611
+ for (;;) {
612
+ const remaining = items.filter((item) => !picked.includes(item.id));
613
+ const reply = await ask({
614
+ section: STAGE_NAMES.catalog,
615
+ message: `${message} (${picked.length} picked)`,
616
+ kind: "search",
617
+ hint: "type to search; < undoes the last pick",
618
+ choices: [{
619
+ title: `Done picking (${picked.length})`,
620
+ value: "done"
621
+ }, ...remaining.map((item) => ({
622
+ title: item.title,
623
+ value: String(item.id)
624
+ }))]
625
+ });
626
+ if ("nav" in reply && reply.nav === "back" && picked.length > 0) {
627
+ picked.pop();
628
+ continue;
629
+ }
630
+ const nav = navOutcome(reply);
631
+ if (nav) return nav;
632
+ const value = reply.value;
633
+ if (value === "done") return picked;
634
+ picked.push(Number(value));
635
+ }
636
+ };
637
+ const sendProducts = async (current, productIds, { explicit = false } = {}) => {
638
+ const plan = planAdd(productIds, await listListings(api, current), explicit);
639
+ if (plan.previouslyRemoved.length > 0) prompter.note(`${plan.previouslyRemoved.length} product(s) you removed earlier stay off. Re-add them with fluid channels catalog ${current.id} --add <ids> --include-removed --yes.`);
640
+ if (plan.toAdd.length === 0) {
641
+ prompter.note(`Nothing new to send; ${plan.alreadyListed.length} already listed.`);
642
+ return "next";
643
+ }
644
+ if (!await confirm(`Send ${plan.toAdd.length} product(s) to ${current.label}?`)) return "stay";
645
+ try {
646
+ const listings = await addProducts(api, current, plan.toAdd);
647
+ added += listings.length;
648
+ } catch (error) {
649
+ failed(error);
650
+ return "stay";
651
+ }
652
+ prompter.note(`Sent ${plan.toAdd.length}. Each publishes in the background, and ${current.label} reviews it; check later with fluid channels status ${current.id}.`);
653
+ return "next";
654
+ };
655
+ const catalog = async (current) => {
656
+ const connection = await requireConnected(current);
657
+ if ("jump" in connection) return connection;
658
+ const counts = countListings(await listListings(api, current));
659
+ const reply = await ask({
660
+ section: STAGE_NAMES.catalog,
661
+ message: `Which products go to ${current.label}? (${counts.listed} listed now)`,
662
+ kind: "select",
663
+ choices: [
664
+ {
665
+ title: "Whole catalog",
666
+ value: "all",
667
+ description: "every active product not listed yet; products you create later need another run"
668
+ },
669
+ {
670
+ title: "Whole collections",
671
+ value: "collections",
672
+ description: "the active products in the collections you pick"
673
+ },
674
+ {
675
+ title: "Selected products",
676
+ value: "selected",
677
+ description: "only the products you pick; best for trying a few first"
678
+ },
679
+ {
680
+ title: "Leave them as they are",
681
+ value: "none"
682
+ }
683
+ ]
684
+ });
685
+ const nav = navOutcome(reply);
686
+ if (nav) return nav;
687
+ const choice = reply.value;
688
+ if (choice === "none") return "next";
689
+ if (choice === "all") return sendProducts(current, await listActiveProductIds(api));
690
+ if (choice === "collections") {
691
+ const collections = await listCollections(api);
692
+ if (collections.length === 0) {
693
+ prompter.note("This company has no collections.");
694
+ return "stay";
695
+ }
696
+ const ids = await pickMany("Pick collections", collections);
697
+ if (!Array.isArray(ids)) return ids === "back" ? "stay" : ids;
698
+ if (ids.length === 0) return "stay";
699
+ return sendProducts(current, await listActiveProductIds(api, ids));
700
+ }
701
+ const products = await listActiveProducts(api);
702
+ if (products.length === 0) {
703
+ prompter.note("This company has no active products.");
704
+ return "stay";
705
+ }
706
+ const ids = await pickMany("Pick products", products);
707
+ if (!Array.isArray(ids)) return ids === "back" ? "stay" : ids;
708
+ if (ids.length === 0) return "stay";
709
+ return sendProducts(current, ids, { explicit: true });
710
+ };
711
+ const issues = async (current) => {
712
+ const connection = await requireConnected(current);
713
+ if ("jump" in connection) return connection;
714
+ const found = findIssues(await listListings(api, current));
715
+ if (found.length === 0) {
716
+ prompter.note(`No issues. Products still publishing or in review show in fluid channels status ${current.id}.`);
717
+ return "next";
718
+ }
719
+ for (const issue of found.slice(0, 10)) prompter.note(`Product ${issue.productId} (${issue.status}${issue.auditStatus ? `, ${issue.auditStatus}` : ""}): ${issue.error ? `${issue.error}. ` : ""}${issue.hint}`);
720
+ if (found.length > 10) prompter.note(`…and ${found.length - 10} more: fluid channels issues ${current.id}`);
721
+ const retryable = found.filter((issue) => issue.action === "retry");
722
+ const reply = await ask({
723
+ section: STAGE_NAMES.issues,
724
+ message: `${found.length} issue(s). Fix the products in Fluid, then:`,
725
+ kind: "select",
726
+ choices: [
727
+ ...retryable.length > 0 ? [{
728
+ title: `Retry ${retryable.length} failed publish(es)`,
729
+ value: "retry"
730
+ }] : [],
731
+ {
732
+ title: "Check again",
733
+ value: "check"
734
+ },
735
+ {
736
+ title: "Done for now",
737
+ value: "done"
738
+ }
739
+ ]
740
+ });
741
+ const nav = navOutcome(reply);
742
+ if (nav) return nav;
743
+ const choice = reply.value;
744
+ if (choice === "done") return "next";
745
+ if (choice === "check") return "stay";
746
+ if (!await confirm(`Retry ${retryable.length} failed publish(es) on ${current.label}?`)) return "stay";
747
+ for (const issue of retryable) try {
748
+ await retryListing(api, current, issue.listingId);
749
+ retried += 1;
750
+ } catch (error) {
751
+ failed(error);
752
+ }
753
+ prompter.note(`Retried ${retried}. They publish in the background.`);
754
+ return "stay";
755
+ };
756
+ const runStage = async (stage) => {
757
+ if (stage === "channel") return pickChannel();
758
+ if (!channel) return { jump: "channel" };
759
+ if (stage === "connect") return connect(channel);
760
+ if (stage === "warehouse") return warehouse(channel);
761
+ if (stage === "catalog") return catalog(channel);
762
+ return issues(channel);
763
+ };
764
+ /** The menu: jump to any stage, or stop. */
765
+ const openMenu = async () => {
766
+ return await prompter.menu("Where to?", [...stages().map((stage) => ({
767
+ title: `${skipped.has(stage) ? "skipped " : ""}${STAGE_NAMES[stage]}`,
768
+ value: stage,
769
+ disabled: stage !== "channel" && !channel
770
+ })), {
771
+ title: "Stop for now",
772
+ value: "quit"
773
+ }]);
774
+ };
775
+ prompter.note(NAV_HELP);
776
+ let stage = stages()[0];
777
+ let finished = false;
778
+ for (;;) {
779
+ const outcome = await runStage(stage);
780
+ if (outcome === "quit") break;
781
+ if (outcome === "stay") continue;
782
+ if (typeof outcome === "object") {
783
+ history.push(stage);
784
+ stage = outcome.jump;
785
+ continue;
786
+ }
787
+ if (outcome === "back") {
788
+ const previous = history.pop();
789
+ if (previous) stage = previous;
790
+ else prompter.note("This is the first step.");
791
+ continue;
792
+ }
793
+ if (outcome === "menu") {
794
+ const choice = await openMenu();
795
+ if (choice === "quit") break;
796
+ if (choice) {
797
+ history.push(stage);
798
+ stage = choice;
799
+ }
800
+ continue;
801
+ }
802
+ if (outcome === "skip") skipped.add(stage);
803
+ else skipped.delete(stage);
804
+ const order = stages();
805
+ const next = order[order.indexOf(stage) + 1];
806
+ history.push(stage);
807
+ if (!next) {
808
+ finished = true;
809
+ break;
810
+ }
811
+ stage = next;
812
+ }
813
+ if (finished && channel) prompter.note(`Done. Check how the products sync with fluid channels status ${channel.id}, and fix rejections with fluid channels issues ${channel.id}.`);
814
+ return {
815
+ channel: channel?.id ?? null,
816
+ connected,
817
+ warehouseSet,
818
+ added,
819
+ retried,
820
+ skipped: [...skipped],
821
+ finished
822
+ };
823
+ }
824
+ //#endregion
825
+ //#region src/index.ts
826
+ async function client(ctx) {
827
+ return await ctx.getClient();
828
+ }
829
+ /**
830
+ * Changes to a live channel need an explicit yes. Scripts and agents pass
831
+ * `--yes`; an interactive terminal is asked. With neither, nothing runs.
832
+ */
833
+ async function confirmChange(question, yes, interactive = Boolean(process.stdin.isTTY)) {
834
+ if (yes) return;
835
+ if (!interactive) throw new ChannelsError(`${question} Pass --yes to confirm.`);
836
+ if (!(await prompts({
837
+ type: "confirm",
838
+ name: "ok",
839
+ message: question,
840
+ initial: false
841
+ })).ok) throw new ChannelsError("Cancelled.");
842
+ }
843
+ /** The error the API sent back, as `{ message, details }`, when there is one. */
844
+ function apiError(err) {
845
+ const body = err?.body;
846
+ if (!body || typeof body !== "object") return {};
847
+ const error = body["error"];
848
+ if (error && typeof error === "object") {
849
+ const record = error;
850
+ const details = record["details"];
851
+ return {
852
+ ...typeof record["message"] === "string" ? { message: record["message"] } : {},
853
+ ...details && typeof details === "object" && Object.keys(details).length > 0 ? { details } : {}
854
+ };
855
+ }
856
+ if (typeof error === "string") return { message: error };
857
+ if (typeof body["error_message"] === "string") return { message: body["error_message"] };
858
+ return {};
859
+ }
860
+ /** Print a failure as JSON on stderr and exit non-zero, so scripts and
861
+ * agents can read it the same way as a success. */
862
+ function fail(err) {
863
+ const rawStatus = err?.status;
864
+ const status = typeof rawStatus === "number" ? rawStatus : void 0;
865
+ const fromApi = apiError(err);
866
+ const message = fromApi.message ?? (err instanceof Error ? err.message : String(err));
867
+ process.stderr.write(JSON.stringify({
868
+ error: message,
869
+ ...status ? { status } : {},
870
+ ...fromApi.details ? { details: fromApi.details } : {}
871
+ }) + "\n");
872
+ process.exitCode = 1;
873
+ }
874
+ function run(action) {
875
+ return action().catch(fail);
876
+ }
877
+ const collect = (value, previous = []) => [...previous, value];
878
+ /** Product, listing, or collection IDs from repeatable, comma-separated flags. */
879
+ function parseIds(values, flag) {
880
+ const ids = [];
881
+ for (const value of values) for (const part of value.split(",")) {
882
+ const trimmed = part.trim();
883
+ if (trimmed === "") continue;
884
+ if (!/^\d+$/.test(trimmed) || Number(trimmed) <= 0) throw new ChannelsError(`${flag} takes numeric IDs; "${trimmed}" isn't one.`);
885
+ ids.push(Number(trimmed));
886
+ }
887
+ return [...new Set(ids)];
888
+ }
889
+ /** Exactly one catalog change per run, or none to list the listings. */
890
+ function catalogMode(opts) {
891
+ const chosen = [
892
+ opts.all && "all",
893
+ opts.add.length > 0 && "add",
894
+ opts.remove.length > 0 && "remove",
895
+ opts.removeAll && "remove-all"
896
+ ].filter(Boolean);
897
+ if (chosen.length > 1) throw new ChannelsError("Use one of --all, --add, --remove, or --remove-all per run.");
898
+ if (opts.collection.length > 0 && !opts.all) throw new ChannelsError("--collection goes with --all.");
899
+ if (opts.includeRemoved && !opts.all && opts.add.length === 0) throw new ChannelsError("--include-removed goes with --all or --add.");
900
+ return chosen[0] ?? "list";
901
+ }
902
+ async function removeEach(api, channel, listings) {
903
+ const removed = [];
904
+ const failed = [];
905
+ for (const listing of listings) try {
906
+ removed.push(await removeListing(api, channel, listing.id));
907
+ } catch (err) {
908
+ failed.push({
909
+ productId: listing.productId,
910
+ error: apiError(err).message ?? (err instanceof Error ? err.message : String(err))
911
+ });
912
+ }
913
+ return {
914
+ removed,
915
+ failed
916
+ };
917
+ }
918
+ /** Print a result that has per-item failures, and exit non-zero when any did. */
919
+ function outputWithFailures(ctx, result) {
920
+ ctx.output(result);
921
+ if (result.failed.length > 0) {
922
+ process.stderr.write(JSON.stringify({ error: `${result.failed.length} item(s) failed; see failed in the output.` }) + "\n");
923
+ process.exitCode = 1;
924
+ }
925
+ }
926
+ function registerChannelCommands(parent, ctx) {
927
+ parent.command("list").description(`Show ${CHANNEL_IDS.join(", ")} and whether each is connected`).action(() => run(async () => {
928
+ const api = await client(ctx);
929
+ const rows = [];
930
+ for (const id of CHANNEL_IDS) {
931
+ const channel = CHANNELS[id];
932
+ const connection = await getConnection(api, channel);
933
+ rows.push({
934
+ label: channel.label,
935
+ ...connection
936
+ });
937
+ }
938
+ ctx.output(rows);
939
+ }));
940
+ parent.command("connect <channel>").description("Print the URL a person opens to approve Fluid's access on the channel; --open opens it in a browser").option("--region <region>", "TikTok Shop only: US or GLOBAL", "US").option("--open", "Open the URL in the default browser", false).action((channelId, opts) => run(async () => {
941
+ const channel = channelOf(channelId);
942
+ const region = opts.region.trim().toUpperCase();
943
+ if (channel.id === "tiktok" && region !== "US" && region !== "GLOBAL") throw new ChannelsError(`--region is US or GLOBAL, not "${opts.region}".`);
944
+ const api = await client(ctx);
945
+ const current = await getConnection(api, channel);
946
+ const url = await authorizeUrl(api, channel, { region });
947
+ const opened = opts.open ? openInBrowser(url) : false;
948
+ ctx.output({
949
+ channel: channel.id,
950
+ alreadyConnected: current.connected,
951
+ url,
952
+ opened,
953
+ next: `Open the URL in a browser signed in to the ${channel.label} account, approve the access, then run: fluid channels status ${channel.id}. The link works once and expires; run connect again for a new one.`
954
+ });
955
+ }));
956
+ parent.command("status <channel>").description("Show the connection and a count of its listings by status").action((channelId) => run(async () => {
957
+ const channel = channelOf(channelId);
958
+ const api = await client(ctx);
959
+ const connection = await getConnection(api, channel);
960
+ if (!("id" in connection)) {
961
+ ctx.output({
962
+ connection,
963
+ listings: null
964
+ });
965
+ return;
966
+ }
967
+ ctx.output({
968
+ connection,
969
+ listings: countListings(await listListings(api, channel))
970
+ });
971
+ }));
972
+ parent.command("catalog <channel>").description("List the products on the channel, or change them: --all, --add, --remove, or --remove-all (changes live config)").option("--all", "Add every active product (or every active product in --collection) not already listed", false).option("--collection <ids>", "With --all, only products in these collections (repeatable, comma-separated)", collect, []).option("--include-removed", "With --all or --add, also re-add products you removed earlier", false).option("--add <ids>", "Add these product IDs (repeatable, comma-separated)", collect, []).option("--remove <ids>", "Remove these product IDs from the channel (repeatable, comma-separated)", collect, []).option("--remove-all", "Remove every listed product from the channel", false).option("--yes", "Confirm without a prompt", false).action((channelId, opts) => run(async () => {
973
+ const channel = channelOf(channelId);
974
+ const mode = catalogMode(opts);
975
+ const api = await client(ctx);
976
+ await requireConnection(api, channel);
977
+ const listings = await listListings(api, channel);
978
+ if (mode === "list") {
979
+ ctx.output({
980
+ channel: channel.id,
981
+ counts: countListings(listings),
982
+ listings
983
+ });
984
+ return;
985
+ }
986
+ if (mode === "all" || mode === "add") {
987
+ const collections = parseIds(opts.collection, "--collection");
988
+ const requested = mode === "all" ? await listActiveProductIds(api, collections) : parseIds(opts.add, "--add");
989
+ const plan = planAdd(requested, listings, opts.includeRemoved);
990
+ const summary = {
991
+ channel: channel.id,
992
+ mode,
993
+ ...mode === "all" ? {
994
+ collections,
995
+ activeProducts: requested.length
996
+ } : {},
997
+ alreadyListed: plan.alreadyListed.length,
998
+ previouslyRemoved: plan.previouslyRemoved
999
+ };
1000
+ if (plan.toAdd.length === 0) {
1001
+ ctx.output({
1002
+ ...summary,
1003
+ added: 0,
1004
+ listings: []
1005
+ });
1006
+ return;
1007
+ }
1008
+ await confirmChange(`Send ${plan.toAdd.length} product(s) to ${channel.label}?`, opts.yes);
1009
+ const added = await addProducts(api, channel, plan.toAdd);
1010
+ ctx.output({
1011
+ ...summary,
1012
+ added: added.length,
1013
+ listings: added,
1014
+ next: `Each product publishes in the background. Check progress with: fluid channels status ${channel.id}`
1015
+ });
1016
+ return;
1017
+ }
1018
+ const targets = mode === "remove-all" ? {
1019
+ found: listings.filter(isListed),
1020
+ notListed: []
1021
+ } : listingsForProducts(parseIds(opts.remove, "--remove"), listings);
1022
+ if (targets.found.length === 0) {
1023
+ ctx.output({
1024
+ channel: channel.id,
1025
+ mode,
1026
+ removed: [],
1027
+ notListed: targets.notListed,
1028
+ failed: []
1029
+ });
1030
+ return;
1031
+ }
1032
+ await confirmChange(`Remove ${targets.found.length} product(s) from ${channel.label}?`, opts.yes);
1033
+ const result = await removeEach(api, channel, targets.found);
1034
+ outputWithFailures(ctx, {
1035
+ channel: channel.id,
1036
+ mode,
1037
+ removed: result.removed,
1038
+ notListed: targets.notListed,
1039
+ failed: result.failed
1040
+ });
1041
+ }));
1042
+ parent.command("issues <channel>").description("List listings that failed to publish or were rejected, with what to do; --retry retries failed publishes (changes live config)").option("--retry", "Retry every listing whose publish failed", false).option("--yes", "Confirm --retry without a prompt", false).action((channelId, opts) => run(async () => {
1043
+ const channel = channelOf(channelId);
1044
+ const api = await client(ctx);
1045
+ await requireConnection(api, channel);
1046
+ const issues = findIssues(await listListings(api, channel));
1047
+ if (!opts.retry) {
1048
+ ctx.output({
1049
+ channel: channel.id,
1050
+ count: issues.length,
1051
+ issues
1052
+ });
1053
+ return;
1054
+ }
1055
+ const retryable = issues.filter((issue) => issue.action === "retry");
1056
+ if (retryable.length === 0) {
1057
+ ctx.output({
1058
+ channel: channel.id,
1059
+ retried: [],
1060
+ failed: [],
1061
+ issues
1062
+ });
1063
+ return;
1064
+ }
1065
+ await confirmChange(`Retry ${retryable.length} failed publish(es) on ${channel.label}?`, opts.yes);
1066
+ const retried = [];
1067
+ const failed = [];
1068
+ for (const issue of retryable) try {
1069
+ retried.push(await retryListing(api, channel, issue.listingId));
1070
+ } catch (err) {
1071
+ failed.push({
1072
+ productId: issue.productId,
1073
+ error: apiError(err).message ?? (err instanceof Error ? err.message : String(err))
1074
+ });
1075
+ }
1076
+ outputWithFailures(ctx, {
1077
+ channel: channel.id,
1078
+ retried,
1079
+ failed
1080
+ });
1081
+ }));
1082
+ parent.command("warehouse <channel> <tiktokWarehouseId>").description("TikTok Shop only: choose the TikTok warehouse whose inventory Fluid publishes (changes live config)").option("--yes", "Confirm without a prompt", false).action((channelId, tiktokWarehouseId, opts) => run(async () => {
1083
+ const channel = channelOf(channelId);
1084
+ if (channel.id !== "tiktok") throw new ChannelsError("Only TikTok Shop uses a warehouse setting.");
1085
+ const id = tiktokWarehouseId.trim();
1086
+ if (id === "") throw new ChannelsError("Pass the TikTok warehouse ID.");
1087
+ const api = await client(ctx);
1088
+ await requireConnection(api, channel);
1089
+ await confirmChange(`Publish inventory from TikTok warehouse ${id}?`, opts.yes);
1090
+ ctx.output(await setTiktokWarehouse(api, channel, id));
1091
+ }));
1092
+ parent.command("walk [channel]").description("Set a channel up question by question in the terminal: connect it, choose the products, fix issues; asks before each change (changes live config)").action((channelId) => run(async () => {
1093
+ assertInteractive("`connect`, `status`, `catalog`, and `issues` with --yes");
1094
+ if (channelId) channelOf(channelId);
1095
+ ctx.output(await walkChannels({
1096
+ api: await client(ctx),
1097
+ prompter: terminalPrompter(),
1098
+ ...channelId ? { channel: channelId } : {},
1099
+ open: openInBrowser
1100
+ }));
1101
+ }));
1102
+ parent.command("disconnect <channel>").description("Disconnect the channel; products already on it stay there (changes live config)").option("--yes", "Confirm without a prompt", false).action((channelId, opts) => run(async () => {
1103
+ const channel = channelOf(channelId);
1104
+ const api = await client(ctx);
1105
+ await requireConnection(api, channel);
1106
+ const stillListed = (await listListings(api, channel)).filter(isListed).length;
1107
+ const warning = stillListed > 0 ? ` ${stillListed} product(s) stay on ${channel.label} and stop syncing; remove them first with fluid channels catalog ${channel.id} --remove-all.` : "";
1108
+ await confirmChange(`Disconnect ${channel.label}?${warning}`, opts.yes);
1109
+ ctx.output({
1110
+ connection: await disconnect(api, channel),
1111
+ stillListed
1112
+ });
1113
+ }));
1114
+ }
1115
+ const plugin = {
1116
+ name: "@fluid-app/fluid-cli-channels",
1117
+ version: "0.1.0",
1118
+ register(ctx) {
1119
+ ctx.program.addCommand(createDomainCommand("channels", "Sales channels: connect TikTok Shop, Meta, or Google Merchant Center, choose the products, check sync, disconnect", registerChannelCommands));
1120
+ }
1121
+ };
1122
+ //#endregion
1123
+ export { catalogMode, confirmChange, plugin as default, fail, parseIds, registerChannelCommands };
1124
+
1125
+ //# sourceMappingURL=index.mjs.map