@fluid-app/fluid-cli-connect 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -0
- package/dist/index.d.mts +14 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1603 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +84 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1603 @@
|
|
|
1
|
+
import prompts from "prompts";
|
|
2
|
+
import { NAV_HELP, assertInteractive, createDomainCommand, terminalPrompter } from "@fluid-app/fluid-cli";
|
|
3
|
+
//#region src/providers.ts
|
|
4
|
+
const MSSQL_CREDENTIALS = [
|
|
5
|
+
{
|
|
6
|
+
key: "mssql.dataserver",
|
|
7
|
+
label: "SQL Server host (data server)"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
key: "mssql.database",
|
|
11
|
+
label: "SQL Server database name"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
key: "mssql.username",
|
|
15
|
+
label: "SQL Server login username"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
key: "mssql.password",
|
|
19
|
+
label: "SQL Server login password",
|
|
20
|
+
secret: true
|
|
21
|
+
}
|
|
22
|
+
];
|
|
23
|
+
const PROVIDERS = {
|
|
24
|
+
exigo: {
|
|
25
|
+
label: "Exigo",
|
|
26
|
+
slug: "exigo",
|
|
27
|
+
dropletName: "Exigo",
|
|
28
|
+
credentialGroups: ["api", "mssql"],
|
|
29
|
+
credentials: [
|
|
30
|
+
{
|
|
31
|
+
key: "api.company",
|
|
32
|
+
label: "Exigo company login (company code)"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
key: "api.username",
|
|
36
|
+
label: "Exigo API service-account username"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
key: "api.password",
|
|
40
|
+
label: "Exigo API service-account password",
|
|
41
|
+
secret: true
|
|
42
|
+
},
|
|
43
|
+
...MSSQL_CREDENTIALS
|
|
44
|
+
],
|
|
45
|
+
environments: true,
|
|
46
|
+
targetOptions: true
|
|
47
|
+
},
|
|
48
|
+
bydesign: {
|
|
49
|
+
label: "ByDesign",
|
|
50
|
+
slug: "bydesign",
|
|
51
|
+
dropletName: "ByDesign",
|
|
52
|
+
credentialGroups: ["api"],
|
|
53
|
+
credentials: [
|
|
54
|
+
{
|
|
55
|
+
key: "api.company",
|
|
56
|
+
label: "ByDesign tenant (company) code"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
key: "api.username",
|
|
60
|
+
label: "ByDesign API service-account username"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
key: "api.password",
|
|
64
|
+
label: "ByDesign API service-account password",
|
|
65
|
+
secret: true
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
key: "api.api_url",
|
|
69
|
+
label: "Freedom API host (blank for Fluid's default)",
|
|
70
|
+
optional: true
|
|
71
|
+
}
|
|
72
|
+
],
|
|
73
|
+
environments: false,
|
|
74
|
+
targetOptions: false
|
|
75
|
+
},
|
|
76
|
+
infotrax: {
|
|
77
|
+
label: "InfoTrax",
|
|
78
|
+
slug: "infotrax",
|
|
79
|
+
dropletName: "Infotrax",
|
|
80
|
+
credentialGroups: ["api", "mssql"],
|
|
81
|
+
credentials: MSSQL_CREDENTIALS,
|
|
82
|
+
environments: false,
|
|
83
|
+
targetOptions: false
|
|
84
|
+
},
|
|
85
|
+
pillars: {
|
|
86
|
+
label: "Pillars",
|
|
87
|
+
slug: "pillars",
|
|
88
|
+
dropletName: "Pillars",
|
|
89
|
+
credentialGroups: ["api"],
|
|
90
|
+
credentials: [{
|
|
91
|
+
key: "api.token",
|
|
92
|
+
label: "Pillars API token",
|
|
93
|
+
secret: true
|
|
94
|
+
}, {
|
|
95
|
+
key: "api.api_url",
|
|
96
|
+
label: "Pillars API base URL"
|
|
97
|
+
}],
|
|
98
|
+
environments: false,
|
|
99
|
+
targetOptions: false
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const PROVIDER_IDS = Object.keys(PROVIDERS);
|
|
103
|
+
/** Scopes the Connect droplet is installed with (the same set Mist uses). */
|
|
104
|
+
const CONNECT_DROPLET_SCOPES = [
|
|
105
|
+
"main",
|
|
106
|
+
"members",
|
|
107
|
+
"prospects",
|
|
108
|
+
"products",
|
|
109
|
+
"orders",
|
|
110
|
+
"users",
|
|
111
|
+
"settings"
|
|
112
|
+
];
|
|
113
|
+
var ConnectError = class extends Error {
|
|
114
|
+
constructor(message) {
|
|
115
|
+
super(message);
|
|
116
|
+
this.name = "ConnectError";
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
function providerOf(id) {
|
|
120
|
+
const key = id.trim().toLowerCase();
|
|
121
|
+
if (!(key in PROVIDERS)) throw new ConnectError(`Unknown provider "${id}". Use one of: ${PROVIDER_IDS.join(", ")}.`);
|
|
122
|
+
return {
|
|
123
|
+
id: key,
|
|
124
|
+
...PROVIDERS[key]
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** fluid-integrations, where Connect's configuration lives. It accepts the
|
|
128
|
+
* same Fluid API token the CLI sends to api.fluid.app. */
|
|
129
|
+
function integrationsBase() {
|
|
130
|
+
return (process.env["FLUID_INTEGRATIONS_BASE"] ?? "https://fluid-integrations-106074092699.us-west3.run.app").trim().replace(/\/$/, "");
|
|
131
|
+
}
|
|
132
|
+
/** The provider's integration as fluid-integrations sees this company. */
|
|
133
|
+
async function providerStatus(integrations, provider) {
|
|
134
|
+
let wire;
|
|
135
|
+
try {
|
|
136
|
+
wire = await integrations.get(`/api/v1/integrations/${provider.slug}`);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error.status === 404) throw new ConnectError(`${provider.label} isn't available on fluid-integrations for this company.`);
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
const ciId = wire.company_integration_id ?? null;
|
|
142
|
+
return {
|
|
143
|
+
provider: provider.id,
|
|
144
|
+
label: provider.label,
|
|
145
|
+
installed: ciId !== null,
|
|
146
|
+
connected: wire.is_connected === true,
|
|
147
|
+
companyIntegrationId: ciId,
|
|
148
|
+
dropletUuid: wire.droplet_uuid ?? null,
|
|
149
|
+
integrationId: wire.id ?? null
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** The company integration id, or a ConnectError saying to install first. */
|
|
153
|
+
async function companyIntegrationId(integrations, provider) {
|
|
154
|
+
const status = await providerStatus(integrations, provider);
|
|
155
|
+
if (status.companyIntegrationId === null) throw new ConnectError(`${provider.label} isn't installed for this company. Run \`fluid connect install ${provider.id} --yes\` first.`);
|
|
156
|
+
return status.companyIntegrationId;
|
|
157
|
+
}
|
|
158
|
+
async function getCompanyIntegration(integrations, ciId) {
|
|
159
|
+
return integrations.get(`/api/v1/company_integrations/${ciId}`);
|
|
160
|
+
}
|
|
161
|
+
/** Save credentials. fluid-integrations tests the connection with them and
|
|
162
|
+
* answers 422 with the reason when it fails, so nothing broken is saved. */
|
|
163
|
+
async function setCredentials(integrations, ciId, authSettings) {
|
|
164
|
+
await integrations.put(`/api/v1/company_integrations/${ciId}`, { company_integration: { auth_settings: authSettings } });
|
|
165
|
+
return { saved: true };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Save credentials, creating the connection when the droplet is installed
|
|
169
|
+
* but its install hasn't created the connection yet (the same request
|
|
170
|
+
* Fluid's admin makes). Without an installed droplet nothing is created, so
|
|
171
|
+
* `install` still installs it. Either way fluid-integrations tests the
|
|
172
|
+
* connection first and answers 422 with the reason when it fails, so
|
|
173
|
+
* nothing broken is saved.
|
|
174
|
+
*/
|
|
175
|
+
async function saveCredentials(integrations, fluid, provider, authSettings) {
|
|
176
|
+
const status = await providerStatus(integrations, provider);
|
|
177
|
+
if (status.companyIntegrationId !== null) {
|
|
178
|
+
await setCredentials(integrations, status.companyIntegrationId, authSettings);
|
|
179
|
+
return {
|
|
180
|
+
saved: true,
|
|
181
|
+
created: false,
|
|
182
|
+
companyIntegrationId: status.companyIntegrationId
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const dropletUuid = status.dropletUuid ?? await findDropletUuid(fluid, provider).catch(() => null);
|
|
186
|
+
if (status.integrationId === null || dropletUuid === null || !await dropletInstalled(fluid, dropletUuid)) throw new ConnectError(`${provider.label} isn't installed for this company. Run \`fluid connect install ${provider.id} --yes\` first.`);
|
|
187
|
+
const created = await integrations.post("/api/v1/company_integrations", { company_integration: {
|
|
188
|
+
integration_id: status.integrationId,
|
|
189
|
+
name: provider.label,
|
|
190
|
+
droplet_uuid: dropletUuid,
|
|
191
|
+
auth_settings: authSettings
|
|
192
|
+
} });
|
|
193
|
+
const id = created?.id ?? created?.data?.id ?? (await providerStatus(integrations, provider)).companyIntegrationId;
|
|
194
|
+
if (typeof id !== "number") throw new ConnectError(`${provider.label} accepted the credentials, but the connection's id didn't come back. Run \`fluid connect show ${provider.id}\`.`);
|
|
195
|
+
return {
|
|
196
|
+
saved: true,
|
|
197
|
+
created: true,
|
|
198
|
+
companyIntegrationId: id
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/** Merge a sync-settings patch into the saved settings. */
|
|
202
|
+
async function updateSyncSettings(integrations, ciId, patch) {
|
|
203
|
+
await integrations.put(`/api/v1/company_integrations/${ciId}`, { company_integration: { sync_settings: patch } });
|
|
204
|
+
return getCompanyIntegration(integrations, ciId);
|
|
205
|
+
}
|
|
206
|
+
/** A mapping type as its route names it, e.g. `customer_type`. */
|
|
207
|
+
function mappingPath(ciId, type) {
|
|
208
|
+
if (!/^[a-z_]+$/.test(type)) throw new ConnectError(`"${type}" isn't a mapping type. Use names like customer_type or warehouse (see \`fluid connect show\`).`);
|
|
209
|
+
return `/api/v1/company_integrations/${ciId}/${type}_mappings`;
|
|
210
|
+
}
|
|
211
|
+
async function listMappings(integrations, ciId, type) {
|
|
212
|
+
return integrations.get(mappingPath(ciId, type));
|
|
213
|
+
}
|
|
214
|
+
/** Values to choose from: Fluid's for the source side, and for Exigo also
|
|
215
|
+
* Exigo's own for the target side. */
|
|
216
|
+
async function mappingOptions(integrations, ciId, type, provider) {
|
|
217
|
+
let fluid = null;
|
|
218
|
+
try {
|
|
219
|
+
fluid = await integrations.get(`${mappingPath(ciId, type)}/source_options`);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (error.status !== 404) throw error;
|
|
222
|
+
if (!provider.targetOptions) throw error;
|
|
223
|
+
}
|
|
224
|
+
if (!provider.targetOptions) return { fluid };
|
|
225
|
+
return {
|
|
226
|
+
fluid,
|
|
227
|
+
provider: await integrations.get(`${mappingPath(ciId, type)}/exigo_options`)
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
/** Create one mapping row. `fields` are the row's attributes, wrapped in
|
|
231
|
+
* the singular resource key the API expects (`customer_type_mapping`). */
|
|
232
|
+
async function createMapping(integrations, ciId, type, fields) {
|
|
233
|
+
return integrations.post(mappingPath(ciId, type), { [`${type}_mapping`]: fields });
|
|
234
|
+
}
|
|
235
|
+
async function deleteMapping(integrations, ciId, type, id) {
|
|
236
|
+
await integrations.delete(`${mappingPath(ciId, type)}/${id}`);
|
|
237
|
+
return {
|
|
238
|
+
deleted: true,
|
|
239
|
+
id
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/** Whether the company has the droplet installed and active in Fluid (one
|
|
243
|
+
* pending uninstall is listed, but inactive). */
|
|
244
|
+
async function dropletInstalled(fluid, dropletUuid) {
|
|
245
|
+
for (let page = 1; page <= CATALOG_PAGES; page += 1) {
|
|
246
|
+
const data = await fluid.get("/api/droplet_installations", {
|
|
247
|
+
page,
|
|
248
|
+
per_page: 100
|
|
249
|
+
});
|
|
250
|
+
const rows = data.droplet_installations ?? [];
|
|
251
|
+
if (rows.some((row) => row.droplet_uuid === dropletUuid && row.active !== false)) return true;
|
|
252
|
+
const totalPages = data.meta?.pagination?.total_pages ?? 1;
|
|
253
|
+
if (page >= totalPages || rows.length < 100) break;
|
|
254
|
+
}
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
const CATALOG_PAGES = 10;
|
|
258
|
+
/** Install the provider's droplet in Fluid, which creates its company
|
|
259
|
+
* integration on fluid-integrations. */
|
|
260
|
+
async function installProvider(fluid, provider, officialUuid = null) {
|
|
261
|
+
const dropletUuid = officialUuid ?? await findDropletUuid(fluid, provider);
|
|
262
|
+
await fluid.post("/api/droplet_installations", {
|
|
263
|
+
droplet_uuid: dropletUuid,
|
|
264
|
+
scopes: CONNECT_DROPLET_SCOPES
|
|
265
|
+
});
|
|
266
|
+
return {
|
|
267
|
+
installed: true,
|
|
268
|
+
dropletUuid
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
async function findDropletUuid(fluid, provider) {
|
|
272
|
+
const wanted = provider.dropletName.toLowerCase();
|
|
273
|
+
const matches = /* @__PURE__ */ new Set();
|
|
274
|
+
for (let page = 1; page <= CATALOG_PAGES; page += 1) {
|
|
275
|
+
const data = await fluid.get("/api/droplets", {
|
|
276
|
+
page,
|
|
277
|
+
per_page: 100
|
|
278
|
+
});
|
|
279
|
+
for (const droplet of data.droplets ?? []) if (droplet.uuid && droplet.name?.trim().toLowerCase() === wanted) matches.add(droplet.uuid);
|
|
280
|
+
const totalPages = data.meta?.pagination?.total_pages ?? 1;
|
|
281
|
+
if (page >= totalPages || (data.droplets ?? []).length < 100) break;
|
|
282
|
+
}
|
|
283
|
+
if (matches.size === 1) return [...matches][0];
|
|
284
|
+
if (matches.size > 1) throw new ConnectError(`More than one droplet is named ${provider.dropletName}, and fluid-integrations didn't say which is ${provider.label}'s. Ask Fluid to check the ${provider.label} integration's droplet.`);
|
|
285
|
+
throw new ConnectError(`The ${provider.label} droplet isn't in this company's droplet catalog. Ask Fluid to make it available.`);
|
|
286
|
+
}
|
|
287
|
+
//#endregion
|
|
288
|
+
//#region src/settings.ts
|
|
289
|
+
/**
|
|
290
|
+
* Parse `--set` values into the nested object the API takes.
|
|
291
|
+
*
|
|
292
|
+
* `api.username=alice` → `{ api: { username: "alice" } }`. Values that read
|
|
293
|
+
* as JSON (true, 42, ["a","b"], {"x":1}) are parsed; anything else stays a
|
|
294
|
+
* string, so a password like `abc123` is never mangled.
|
|
295
|
+
*/
|
|
296
|
+
function parseAssignments(assignments, opts = {}) {
|
|
297
|
+
const out = {};
|
|
298
|
+
for (const assignment of assignments) {
|
|
299
|
+
const at = assignment.indexOf("=");
|
|
300
|
+
if (at <= 0) throw new ConnectError(`"${assignment}" isn't key=value. Use dots for nesting, e.g. api.username=alice.`);
|
|
301
|
+
const path = assignment.slice(0, at).split(".");
|
|
302
|
+
if (path.some((part) => part.length === 0)) throw new ConnectError(`"${assignment}" has an empty key segment.`);
|
|
303
|
+
if (path.some((part) => UNSAFE_KEYS.has(part))) throw new ConnectError(`"${assignment}" uses a reserved key name.`);
|
|
304
|
+
const raw = assignment.slice(at + 1);
|
|
305
|
+
setPath$1(out, path, opts.strings ? raw : parseValue(raw));
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
const UNSAFE_KEYS = new Set([
|
|
310
|
+
"__proto__",
|
|
311
|
+
"constructor",
|
|
312
|
+
"prototype"
|
|
313
|
+
]);
|
|
314
|
+
function parseValue(raw) {
|
|
315
|
+
if (/^(true|false|null|-?\d+(\.\d+)?)$/.test(raw) || /^[[{]/.test(raw)) try {
|
|
316
|
+
return JSON.parse(raw);
|
|
317
|
+
} catch {
|
|
318
|
+
return raw;
|
|
319
|
+
}
|
|
320
|
+
return raw;
|
|
321
|
+
}
|
|
322
|
+
function setPath$1(target, path, value) {
|
|
323
|
+
let node = target;
|
|
324
|
+
for (const key of path.slice(0, -1)) {
|
|
325
|
+
const next = node[key];
|
|
326
|
+
if (typeof next !== "object" || next === null || Array.isArray(next)) node[key] = {};
|
|
327
|
+
node = node[key];
|
|
328
|
+
}
|
|
329
|
+
node[path[path.length - 1]] = value;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Sync settings are saved per entity as the schema entry itself, with the
|
|
333
|
+
* chosen value under `value`: `entities.<entity>.settings.<key>.value`.
|
|
334
|
+
* `--set` takes the short `<entity>.<key>=value` form `fluid connect
|
|
335
|
+
* settings` prints, and this expands it. Exigo's `sponsor_tree` is the one
|
|
336
|
+
* top-level setting (`sync_settings.sponsor_tree.value`); write it as
|
|
337
|
+
* `sponsor_tree.sponsor_tree=value`.
|
|
338
|
+
*/
|
|
339
|
+
function syncSettingsPatch(assignments) {
|
|
340
|
+
const entries = [];
|
|
341
|
+
for (const assignment of assignments) {
|
|
342
|
+
const parsed = parseAssignments([assignment]);
|
|
343
|
+
const [group, values] = Object.entries(parsed)[0];
|
|
344
|
+
if (typeof values !== "object" || values === null || Array.isArray(values)) throw new ConnectError(`"${assignment}" needs an entity and a setting, e.g. order.refund_handling=hold.`);
|
|
345
|
+
for (const [key, value] of Object.entries(values)) entries.push([`${group}.${key}`, value]);
|
|
346
|
+
}
|
|
347
|
+
return patchFromEntries(entries);
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* A patch that puts settings back to their schema defaults: all of them,
|
|
351
|
+
* or only the `<entity>.<key>` names in `only`. Unknown names are refused
|
|
352
|
+
* rather than silently skipped.
|
|
353
|
+
*/
|
|
354
|
+
function defaultsPatch(schema, only = []) {
|
|
355
|
+
const all = Object.entries(schema).flatMap(([entity, settings]) => Object.entries(settings).map(([key, setting]) => [`${entity}.${key}`, setting.value]));
|
|
356
|
+
const withDefault = all.filter(([, value]) => value !== void 0);
|
|
357
|
+
if (only.length === 0) return patchFromEntries(withDefault);
|
|
358
|
+
const known = new Map(all);
|
|
359
|
+
const unknown = only.filter((key) => !known.has(key));
|
|
360
|
+
if (unknown.length > 0) throw new ConnectError(`Unknown setting(s): ${unknown.join(", ")}. List them with \`fluid connect settings <provider>\`.`);
|
|
361
|
+
const noDefault = only.filter((key) => known.get(key) === void 0);
|
|
362
|
+
if (noDefault.length > 0) throw new ConnectError(`No default to restore for: ${noDefault.join(", ")}.`);
|
|
363
|
+
return patchFromEntries(only.map((key) => [key, known.get(key)]));
|
|
364
|
+
}
|
|
365
|
+
/** Settings worth deciding before the first sync, in the order the setup
|
|
366
|
+
* guides list them. A provider without one of them skips it. */
|
|
367
|
+
const COMMON_SETTINGS = [
|
|
368
|
+
"customer.duplicate_customer_handling",
|
|
369
|
+
"customer.unverified_customer_handling",
|
|
370
|
+
"customer.customer_creation_sync",
|
|
371
|
+
"customer.customer_changelog_sync",
|
|
372
|
+
"order.refund_handling",
|
|
373
|
+
"order.duplicate_order_handling",
|
|
374
|
+
"order.order_creation_sync",
|
|
375
|
+
"order.order_changelog_sync",
|
|
376
|
+
"product.new_inactive",
|
|
377
|
+
"product.warehouseless_product_handling",
|
|
378
|
+
"rank.sync_ranks",
|
|
379
|
+
"rank.pulled_from",
|
|
380
|
+
"sponsor_tree.sponsor_tree",
|
|
381
|
+
"customer.fetch_mode",
|
|
382
|
+
"order_payment.skip_payment_on_export"
|
|
383
|
+
];
|
|
384
|
+
/** A patch setting `<entity>.<key>` names to values exactly as given (no
|
|
385
|
+
* parsing), in the shape `--set` and `--defaults` send. */
|
|
386
|
+
function settingsPatch(values) {
|
|
387
|
+
return patchFromEntries(Object.entries(values));
|
|
388
|
+
}
|
|
389
|
+
function patchFromEntries(entries) {
|
|
390
|
+
const entities = {};
|
|
391
|
+
const patch = {};
|
|
392
|
+
for (const [name, value] of entries) {
|
|
393
|
+
const at = name.indexOf(".");
|
|
394
|
+
const group = name.slice(0, at);
|
|
395
|
+
const key = name.slice(at + 1);
|
|
396
|
+
if (group === "sponsor_tree" && key === "sponsor_tree") {
|
|
397
|
+
patch["sponsor_tree"] = { value };
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
entities[group] ??= { settings: {} };
|
|
401
|
+
entities[group].settings[key] = { value };
|
|
402
|
+
}
|
|
403
|
+
if (Object.keys(entities).length > 0) patch["entities"] = entities;
|
|
404
|
+
return patch;
|
|
405
|
+
}
|
|
406
|
+
//#endregion
|
|
407
|
+
//#region src/summary.ts
|
|
408
|
+
/**
|
|
409
|
+
* Every sync setting the provider supports, with the value saved for this
|
|
410
|
+
* company next to the default. Saved values live at
|
|
411
|
+
* `entities.<entity>.settings.<key>.value` (Exigo's `sponsor_tree` at
|
|
412
|
+
* `sponsor_tree.value`); a setting never saved reads as its default.
|
|
413
|
+
*/
|
|
414
|
+
function summarizeSettings(ci) {
|
|
415
|
+
const schema = ci.settings_schema ?? {};
|
|
416
|
+
const saved = ci.sync_settings ?? {};
|
|
417
|
+
const out = [];
|
|
418
|
+
for (const [entity, settings] of Object.entries(schema)) for (const [name, setting] of Object.entries(settings)) out.push(summarize(entity, name, setting, savedValue(saved, entity, name)));
|
|
419
|
+
return out;
|
|
420
|
+
}
|
|
421
|
+
function summarize(entity, name, setting, saved) {
|
|
422
|
+
return {
|
|
423
|
+
key: `${entity}.${name}`,
|
|
424
|
+
label: setting.label ?? null,
|
|
425
|
+
description: setting.description ?? null,
|
|
426
|
+
type: setting.type ?? null,
|
|
427
|
+
value: saved.found ? saved.value : setting.value ?? null,
|
|
428
|
+
default: setting.value ?? null,
|
|
429
|
+
...setting.options ? { options: setting.options.map((o) => o.value) } : {},
|
|
430
|
+
...setting.multiple !== void 0 ? { multiple: setting.multiple } : {},
|
|
431
|
+
...setting.depends_on ? { dependsOn: setting.depends_on } : {}
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
function savedValue(saved, entity, name) {
|
|
435
|
+
const entry = entity === "sponsor_tree" && name === "sponsor_tree" ? saved["sponsor_tree"] : dig(saved, [
|
|
436
|
+
"entities",
|
|
437
|
+
entity,
|
|
438
|
+
"settings",
|
|
439
|
+
name
|
|
440
|
+
]);
|
|
441
|
+
if (entry && typeof entry === "object" && "value" in entry) return {
|
|
442
|
+
found: true,
|
|
443
|
+
value: entry.value
|
|
444
|
+
};
|
|
445
|
+
return {
|
|
446
|
+
found: false,
|
|
447
|
+
value: void 0
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
function dig(node, path) {
|
|
451
|
+
let current = node;
|
|
452
|
+
for (const key of path) {
|
|
453
|
+
if (!current || typeof current !== "object") return void 0;
|
|
454
|
+
current = current[key];
|
|
455
|
+
}
|
|
456
|
+
return current;
|
|
457
|
+
}
|
|
458
|
+
//#endregion
|
|
459
|
+
//#region src/unmapped.ts
|
|
460
|
+
/** The field each mapping type keeps the Fluid (source) value in. A row
|
|
461
|
+
* whose source is `*` is the fallback for every value without its own row. */
|
|
462
|
+
const SOURCE_FIELD = {
|
|
463
|
+
customer_type: "source_type_slug",
|
|
464
|
+
customer_status: "source_status_slug",
|
|
465
|
+
order_status: "source_status_slug",
|
|
466
|
+
order_type: "source_type_slug",
|
|
467
|
+
payable_type: "source_type_slug",
|
|
468
|
+
price_type: "source_price_type_title",
|
|
469
|
+
warehouse: "source_warehouse_title",
|
|
470
|
+
customer_warehouse: "source_country_code",
|
|
471
|
+
payment_type: "source_payment_method",
|
|
472
|
+
wallet_type: "source_payment_method",
|
|
473
|
+
fraud_decision: "source_decision",
|
|
474
|
+
ship_method: "source_shipping_method_title",
|
|
475
|
+
product_type: "source_title",
|
|
476
|
+
country_market: "source_title",
|
|
477
|
+
tree: "source_title",
|
|
478
|
+
rank: "source_title"
|
|
479
|
+
};
|
|
480
|
+
/** The field each mapping type keeps the provider's (target) value in. */
|
|
481
|
+
const TARGET_FIELD = {
|
|
482
|
+
customer_type: "target_type_id",
|
|
483
|
+
customer_status: "target_status_id",
|
|
484
|
+
order_status: "target_status_id",
|
|
485
|
+
order_type: "target_type_id",
|
|
486
|
+
payable_type: "target_payable_id",
|
|
487
|
+
price_type: "target_price_type_id",
|
|
488
|
+
warehouse: "external_warehouse_id",
|
|
489
|
+
customer_warehouse: "target_warehouse_id",
|
|
490
|
+
payment_type: "target_payment_type",
|
|
491
|
+
wallet_type: "target_wallet_type_id",
|
|
492
|
+
fraud_decision: "target_field_value",
|
|
493
|
+
ship_method: "exigo_ship_method_id",
|
|
494
|
+
product_type: "target_title",
|
|
495
|
+
country_market: "target_title",
|
|
496
|
+
tree: "target_title",
|
|
497
|
+
rank: "target_title"
|
|
498
|
+
};
|
|
499
|
+
/** Types whose Fluid values the API lists (`source_options`), with a
|
|
500
|
+
* `mapped` flag on each. Ranks and ship methods have no such list. */
|
|
501
|
+
const LISTS_FLUID_VALUES = new Set([
|
|
502
|
+
"customer_type",
|
|
503
|
+
"customer_status",
|
|
504
|
+
"order_status",
|
|
505
|
+
"order_type",
|
|
506
|
+
"payable_type",
|
|
507
|
+
"price_type",
|
|
508
|
+
"warehouse",
|
|
509
|
+
"customer_warehouse",
|
|
510
|
+
"payment_type",
|
|
511
|
+
"wallet_type",
|
|
512
|
+
"fraud_decision",
|
|
513
|
+
"currency_point_account",
|
|
514
|
+
"product_type",
|
|
515
|
+
"country_market",
|
|
516
|
+
"tree"
|
|
517
|
+
]);
|
|
518
|
+
/** How to build a row from a Fluid option and the Exigo option with the
|
|
519
|
+
* same title. Only types whose target is an Exigo id are listed: a title
|
|
520
|
+
* match then names exactly one Exigo record. */
|
|
521
|
+
const AUTO_MAP = {
|
|
522
|
+
customer_type: (f, p) => f.slug ? {
|
|
523
|
+
source_type_slug: f.slug,
|
|
524
|
+
target_type_id: String(p.id)
|
|
525
|
+
} : null,
|
|
526
|
+
customer_status: (f, p) => f.slug ? {
|
|
527
|
+
source_status_slug: f.slug,
|
|
528
|
+
target_status_id: String(p.id)
|
|
529
|
+
} : null,
|
|
530
|
+
order_status: (f, p) => f.slug ? {
|
|
531
|
+
source_status_slug: f.slug,
|
|
532
|
+
target_status_id: String(p.id)
|
|
533
|
+
} : null,
|
|
534
|
+
order_type: (f, p) => f.slug ? {
|
|
535
|
+
source_type_slug: f.slug,
|
|
536
|
+
target_type_id: String(p.id)
|
|
537
|
+
} : null,
|
|
538
|
+
payable_type: (f, p) => f.slug ? {
|
|
539
|
+
source_type_slug: f.slug,
|
|
540
|
+
target_payable_id: String(p.id)
|
|
541
|
+
} : null,
|
|
542
|
+
price_type: (f, p) => f.title ? {
|
|
543
|
+
source_price_type_title: f.title,
|
|
544
|
+
target_price_type_id: String(p.id)
|
|
545
|
+
} : null,
|
|
546
|
+
warehouse: (f, p) => f.title && f.id !== void 0 ? {
|
|
547
|
+
fluid_warehouse_id: f.id,
|
|
548
|
+
source_warehouse_title: f.title,
|
|
549
|
+
external_warehouse_id: String(p.id)
|
|
550
|
+
} : null
|
|
551
|
+
};
|
|
552
|
+
const norm = (text) => (text ?? "").trim().toLowerCase();
|
|
553
|
+
/** The value a row's source field takes for a Fluid option: the title for
|
|
554
|
+
* fields keyed by title (`source_price_type_title`), else the slug. */
|
|
555
|
+
function sourceValue(option, sourceField) {
|
|
556
|
+
const value = sourceField?.endsWith("_title") ? option.title ?? option.slug ?? option.id : option.slug ?? option.title ?? option.id;
|
|
557
|
+
return String(value ?? "");
|
|
558
|
+
}
|
|
559
|
+
/** Pair unmapped Fluid options with provider options whose title matches
|
|
560
|
+
* exactly (ignoring case and edge spaces). A title that more than one
|
|
561
|
+
* provider option shares is ambiguous and left for a person. */
|
|
562
|
+
function exactTitleMatches(unmapped, providerOptions) {
|
|
563
|
+
const byTitle = /* @__PURE__ */ new Map();
|
|
564
|
+
for (const option of providerOptions) {
|
|
565
|
+
const key = norm(option.title);
|
|
566
|
+
if (!key || option.id === void 0 || option.id === null) continue;
|
|
567
|
+
byTitle.set(key, [...byTitle.get(key) ?? [], option]);
|
|
568
|
+
}
|
|
569
|
+
const pairs = [];
|
|
570
|
+
for (const fluid of unmapped) {
|
|
571
|
+
const candidates = byTitle.get(norm(fluid.title)) ?? [];
|
|
572
|
+
if (candidates.length === 1) pairs.push({
|
|
573
|
+
fluid,
|
|
574
|
+
provider: candidates[0]
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
return pairs;
|
|
578
|
+
}
|
|
579
|
+
function optionsOf(response) {
|
|
580
|
+
const options = response?.options;
|
|
581
|
+
return Array.isArray(options) ? options : [];
|
|
582
|
+
}
|
|
583
|
+
function rowsOf(response, type) {
|
|
584
|
+
const rows = response?.[`${type}_mappings`];
|
|
585
|
+
return Array.isArray(rows) ? rows : [];
|
|
586
|
+
}
|
|
587
|
+
function errorText(error) {
|
|
588
|
+
const body = error.body;
|
|
589
|
+
const detail = body?.["error"] ?? body?.["errors"];
|
|
590
|
+
if (typeof detail === "string") return detail;
|
|
591
|
+
return error instanceof Error ? error.message : String(error);
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* For every mapping type the provider supports, the Fluid values that still
|
|
595
|
+
* need a row. With `autoMap`, rows are first created for values whose title
|
|
596
|
+
* matches exactly one provider value (Exigo only, and only types whose target
|
|
597
|
+
* is an id), so what's returned is what a person still has to decide.
|
|
598
|
+
*/
|
|
599
|
+
async function unmappedReport(integrations, ciId, provider, autoMap) {
|
|
600
|
+
const types = ((await getCompanyIntegration(integrations, ciId)).supported_mappings ?? []).map((card) => card.replace(/_title$/, "")).filter((type) => type in SOURCE_FIELD || LISTS_FLUID_VALUES.has(type));
|
|
601
|
+
const reports = [];
|
|
602
|
+
for (const type of types) reports.push(await reportFor(integrations, ciId, provider, type, autoMap));
|
|
603
|
+
return reports;
|
|
604
|
+
}
|
|
605
|
+
async function reportFor(integrations, ciId, provider, type, autoMap) {
|
|
606
|
+
const sourceField = SOURCE_FIELD[type] ?? null;
|
|
607
|
+
if (!LISTS_FLUID_VALUES.has(type)) return {
|
|
608
|
+
type,
|
|
609
|
+
status: "check_by_hand",
|
|
610
|
+
sourceField
|
|
611
|
+
};
|
|
612
|
+
const autoMapped = [];
|
|
613
|
+
try {
|
|
614
|
+
const path = mappingPath(ciId, type);
|
|
615
|
+
const [optionsResponse, rowsResponse] = await Promise.all([integrations.get(`${path}/source_options`), integrations.get(path)]);
|
|
616
|
+
if (sourceField !== null && rowsOf(rowsResponse, type).some((row) => row[sourceField] === "*")) return {
|
|
617
|
+
type,
|
|
618
|
+
status: "covered",
|
|
619
|
+
sourceField,
|
|
620
|
+
fallback: true
|
|
621
|
+
};
|
|
622
|
+
let unmapped = optionsOf(optionsResponse).filter((option) => option.mapped === false);
|
|
623
|
+
const build = AUTO_MAP[type];
|
|
624
|
+
if (autoMap && provider.targetOptions && build && unmapped.length > 0) {
|
|
625
|
+
const providerOptions = optionsOf(await integrations.get(`${path}/exigo_options`));
|
|
626
|
+
for (const { fluid, provider: match } of exactTitleMatches(unmapped, providerOptions)) {
|
|
627
|
+
const fields = build(fluid, match);
|
|
628
|
+
if (!fields) continue;
|
|
629
|
+
await createMapping(integrations, ciId, type, fields);
|
|
630
|
+
autoMapped.push(fields);
|
|
631
|
+
unmapped = unmapped.filter((option) => option !== fluid);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return {
|
|
635
|
+
type,
|
|
636
|
+
status: unmapped.length === 0 ? "covered" : "needs_mapping",
|
|
637
|
+
sourceField,
|
|
638
|
+
unmapped: unmapped.map((option) => ({
|
|
639
|
+
value: sourceValue(option, sourceField),
|
|
640
|
+
title: option.title ?? null,
|
|
641
|
+
...option.id !== void 0 && option.id !== null ? { id: option.id } : {}
|
|
642
|
+
})),
|
|
643
|
+
...autoMapped.length > 0 ? { autoMapped } : {}
|
|
644
|
+
};
|
|
645
|
+
} catch (error) {
|
|
646
|
+
return {
|
|
647
|
+
type,
|
|
648
|
+
status: "error",
|
|
649
|
+
sourceField,
|
|
650
|
+
error: errorText(error),
|
|
651
|
+
...autoMapped.length > 0 ? { autoMapped } : {}
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
//#endregion
|
|
656
|
+
//#region src/walk.ts
|
|
657
|
+
const PHASE_NAMES = {
|
|
658
|
+
credentials: "Credentials",
|
|
659
|
+
mappings: "Mappings",
|
|
660
|
+
settings: "Settings"
|
|
661
|
+
};
|
|
662
|
+
const DONE = { done: true };
|
|
663
|
+
const FALLBACK = "*";
|
|
664
|
+
/** The reason a request failed: the provider's, on a failed connection test. */
|
|
665
|
+
function reasonOf(error) {
|
|
666
|
+
const body = error.body;
|
|
667
|
+
const detail = body?.["errors"] ?? body?.["error"] ?? body?.["error_message"];
|
|
668
|
+
if (typeof detail === "string") return detail;
|
|
669
|
+
if (Array.isArray(detail)) return detail.map(String).join("; ");
|
|
670
|
+
if (detail && typeof detail === "object") return Object.entries(detail).map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : String(value)}`).join("; ");
|
|
671
|
+
return error instanceof Error ? error.message : String(error);
|
|
672
|
+
}
|
|
673
|
+
const words = (type) => type.replace(/_/g, " ");
|
|
674
|
+
function display(value) {
|
|
675
|
+
if (value === void 0 || value === null || value === "") return "(none)";
|
|
676
|
+
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
677
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
678
|
+
}
|
|
679
|
+
function setPath(target, key, value) {
|
|
680
|
+
const path = key.split(".");
|
|
681
|
+
let node = target;
|
|
682
|
+
for (const part of path.slice(0, -1)) {
|
|
683
|
+
if (typeof node[part] !== "object" || node[part] === null) node[part] = {};
|
|
684
|
+
node = node[part];
|
|
685
|
+
}
|
|
686
|
+
node[path[path.length - 1]] = value;
|
|
687
|
+
}
|
|
688
|
+
function rowIdOf(response, type) {
|
|
689
|
+
const record = response;
|
|
690
|
+
const row = record?.[`${type}_mapping`] ?? record;
|
|
691
|
+
return typeof row?.id === "number" ? row.id : void 0;
|
|
692
|
+
}
|
|
693
|
+
async function walkConnect(deps) {
|
|
694
|
+
const { integrations, provider, prompter } = deps;
|
|
695
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
696
|
+
const label = provider.label;
|
|
697
|
+
const result = {
|
|
698
|
+
provider: provider.id,
|
|
699
|
+
installed: false,
|
|
700
|
+
connected: false,
|
|
701
|
+
credentialsSaved: false,
|
|
702
|
+
autoMapped: 0,
|
|
703
|
+
rowsAdded: 0,
|
|
704
|
+
mappingsDone: null,
|
|
705
|
+
stillUnmapped: [],
|
|
706
|
+
settingsChanged: [],
|
|
707
|
+
settingsRestored: false,
|
|
708
|
+
skipped: [],
|
|
709
|
+
stopped: false
|
|
710
|
+
};
|
|
711
|
+
prompter.note(NAV_HELP);
|
|
712
|
+
let status = await providerStatus(integrations, provider);
|
|
713
|
+
if (!status.installed) {
|
|
714
|
+
if (await prompter.menu(`${label} isn't installed for this company. Installing it adds the ${label} droplet in Fluid, which creates the connection (changes live config).`, [{
|
|
715
|
+
title: `Install ${label}`,
|
|
716
|
+
value: "install"
|
|
717
|
+
}, {
|
|
718
|
+
title: "Stop",
|
|
719
|
+
value: "stop"
|
|
720
|
+
}]) !== "install") {
|
|
721
|
+
result.stopped = true;
|
|
722
|
+
return result;
|
|
723
|
+
}
|
|
724
|
+
await installProvider(deps.fluid, provider, status.dropletUuid);
|
|
725
|
+
prompter.note(`Installed ${label}. Waiting for the connection…`);
|
|
726
|
+
for (let attempt = 0; attempt < 10 && !status.installed; attempt += 1) {
|
|
727
|
+
await sleep(2e3);
|
|
728
|
+
status = await providerStatus(integrations, provider);
|
|
729
|
+
}
|
|
730
|
+
if (!status.installed) prompter.note(`The connection isn't there yet, so saving the credentials will create it.`);
|
|
731
|
+
}
|
|
732
|
+
result.installed = true;
|
|
733
|
+
result.connected = status.connected;
|
|
734
|
+
/** Null until the connection exists; saving credentials creates it. */
|
|
735
|
+
let ciId = status.companyIntegrationId;
|
|
736
|
+
let ci = ciId === null ? { id: 0 } : await getCompanyIntegration(integrations, ciId);
|
|
737
|
+
/** The connection, for the parts that need one (guarded at mappings-start). */
|
|
738
|
+
const connection = () => {
|
|
739
|
+
if (ciId === null) throw new ConnectError(`${label} has no connection yet. Save its credentials first.`);
|
|
740
|
+
return ciId;
|
|
741
|
+
};
|
|
742
|
+
const steps = [];
|
|
743
|
+
const history = [];
|
|
744
|
+
const skipped = /* @__PURE__ */ new Map();
|
|
745
|
+
const answers = /* @__PURE__ */ new Map();
|
|
746
|
+
let index = 0;
|
|
747
|
+
/** The step reached by back or the menu: asked even if answered. */
|
|
748
|
+
let targeted;
|
|
749
|
+
const indexOf = (id) => steps.findIndex((s) => s.id === id);
|
|
750
|
+
/** Put `fresh` in place of the group's steps, right after `afterId`. */
|
|
751
|
+
const replaceGroup = (group, afterId, fresh) => {
|
|
752
|
+
for (let i = steps.length - 1; i >= 0; i -= 1) if (steps[i].group === group) {
|
|
753
|
+
answers.delete(steps[i].id);
|
|
754
|
+
skipped.delete(steps[i].id);
|
|
755
|
+
steps.splice(i, 1);
|
|
756
|
+
}
|
|
757
|
+
steps.splice(indexOf(afterId) + 1, 0, ...fresh);
|
|
758
|
+
};
|
|
759
|
+
const ask = (question, canGoBack) => prompter.ask({
|
|
760
|
+
...question,
|
|
761
|
+
canGoBack
|
|
762
|
+
});
|
|
763
|
+
const draft = /* @__PURE__ */ new Map();
|
|
764
|
+
const credentialSteps = [];
|
|
765
|
+
const firstCredential = () => credentialSteps.find((s) => s.asks && s.id !== "credentials-keep").id;
|
|
766
|
+
if (status.connected) credentialSteps.push({
|
|
767
|
+
id: "credentials-keep",
|
|
768
|
+
phase: "credentials",
|
|
769
|
+
title: "Change the credentials?",
|
|
770
|
+
asks: true,
|
|
771
|
+
run: async (canGoBack) => {
|
|
772
|
+
const reply = await ask({
|
|
773
|
+
section: PHASE_NAMES.credentials,
|
|
774
|
+
message: `${label} is connected. Change its credentials?`,
|
|
775
|
+
kind: "select",
|
|
776
|
+
choices: [{
|
|
777
|
+
title: "Keep the saved credentials",
|
|
778
|
+
value: "keep"
|
|
779
|
+
}, {
|
|
780
|
+
title: "Change them",
|
|
781
|
+
value: "change"
|
|
782
|
+
}]
|
|
783
|
+
}, canGoBack);
|
|
784
|
+
if ("nav" in reply) return reply;
|
|
785
|
+
if (reply.value === "keep") {
|
|
786
|
+
answers.set("credentials-keep", "Keep");
|
|
787
|
+
history.push("credentials-keep");
|
|
788
|
+
return { goto: "mappings-start" };
|
|
789
|
+
}
|
|
790
|
+
return {
|
|
791
|
+
done: true,
|
|
792
|
+
answer: "Change"
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
const required = (field) => !field.optional && !result.connected;
|
|
797
|
+
const credentialStep = (field, when) => ({
|
|
798
|
+
id: `credential:${field.key}`,
|
|
799
|
+
phase: "credentials",
|
|
800
|
+
title: field.label,
|
|
801
|
+
asks: true,
|
|
802
|
+
...when ? { when } : {},
|
|
803
|
+
run: async (canGoBack) => {
|
|
804
|
+
const typed = draft.get(field.key);
|
|
805
|
+
const reply = await ask({
|
|
806
|
+
section: `${PHASE_NAMES.credentials} · ${field.key}`,
|
|
807
|
+
message: field.label,
|
|
808
|
+
kind: field.secret ? "secret" : "text",
|
|
809
|
+
...typed !== void 0 ? { current: field.secret ? "••••••" : typed } : {},
|
|
810
|
+
...result.connected ? { hint: "Enter keeps the saved value" } : field.optional ? { hint: "optional; Enter leaves it blank" } : {}
|
|
811
|
+
}, canGoBack);
|
|
812
|
+
if ("nav" in reply) return reply;
|
|
813
|
+
const value = reply.value.trim();
|
|
814
|
+
if (value === "") {
|
|
815
|
+
if (typed !== void 0 || !required(field)) return {
|
|
816
|
+
done: true,
|
|
817
|
+
answer: typed !== void 0 ? "(entered)" : "(unchanged)"
|
|
818
|
+
};
|
|
819
|
+
prompter.note(`${label} needs this. Type it, or > to skip.`);
|
|
820
|
+
return { retry: true };
|
|
821
|
+
}
|
|
822
|
+
draft.set(field.key, value);
|
|
823
|
+
return {
|
|
824
|
+
done: true,
|
|
825
|
+
answer: field.secret ? "(entered)" : value
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
if (provider.environments) {
|
|
830
|
+
credentialSteps.push({
|
|
831
|
+
id: "credential:environment",
|
|
832
|
+
phase: "credentials",
|
|
833
|
+
title: `${label} environment`,
|
|
834
|
+
asks: true,
|
|
835
|
+
run: async (canGoBack) => {
|
|
836
|
+
const reply = await ask({
|
|
837
|
+
section: `${PHASE_NAMES.credentials} · environment`,
|
|
838
|
+
message: `Which ${label} environment?`,
|
|
839
|
+
kind: "select",
|
|
840
|
+
choices: [{
|
|
841
|
+
title: "Production (your live Exigo)",
|
|
842
|
+
value: "production"
|
|
843
|
+
}, {
|
|
844
|
+
title: "Sandbox",
|
|
845
|
+
value: "sandbox"
|
|
846
|
+
}],
|
|
847
|
+
...draft.get("environment") ?? ci.environment ? { current: draft.get("environment") ?? ci.environment ?? void 0 } : {}
|
|
848
|
+
}, canGoBack);
|
|
849
|
+
if ("nav" in reply) return reply;
|
|
850
|
+
const environment = reply.value.trim() || draft.get("environment") || ci.environment;
|
|
851
|
+
if (!environment) {
|
|
852
|
+
prompter.note("Choose an environment, or > to skip.");
|
|
853
|
+
return { retry: true };
|
|
854
|
+
}
|
|
855
|
+
draft.set("environment", environment);
|
|
856
|
+
return {
|
|
857
|
+
done: true,
|
|
858
|
+
answer: environment
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
credentialSteps.push(credentialStep({
|
|
863
|
+
key: "sandbox_id",
|
|
864
|
+
label: "Exigo sandbox ID (the number in sandboxapi<ID>.exigo.com)"
|
|
865
|
+
}, () => (draft.get("environment") ?? ci.environment) !== "production"));
|
|
866
|
+
}
|
|
867
|
+
for (const field of provider.credentials) credentialSteps.push(credentialStep(field));
|
|
868
|
+
credentialSteps.push({
|
|
869
|
+
id: "credentials-save",
|
|
870
|
+
phase: "credentials",
|
|
871
|
+
title: "Save the credentials",
|
|
872
|
+
asks: false,
|
|
873
|
+
run: async () => {
|
|
874
|
+
if (draft.size === 0) return DONE;
|
|
875
|
+
const authSettings = {};
|
|
876
|
+
for (const [key, value] of draft) setPath(authSettings, key, value);
|
|
877
|
+
prompter.note(`Testing the connection to ${label}…`);
|
|
878
|
+
try {
|
|
879
|
+
ciId = (await saveCredentials(integrations, deps.fluid, provider, authSettings)).companyIntegrationId;
|
|
880
|
+
} catch (error) {
|
|
881
|
+
prompter.note(`${label} didn't accept the credentials, so nothing was saved: ${reasonOf(error)}`);
|
|
882
|
+
prompter.note("Check the values and answer again. Enter keeps what you typed.");
|
|
883
|
+
for (const step of credentialSteps) answers.delete(step.id);
|
|
884
|
+
return { goto: firstCredential() };
|
|
885
|
+
}
|
|
886
|
+
draft.clear();
|
|
887
|
+
result.credentialsSaved = true;
|
|
888
|
+
result.connected = true;
|
|
889
|
+
prompter.note("Connected. The credentials are saved.");
|
|
890
|
+
return DONE;
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
steps.push(...credentialSteps);
|
|
894
|
+
let autoMap = false;
|
|
895
|
+
const providerChoices = /* @__PURE__ */ new Map();
|
|
896
|
+
/** Types a `*` row now covers, so their other values aren't asked. */
|
|
897
|
+
const fallbackTypes = /* @__PURE__ */ new Set();
|
|
898
|
+
/** The row each mapping answer created, replaced when it's changed. */
|
|
899
|
+
const rowsByStep = /* @__PURE__ */ new Map();
|
|
900
|
+
steps.push({
|
|
901
|
+
id: "mappings-start",
|
|
902
|
+
phase: "mappings",
|
|
903
|
+
title: "Mappings",
|
|
904
|
+
asks: false,
|
|
905
|
+
run: async () => {
|
|
906
|
+
if (ciId !== null) return DONE;
|
|
907
|
+
prompter.note(`Mappings and settings need the connection, which saving ${label}'s credentials creates. Answer the credentials, or run \`fluid connect credentials ${provider.id}\`.`);
|
|
908
|
+
return { stop: true };
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
if (provider.targetOptions) steps.push({
|
|
912
|
+
id: "mappings-automap",
|
|
913
|
+
phase: "mappings",
|
|
914
|
+
title: "Map exact name matches",
|
|
915
|
+
asks: true,
|
|
916
|
+
run: async (canGoBack) => {
|
|
917
|
+
const reply = await ask({
|
|
918
|
+
section: PHASE_NAMES.mappings,
|
|
919
|
+
message: `Add a mapping wherever a Fluid value's name matches exactly one ${label} value? (changes live config)`,
|
|
920
|
+
kind: "select",
|
|
921
|
+
choices: [{
|
|
922
|
+
title: "Yes, map exact name matches first",
|
|
923
|
+
value: "yes"
|
|
924
|
+
}, {
|
|
925
|
+
title: "No, I'll map each one",
|
|
926
|
+
value: "no"
|
|
927
|
+
}],
|
|
928
|
+
...answers.has("mappings-automap") ? { current: autoMap ? "yes" : "no" } : {}
|
|
929
|
+
}, canGoBack);
|
|
930
|
+
if ("nav" in reply) return reply;
|
|
931
|
+
autoMap = reply.value === "yes";
|
|
932
|
+
return {
|
|
933
|
+
done: true,
|
|
934
|
+
answer: autoMap ? "Yes" : "No"
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
/** The provider's values for a type, as pick-list choices (Exigo). */
|
|
939
|
+
const choicesFor = async (type) => {
|
|
940
|
+
if (!provider.targetOptions) return [];
|
|
941
|
+
const cached = providerChoices.get(type);
|
|
942
|
+
if (cached) return cached;
|
|
943
|
+
let choices = [];
|
|
944
|
+
try {
|
|
945
|
+
const response = await integrations.get(`${mappingPath(connection(), type)}/exigo_options`);
|
|
946
|
+
const byTitle = (TARGET_FIELD[type] ?? "").endsWith("_title");
|
|
947
|
+
choices = (response?.options ?? []).filter((o) => o.id !== void 0 && o.id !== null).map((o) => ({
|
|
948
|
+
title: o.title ? `${o.title} (${String(o.id)})` : String(o.id),
|
|
949
|
+
value: byTitle && o.title ? o.title : String(o.id)
|
|
950
|
+
}));
|
|
951
|
+
} catch {
|
|
952
|
+
choices = [];
|
|
953
|
+
}
|
|
954
|
+
providerChoices.set(type, choices);
|
|
955
|
+
return choices;
|
|
956
|
+
};
|
|
957
|
+
/** Ask for the provider's value: a pick list for Exigo, typed otherwise. */
|
|
958
|
+
const askTarget = async (type, message, section, canGoBack, offerFallback, current) => {
|
|
959
|
+
const choices = await choicesFor(type);
|
|
960
|
+
const fallback = offerFallback ? [{
|
|
961
|
+
title: `* Use one ${label} value for every remaining ${words(type)}`,
|
|
962
|
+
value: FALLBACK
|
|
963
|
+
}] : [];
|
|
964
|
+
if (choices.length > 0) return ask({
|
|
965
|
+
section,
|
|
966
|
+
message,
|
|
967
|
+
kind: "search",
|
|
968
|
+
choices: [...choices, ...fallback],
|
|
969
|
+
hint: "type to filter",
|
|
970
|
+
...current ? { current } : {}
|
|
971
|
+
}, canGoBack);
|
|
972
|
+
const targetIsName = (TARGET_FIELD[type] ?? "").endsWith("_title");
|
|
973
|
+
return ask({
|
|
974
|
+
section,
|
|
975
|
+
message,
|
|
976
|
+
kind: "text",
|
|
977
|
+
hint: [`${label}'s ${words(type)} ${targetIsName ? "name" : "ID"}, from your ${label} back office`, offerFallback ? `or * to use one value for every remaining ${words(type)}` : ""].filter(Boolean).join("; "),
|
|
978
|
+
...current ? { current } : {}
|
|
979
|
+
}, canGoBack);
|
|
980
|
+
};
|
|
981
|
+
/** Save one row, replacing the row this step saved before. */
|
|
982
|
+
const saveRow = async (stepId, type, fields, fallback) => {
|
|
983
|
+
let response;
|
|
984
|
+
try {
|
|
985
|
+
response = await createMapping(integrations, connection(), type, fields);
|
|
986
|
+
} catch (error) {
|
|
987
|
+
prompter.note(`Not saved: ${reasonOf(error)}`);
|
|
988
|
+
return false;
|
|
989
|
+
}
|
|
990
|
+
const previous = rowsByStep.get(stepId);
|
|
991
|
+
if (previous) {
|
|
992
|
+
if (previous.fallback) fallbackTypes.delete(previous.type);
|
|
993
|
+
if (previous.id !== void 0) try {
|
|
994
|
+
await deleteMapping(integrations, connection(), previous.type, previous.id);
|
|
995
|
+
} catch (error) {
|
|
996
|
+
prompter.note(`The earlier row (${previous.id}) is still there: ${reasonOf(error)}. Remove it with \`fluid connect unmap ${provider.id} ${previous.type} ${previous.id} --yes\`.`);
|
|
997
|
+
}
|
|
998
|
+
} else result.rowsAdded += 1;
|
|
999
|
+
const id = rowIdOf(response, type);
|
|
1000
|
+
rowsByStep.set(stepId, {
|
|
1001
|
+
type,
|
|
1002
|
+
...id !== void 0 ? { id } : {},
|
|
1003
|
+
fallback
|
|
1004
|
+
});
|
|
1005
|
+
if (fallback) fallbackTypes.add(type);
|
|
1006
|
+
return true;
|
|
1007
|
+
};
|
|
1008
|
+
const valueStep = (report, value, position) => {
|
|
1009
|
+
const type = report.type;
|
|
1010
|
+
const id = `map:${type}:${value.value}`;
|
|
1011
|
+
const name = value.title && value.title !== value.value ? `"${value.title}" (${value.value})` : `"${value.value}"`;
|
|
1012
|
+
const section = `${PHASE_NAMES.mappings} · ${words(type)} · ${position} of ${report.unmapped.length}`;
|
|
1013
|
+
return {
|
|
1014
|
+
id,
|
|
1015
|
+
phase: "mappings",
|
|
1016
|
+
group: "mapping-values",
|
|
1017
|
+
title: `${words(type)}: ${value.title ?? value.value}`,
|
|
1018
|
+
asks: true,
|
|
1019
|
+
when: () => !fallbackTypes.has(type) || rowsByStep.get(id)?.fallback === true,
|
|
1020
|
+
run: async (canGoBack) => {
|
|
1021
|
+
const reply = await askTarget(type, `${label} ${words(type)} for Fluid's ${name}`, section, canGoBack, true, answers.get(id));
|
|
1022
|
+
if ("nav" in reply) return reply;
|
|
1023
|
+
let target = reply.value.trim();
|
|
1024
|
+
if (target === "") {
|
|
1025
|
+
if (answers.has(id)) return {
|
|
1026
|
+
done: true,
|
|
1027
|
+
answer: answers.get(id)
|
|
1028
|
+
};
|
|
1029
|
+
prompter.note("Choose a value, or > to skip.");
|
|
1030
|
+
return { retry: true };
|
|
1031
|
+
}
|
|
1032
|
+
if (target === answers.get(id)) return {
|
|
1033
|
+
done: true,
|
|
1034
|
+
answer: target
|
|
1035
|
+
};
|
|
1036
|
+
const fallback = target === FALLBACK;
|
|
1037
|
+
if (fallback) {
|
|
1038
|
+
const second = await askTarget(type, `${label} ${words(type)} for every Fluid ${words(type)} without its own row`, section, canGoBack, false);
|
|
1039
|
+
if ("nav" in second) return second.nav === "back" ? { retry: true } : second;
|
|
1040
|
+
target = second.value.trim();
|
|
1041
|
+
if (target === "") return { retry: true };
|
|
1042
|
+
}
|
|
1043
|
+
const fields = {
|
|
1044
|
+
[SOURCE_FIELD[type]]: fallback ? FALLBACK : value.value,
|
|
1045
|
+
[TARGET_FIELD[type]]: target
|
|
1046
|
+
};
|
|
1047
|
+
if (type === "warehouse" && !fallback && value.id !== void 0) {
|
|
1048
|
+
fields["fluid_warehouse_id"] = value.id;
|
|
1049
|
+
fields["source_warehouse_title"] = value.title ?? value.value;
|
|
1050
|
+
}
|
|
1051
|
+
if (!await saveRow(id, type, fields, fallback)) return { retry: true };
|
|
1052
|
+
const answer = fallback ? `* → ${target}` : target;
|
|
1053
|
+
prompter.note(fallback ? `Saved a * row: every other ${words(type)} maps to ${target}.` : `Saved: ${value.title ?? value.value} → ${target}.`);
|
|
1054
|
+
return {
|
|
1055
|
+
done: true,
|
|
1056
|
+
answer
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
};
|
|
1061
|
+
/** Ranks and ship methods: the API can't list Fluid's values. */
|
|
1062
|
+
const byHandStep = (report) => {
|
|
1063
|
+
const type = report.type;
|
|
1064
|
+
const id = `by-hand:${type}`;
|
|
1065
|
+
return {
|
|
1066
|
+
id,
|
|
1067
|
+
phase: "mappings",
|
|
1068
|
+
group: "mapping-values",
|
|
1069
|
+
title: `${words(type)} (by hand)`,
|
|
1070
|
+
asks: true,
|
|
1071
|
+
run: async (canGoBack) => {
|
|
1072
|
+
const section = `${PHASE_NAMES.mappings} · ${words(type)}`;
|
|
1073
|
+
const reply = await ask({
|
|
1074
|
+
section,
|
|
1075
|
+
message: `Fluid can't list its ${words(type)}s, so compare \`fluid connect mappings ${provider.id} ${type}\` with them yourself. Add a ${words(type)} row now?`,
|
|
1076
|
+
kind: "select",
|
|
1077
|
+
choices: [{
|
|
1078
|
+
title: "Not now",
|
|
1079
|
+
value: "no"
|
|
1080
|
+
}, {
|
|
1081
|
+
title: `Add a ${words(type)} row`,
|
|
1082
|
+
value: "add"
|
|
1083
|
+
}]
|
|
1084
|
+
}, canGoBack);
|
|
1085
|
+
if ("nav" in reply) return reply;
|
|
1086
|
+
if (reply.value !== "add") return {
|
|
1087
|
+
done: true,
|
|
1088
|
+
answer: answers.get(id) ?? "Not now"
|
|
1089
|
+
};
|
|
1090
|
+
const sourceField = report.sourceField ?? SOURCE_FIELD[type];
|
|
1091
|
+
const source = await ask({
|
|
1092
|
+
section,
|
|
1093
|
+
message: `Fluid ${words(type)} name (or * for every one without its own row)`,
|
|
1094
|
+
kind: "text"
|
|
1095
|
+
}, true);
|
|
1096
|
+
if ("nav" in source) return source.nav === "back" ? { retry: true } : source;
|
|
1097
|
+
if (source.value.trim() === "") return { retry: true };
|
|
1098
|
+
const target = await askTarget(type, `${label} ${words(type)} for "${source.value.trim()}"`, section, true, false);
|
|
1099
|
+
if ("nav" in target) return target.nav === "back" ? { retry: true } : target;
|
|
1100
|
+
if (target.value.trim() === "") return { retry: true };
|
|
1101
|
+
const fields = {
|
|
1102
|
+
[sourceField]: source.value.trim(),
|
|
1103
|
+
[TARGET_FIELD[type]]: target.value.trim()
|
|
1104
|
+
};
|
|
1105
|
+
if (type === "ship_method") {
|
|
1106
|
+
const country = await ask({
|
|
1107
|
+
section,
|
|
1108
|
+
message: "Country code for this ship method (Enter for any country)",
|
|
1109
|
+
kind: "text"
|
|
1110
|
+
}, true);
|
|
1111
|
+
if ("nav" in country) return country.nav === "back" ? { retry: true } : country;
|
|
1112
|
+
if (country.value.trim() !== "") fields["country_code"] = country.value.trim().toUpperCase();
|
|
1113
|
+
}
|
|
1114
|
+
try {
|
|
1115
|
+
await createMapping(integrations, connection(), type, fields);
|
|
1116
|
+
} catch (error) {
|
|
1117
|
+
prompter.note(`Not saved: ${reasonOf(error)}`);
|
|
1118
|
+
return { retry: true };
|
|
1119
|
+
}
|
|
1120
|
+
result.rowsAdded += 1;
|
|
1121
|
+
const added = Number(answers.get(`${id}#count`) ?? "0") + 1;
|
|
1122
|
+
answers.set(`${id}#count`, String(added));
|
|
1123
|
+
answers.set(id, `${added} row(s) added`);
|
|
1124
|
+
prompter.note(`Saved: ${source.value.trim()} → ${target.value.trim()}.`);
|
|
1125
|
+
return { retry: true };
|
|
1126
|
+
}
|
|
1127
|
+
};
|
|
1128
|
+
};
|
|
1129
|
+
steps.push({
|
|
1130
|
+
id: "mappings-report",
|
|
1131
|
+
phase: "mappings",
|
|
1132
|
+
title: "Find what needs mapping",
|
|
1133
|
+
asks: false,
|
|
1134
|
+
run: async () => {
|
|
1135
|
+
prompter.note(autoMap ? "Mapping exact name matches, then checking what's left…" : "Checking what needs mapping…");
|
|
1136
|
+
const report = await unmappedReport(integrations, connection(), provider, autoMap);
|
|
1137
|
+
const fresh = [];
|
|
1138
|
+
let added = 0;
|
|
1139
|
+
for (const entry of report) {
|
|
1140
|
+
added += entry.autoMapped?.length ?? 0;
|
|
1141
|
+
if (entry.status === "needs_mapping" && entry.sourceField && TARGET_FIELD[entry.type]) entry.unmapped.forEach((value, i) => fresh.push(valueStep(entry, value, i + 1)));
|
|
1142
|
+
else if (entry.status === "needs_mapping") prompter.note(`${words(entry.type)}: ${entry.unmapped.length} value(s) to map. Add them with \`fluid connect map ${provider.id} ${entry.type} --set field=value --yes\`.`);
|
|
1143
|
+
else if (entry.status === "check_by_hand" && TARGET_FIELD[entry.type]) fresh.push(byHandStep(entry));
|
|
1144
|
+
else if (entry.status === "error") prompter.note(`${words(entry.type)}: couldn't read its values (${entry.error}). Check it later with \`fluid connect unmapped ${provider.id}\`.`);
|
|
1145
|
+
}
|
|
1146
|
+
if (added > 0) {
|
|
1147
|
+
result.autoMapped += added;
|
|
1148
|
+
prompter.note(`Added ${added} row(s) by matching names.`);
|
|
1149
|
+
}
|
|
1150
|
+
const toMap = fresh.filter((s) => s.id.startsWith("map:")).length;
|
|
1151
|
+
prompter.note(toMap === 0 ? "Every value Fluid lists is mapped." : `${toMap} value(s) to map. Pick the ${label} value for each; * covers every remaining value of that type.`);
|
|
1152
|
+
replaceGroup("mapping-values", "mappings-report", fresh);
|
|
1153
|
+
return DONE;
|
|
1154
|
+
}
|
|
1155
|
+
});
|
|
1156
|
+
const settingStep = (key, setting, position, total) => {
|
|
1157
|
+
return {
|
|
1158
|
+
id: `setting:${key}`,
|
|
1159
|
+
phase: "settings",
|
|
1160
|
+
group: "settings-values",
|
|
1161
|
+
title: setting.label ?? key,
|
|
1162
|
+
asks: true,
|
|
1163
|
+
run: async (canGoBack) => {
|
|
1164
|
+
const summary = summarizeSettings(ci).find((s) => s.key === key);
|
|
1165
|
+
const current = summary ? summary.value : setting.value;
|
|
1166
|
+
const fallbackLabel = (value) => setting.options?.find((o) => o.value === value)?.label ?? display(value);
|
|
1167
|
+
const question = {
|
|
1168
|
+
section: `${PHASE_NAMES.settings} · ${position} of ${total} · ${key}`,
|
|
1169
|
+
message: `${setting.label ?? key} (default: ${fallbackLabel(setting.value)})`,
|
|
1170
|
+
kind: "text",
|
|
1171
|
+
...setting.description ? { hint: setting.description } : {}
|
|
1172
|
+
};
|
|
1173
|
+
let choices;
|
|
1174
|
+
if (setting.options && setting.options.length > 0 && !setting.multiple) choices = setting.options.map((o, i) => ({
|
|
1175
|
+
title: `${o.label ?? display(o.value)}${o.value === setting.value ? " (default)" : ""}`,
|
|
1176
|
+
value: String(i),
|
|
1177
|
+
raw: o.value
|
|
1178
|
+
}));
|
|
1179
|
+
else if (setting.type === "boolean") choices = [{
|
|
1180
|
+
title: `Yes${setting.value === true ? " (default)" : ""}`,
|
|
1181
|
+
value: "yes",
|
|
1182
|
+
raw: true
|
|
1183
|
+
}, {
|
|
1184
|
+
title: `No${setting.value === false ? " (default)" : ""}`,
|
|
1185
|
+
value: "no",
|
|
1186
|
+
raw: false
|
|
1187
|
+
}];
|
|
1188
|
+
if (choices) {
|
|
1189
|
+
question.kind = "select";
|
|
1190
|
+
question.choices = choices.map(({ raw: _raw, ...c }) => c);
|
|
1191
|
+
const now = choices.find((c) => c.raw === current);
|
|
1192
|
+
if (now) question.current = now.title;
|
|
1193
|
+
} else {
|
|
1194
|
+
if (current !== null && current !== void 0 && current !== "") question.current = display(current);
|
|
1195
|
+
question.hint = [setting.description, "JSON for lists and objects"].filter(Boolean).join(" · ");
|
|
1196
|
+
}
|
|
1197
|
+
const reply = await ask(question, canGoBack);
|
|
1198
|
+
if ("nav" in reply) return reply;
|
|
1199
|
+
let value;
|
|
1200
|
+
if (choices) value = choices.find((c) => c.value === reply.value)?.raw;
|
|
1201
|
+
else {
|
|
1202
|
+
const raw = reply.value.trim();
|
|
1203
|
+
if (raw === "") return {
|
|
1204
|
+
done: true,
|
|
1205
|
+
answer: display(current)
|
|
1206
|
+
};
|
|
1207
|
+
try {
|
|
1208
|
+
value = /^(true|false|null|-?\d+(\.\d+)?)$|^[[{]/.test(raw) ? JSON.parse(raw) : raw;
|
|
1209
|
+
} catch {
|
|
1210
|
+
value = raw;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
if (JSON.stringify(value) === JSON.stringify(current)) return {
|
|
1214
|
+
done: true,
|
|
1215
|
+
answer: fallbackLabel(value)
|
|
1216
|
+
};
|
|
1217
|
+
try {
|
|
1218
|
+
ci = await updateSyncSettings(integrations, connection(), settingsPatch({ [key]: value }));
|
|
1219
|
+
} catch (error) {
|
|
1220
|
+
prompter.note(`Not saved: ${reasonOf(error)}`);
|
|
1221
|
+
return { retry: true };
|
|
1222
|
+
}
|
|
1223
|
+
if (!result.settingsChanged.includes(key)) result.settingsChanged.push(key);
|
|
1224
|
+
prompter.note(`Saved: ${setting.label ?? key} → ${fallbackLabel(value)}.`);
|
|
1225
|
+
return {
|
|
1226
|
+
done: true,
|
|
1227
|
+
answer: fallbackLabel(value)
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
};
|
|
1231
|
+
};
|
|
1232
|
+
steps.push({
|
|
1233
|
+
id: "settings-choice",
|
|
1234
|
+
phase: "settings",
|
|
1235
|
+
title: "Sync settings",
|
|
1236
|
+
asks: true,
|
|
1237
|
+
run: async (canGoBack) => {
|
|
1238
|
+
ci = await getCompanyIntegration(integrations, connection());
|
|
1239
|
+
const schema = ci.settings_schema ?? {};
|
|
1240
|
+
const common = COMMON_SETTINGS.flatMap((key) => {
|
|
1241
|
+
const [entity, name] = key.split(".");
|
|
1242
|
+
const setting = schema[entity]?.[name];
|
|
1243
|
+
return setting ? [{
|
|
1244
|
+
key,
|
|
1245
|
+
setting
|
|
1246
|
+
}] : [];
|
|
1247
|
+
});
|
|
1248
|
+
const reply = await ask({
|
|
1249
|
+
section: PHASE_NAMES.settings,
|
|
1250
|
+
message: `Sync settings. A new connection starts on the defaults, and each default is safe.`,
|
|
1251
|
+
kind: "select",
|
|
1252
|
+
choices: [
|
|
1253
|
+
{
|
|
1254
|
+
title: "Keep every setting as it is",
|
|
1255
|
+
value: "keep"
|
|
1256
|
+
},
|
|
1257
|
+
{
|
|
1258
|
+
title: `Go through the ${common.length} common settings`,
|
|
1259
|
+
value: "review"
|
|
1260
|
+
},
|
|
1261
|
+
{
|
|
1262
|
+
title: "Put every setting back to its default",
|
|
1263
|
+
value: "defaults"
|
|
1264
|
+
}
|
|
1265
|
+
]
|
|
1266
|
+
}, canGoBack);
|
|
1267
|
+
if ("nav" in reply) return reply;
|
|
1268
|
+
if (reply.value === "review") {
|
|
1269
|
+
replaceGroup("settings-values", "settings-choice", common.map(({ key, setting }, i) => settingStep(key, setting, i + 1, common.length)));
|
|
1270
|
+
return {
|
|
1271
|
+
done: true,
|
|
1272
|
+
answer: "Go through the common settings"
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
replaceGroup("settings-values", "settings-choice", []);
|
|
1276
|
+
if (reply.value === "defaults") {
|
|
1277
|
+
try {
|
|
1278
|
+
ci = await updateSyncSettings(integrations, connection(), defaultsPatch(schema));
|
|
1279
|
+
} catch (error) {
|
|
1280
|
+
prompter.note(`Not saved: ${reasonOf(error)}`);
|
|
1281
|
+
return { retry: true };
|
|
1282
|
+
}
|
|
1283
|
+
result.settingsRestored = true;
|
|
1284
|
+
prompter.note("Every setting is back on its default.");
|
|
1285
|
+
return {
|
|
1286
|
+
done: true,
|
|
1287
|
+
answer: "Put back to defaults"
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
return {
|
|
1291
|
+
done: true,
|
|
1292
|
+
answer: "Keep as is"
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
});
|
|
1296
|
+
const goBack = () => {
|
|
1297
|
+
let previous = history.pop();
|
|
1298
|
+
while (previous !== void 0 && indexOf(previous) < 0) previous = history.pop();
|
|
1299
|
+
if (previous === void 0) {
|
|
1300
|
+
prompter.note("This is the first question.");
|
|
1301
|
+
return false;
|
|
1302
|
+
}
|
|
1303
|
+
skipped.delete(previous);
|
|
1304
|
+
index = indexOf(previous);
|
|
1305
|
+
targeted = previous;
|
|
1306
|
+
return true;
|
|
1307
|
+
};
|
|
1308
|
+
/** True when the walk should stop. */
|
|
1309
|
+
const openMenu = async () => {
|
|
1310
|
+
const phases = Object.keys(PHASE_NAMES).filter((phase) => steps.some((s) => s.phase === phase));
|
|
1311
|
+
const answered = steps.filter((s) => s.asks && answers.has(s.id));
|
|
1312
|
+
const action = await prompter.menu("What next?", [
|
|
1313
|
+
{
|
|
1314
|
+
title: "Continue",
|
|
1315
|
+
value: "continue"
|
|
1316
|
+
},
|
|
1317
|
+
{
|
|
1318
|
+
title: "Back to the previous question",
|
|
1319
|
+
value: "back",
|
|
1320
|
+
disabled: history.length === 0
|
|
1321
|
+
},
|
|
1322
|
+
...phases.map((phase) => ({
|
|
1323
|
+
title: `Go to ${PHASE_NAMES[phase]}`,
|
|
1324
|
+
value: `phase:${phase}`
|
|
1325
|
+
})),
|
|
1326
|
+
{
|
|
1327
|
+
title: "Change an answer",
|
|
1328
|
+
value: "change",
|
|
1329
|
+
disabled: answered.length === 0
|
|
1330
|
+
},
|
|
1331
|
+
{
|
|
1332
|
+
title: "Finish: show the summary",
|
|
1333
|
+
value: "finish"
|
|
1334
|
+
},
|
|
1335
|
+
{
|
|
1336
|
+
title: "Stop for now (answers are saved)",
|
|
1337
|
+
value: "quit"
|
|
1338
|
+
}
|
|
1339
|
+
]);
|
|
1340
|
+
if (action === null || action === "quit") return true;
|
|
1341
|
+
if (action === "back") goBack();
|
|
1342
|
+
else if (action === "finish") index = steps.length;
|
|
1343
|
+
else if (action.startsWith("phase:")) {
|
|
1344
|
+
const phase = action.slice(6);
|
|
1345
|
+
index = indexOf(phase === "mappings" ? "mappings-start" : steps.find((s) => s.phase === phase).id);
|
|
1346
|
+
targeted = steps.find((s, i) => i >= index && s.asks)?.id;
|
|
1347
|
+
} else if (action === "change") {
|
|
1348
|
+
const id = await prompter.menu("Which answer?", answered.map((s) => ({
|
|
1349
|
+
title: `${s.title} — ${answers.get(s.id)}`,
|
|
1350
|
+
value: s.id
|
|
1351
|
+
})));
|
|
1352
|
+
if (id) {
|
|
1353
|
+
index = indexOf(id);
|
|
1354
|
+
targeted = id;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
return false;
|
|
1358
|
+
};
|
|
1359
|
+
while (index < steps.length) {
|
|
1360
|
+
const step = steps[index];
|
|
1361
|
+
if (step.when && !step.when()) {
|
|
1362
|
+
index += 1;
|
|
1363
|
+
continue;
|
|
1364
|
+
}
|
|
1365
|
+
if (step.asks && answers.has(step.id) && targeted !== step.id) {
|
|
1366
|
+
index += 1;
|
|
1367
|
+
continue;
|
|
1368
|
+
}
|
|
1369
|
+
const outcome = await step.run(history.length > 0);
|
|
1370
|
+
if ("stop" in outcome) {
|
|
1371
|
+
result.stopped = true;
|
|
1372
|
+
break;
|
|
1373
|
+
}
|
|
1374
|
+
if ("goto" in outcome) {
|
|
1375
|
+
index = indexOf(outcome.goto);
|
|
1376
|
+
targeted = steps[index]?.id;
|
|
1377
|
+
continue;
|
|
1378
|
+
}
|
|
1379
|
+
if ("retry" in outcome) {
|
|
1380
|
+
targeted = step.id;
|
|
1381
|
+
continue;
|
|
1382
|
+
}
|
|
1383
|
+
if ("nav" in outcome) {
|
|
1384
|
+
if (outcome.nav === "quit") {
|
|
1385
|
+
result.stopped = true;
|
|
1386
|
+
break;
|
|
1387
|
+
}
|
|
1388
|
+
if (outcome.nav === "back") {
|
|
1389
|
+
if (!goBack()) targeted = step.id;
|
|
1390
|
+
continue;
|
|
1391
|
+
}
|
|
1392
|
+
if (outcome.nav === "skip") {
|
|
1393
|
+
skipped.set(step.id, step.title);
|
|
1394
|
+
history.push(step.id);
|
|
1395
|
+
index += 1;
|
|
1396
|
+
continue;
|
|
1397
|
+
}
|
|
1398
|
+
targeted = step.id;
|
|
1399
|
+
if (await openMenu()) {
|
|
1400
|
+
result.stopped = true;
|
|
1401
|
+
break;
|
|
1402
|
+
}
|
|
1403
|
+
continue;
|
|
1404
|
+
}
|
|
1405
|
+
if (step.asks) {
|
|
1406
|
+
history.push(step.id);
|
|
1407
|
+
skipped.delete(step.id);
|
|
1408
|
+
answers.set(step.id, outcome.answer ?? "");
|
|
1409
|
+
}
|
|
1410
|
+
targeted = void 0;
|
|
1411
|
+
index += 1;
|
|
1412
|
+
}
|
|
1413
|
+
status = await providerStatus(integrations, provider);
|
|
1414
|
+
result.connected = status.connected;
|
|
1415
|
+
result.skipped = [...skipped.values()];
|
|
1416
|
+
try {
|
|
1417
|
+
if (ciId === null) throw new ConnectError("there's no connection yet");
|
|
1418
|
+
const report = await unmappedReport(integrations, ciId, provider, false);
|
|
1419
|
+
result.mappingsDone = report.every((r) => r.status === "covered");
|
|
1420
|
+
result.stillUnmapped = report.filter((r) => r.status !== "covered").map((r) => ({
|
|
1421
|
+
type: r.type,
|
|
1422
|
+
count: r.unmapped?.length ?? null,
|
|
1423
|
+
status: r.status
|
|
1424
|
+
}));
|
|
1425
|
+
} catch (error) {
|
|
1426
|
+
prompter.note(`Couldn't check the mappings: ${reasonOf(error)}`);
|
|
1427
|
+
}
|
|
1428
|
+
const lines = [
|
|
1429
|
+
`${label}: ${result.connected ? "connected" : "not connected yet"}.`,
|
|
1430
|
+
result.credentialsSaved ? "Credentials saved and tested." : "",
|
|
1431
|
+
result.autoMapped + result.rowsAdded > 0 ? `Mapping rows added: ${result.rowsAdded}${result.autoMapped > 0 ? `, plus ${result.autoMapped} by name` : ""}.` : "",
|
|
1432
|
+
result.mappingsDone ? "Every mapping type is covered." : result.stillUnmapped.length > 0 ? `Still to map or check: ${result.stillUnmapped.map((r) => `${words(r.type)}${r.count ? ` (${r.count})` : r.status === "check_by_hand" ? " (check by hand)" : ""}`).join(", ")}. See \`fluid connect unmapped ${provider.id}\`.` : "",
|
|
1433
|
+
result.settingsRestored ? "Every sync setting is on its default." : "",
|
|
1434
|
+
result.settingsChanged.length > 0 ? `Settings changed: ${result.settingsChanged.join(", ")}.` : "",
|
|
1435
|
+
result.skipped.length > 0 ? `Skipped: ${result.skipped.join("; ")}.` : "",
|
|
1436
|
+
`Run \`fluid connect walk ${provider.id}\` again to pick up where you left off.`
|
|
1437
|
+
].filter(Boolean);
|
|
1438
|
+
prompter.note(`\n${lines.join("\n")}`);
|
|
1439
|
+
return result;
|
|
1440
|
+
}
|
|
1441
|
+
//#endregion
|
|
1442
|
+
//#region src/index.ts
|
|
1443
|
+
async function fluidClient(ctx) {
|
|
1444
|
+
return await ctx.getClient();
|
|
1445
|
+
}
|
|
1446
|
+
async function integrationsClient(ctx) {
|
|
1447
|
+
return await ctx.getClientFor(integrationsBase());
|
|
1448
|
+
}
|
|
1449
|
+
/**
|
|
1450
|
+
* Changes to a live connection need an explicit yes. Scripts and agents
|
|
1451
|
+
* pass `--yes`; an interactive terminal is asked. With neither, nothing runs.
|
|
1452
|
+
*/
|
|
1453
|
+
async function confirmChange(question, yes, interactive = Boolean(process.stdin.isTTY)) {
|
|
1454
|
+
if (yes) return;
|
|
1455
|
+
if (!interactive) throw new ConnectError(`${question} Pass --yes to confirm.`);
|
|
1456
|
+
if (!(await prompts({
|
|
1457
|
+
type: "confirm",
|
|
1458
|
+
name: "ok",
|
|
1459
|
+
message: question,
|
|
1460
|
+
initial: false
|
|
1461
|
+
})).ok) throw new ConnectError("Cancelled.");
|
|
1462
|
+
}
|
|
1463
|
+
/** Print a failure as JSON on stderr and exit non-zero. A failed connection
|
|
1464
|
+
* test (422) carries the provider's reason in `errors`. */
|
|
1465
|
+
function fail(err) {
|
|
1466
|
+
const status = typeof err.status === "number" ? err.status : void 0;
|
|
1467
|
+
const body = err.body;
|
|
1468
|
+
const detail = body && (body["errors"] ?? body["error"] ?? body["error_message"]);
|
|
1469
|
+
const message = typeof detail === "string" ? detail : err instanceof Error ? err.message : String(err);
|
|
1470
|
+
process.stderr.write(JSON.stringify({
|
|
1471
|
+
error: message,
|
|
1472
|
+
...status ? { status } : {},
|
|
1473
|
+
...detail && typeof detail === "object" ? { details: detail } : {}
|
|
1474
|
+
}) + "\n");
|
|
1475
|
+
process.exitCode = 1;
|
|
1476
|
+
}
|
|
1477
|
+
function run(action) {
|
|
1478
|
+
return action().catch(fail);
|
|
1479
|
+
}
|
|
1480
|
+
const collect = (value, previous = []) => [...previous, value];
|
|
1481
|
+
function registerConnectCommands(parent, ctx) {
|
|
1482
|
+
parent.command("list").description(`Show ${PROVIDER_IDS.join(", ")} and whether each is installed`).action(() => run(async () => {
|
|
1483
|
+
const integrations = await integrationsClient(ctx);
|
|
1484
|
+
ctx.output(await Promise.all(PROVIDER_IDS.map((id) => providerStatus(integrations, providerOf(id)))));
|
|
1485
|
+
}));
|
|
1486
|
+
parent.command("install <provider>").description("Install the provider's droplet, which creates its connection (changes live config)").option("--yes", "Confirm without a prompt", false).action((providerId, opts) => run(async () => {
|
|
1487
|
+
const provider = providerOf(providerId);
|
|
1488
|
+
const status = await providerStatus(await integrationsClient(ctx), provider);
|
|
1489
|
+
if (status.installed) {
|
|
1490
|
+
ctx.output({
|
|
1491
|
+
...status,
|
|
1492
|
+
alreadyInstalled: true
|
|
1493
|
+
});
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
await confirmChange(`Install ${provider.label} for this company?`, opts.yes);
|
|
1497
|
+
ctx.output(await installProvider(await fluidClient(ctx), provider, status.dropletUuid));
|
|
1498
|
+
}));
|
|
1499
|
+
parent.command("show <provider>").description("Show the connection: state, supported mappings, and every sync setting with its current value").action((providerId) => run(async () => {
|
|
1500
|
+
const provider = providerOf(providerId);
|
|
1501
|
+
const integrations = await integrationsClient(ctx);
|
|
1502
|
+
const ci = await getCompanyIntegration(integrations, await companyIntegrationId(integrations, provider));
|
|
1503
|
+
ctx.output({
|
|
1504
|
+
provider: provider.id,
|
|
1505
|
+
companyIntegrationId: ci.id,
|
|
1506
|
+
initialized: ci.initialized ?? null,
|
|
1507
|
+
environment: ci.environment ?? null,
|
|
1508
|
+
liveSyncState: ci.live_sync_state ?? null,
|
|
1509
|
+
liveSyncDegradedReason: ci.live_sync_degraded_reason ?? null,
|
|
1510
|
+
credentialGroups: provider.credentialGroups,
|
|
1511
|
+
supportedMappings: ci.supported_mappings ?? [],
|
|
1512
|
+
settings: summarizeSettings(ci)
|
|
1513
|
+
});
|
|
1514
|
+
}));
|
|
1515
|
+
parent.command("credentials <provider>").description("Save credentials; the connection is tested before anything is saved, and created if the install has not yet (changes live config). Values here land in shell history; `fluid connect walk` asks for passwords hidden").option("--set <key=value>", "A credential, e.g. api.username=alice or mssql.dataserver=sql.example.com (repeatable)", collect, []).option("--yes", "Confirm without a prompt", false).action((providerId, opts) => run(async () => {
|
|
1516
|
+
const provider = providerOf(providerId);
|
|
1517
|
+
if (opts.set.length === 0) throw new ConnectError(`Pass credentials with --set, e.g. --set api.username=alice. ${provider.label} uses: ${provider.credentialGroups.join(", ")}.`);
|
|
1518
|
+
const authSettings = parseAssignments(opts.set, { strings: true });
|
|
1519
|
+
const integrations = await integrationsClient(ctx);
|
|
1520
|
+
await confirmChange(`Save new ${provider.label} credentials?`, opts.yes);
|
|
1521
|
+
ctx.output(await saveCredentials(integrations, await fluidClient(ctx), provider, authSettings));
|
|
1522
|
+
}));
|
|
1523
|
+
parent.command("settings <provider>").description("List sync settings, change them with --set entity.key=value, or restore defaults with --defaults (changes live config)").option("--set <key=value>", "A setting from `fluid connect show`, e.g. order.refund_handling=hold (repeatable)", collect, []).option("--defaults", "Restore settings to their defaults: all of them, or only those named with --only", false).option("--only <key>", "With --defaults, restore just this setting, e.g. order.refund_handling (repeatable)", collect, []).option("--yes", "Confirm without a prompt", false).action((providerId, opts) => run(async () => {
|
|
1524
|
+
const provider = providerOf(providerId);
|
|
1525
|
+
if (opts.defaults && opts.set.length > 0) throw new ConnectError("Use --set or --defaults, not both.");
|
|
1526
|
+
if (opts.only.length > 0 && !opts.defaults) throw new ConnectError("--only goes with --defaults.");
|
|
1527
|
+
const integrations = await integrationsClient(ctx);
|
|
1528
|
+
const ciId = await companyIntegrationId(integrations, provider);
|
|
1529
|
+
if (opts.defaults) {
|
|
1530
|
+
const patch = defaultsPatch((await getCompanyIntegration(integrations, ciId)).settings_schema ?? {}, opts.only);
|
|
1531
|
+
await confirmChange(`Restore ${opts.only.length > 0 ? `${opts.only.length} setting(s)` : "every sync setting"} to the default for ${provider.label}?`, opts.yes);
|
|
1532
|
+
ctx.output(summarizeSettings(await updateSyncSettings(integrations, ciId, patch)));
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
if (opts.set.length === 0) {
|
|
1536
|
+
ctx.output(summarizeSettings(await getCompanyIntegration(integrations, ciId)));
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
const patch = syncSettingsPatch(opts.set);
|
|
1540
|
+
await confirmChange(`Change ${opts.set.length} ${provider.label} sync setting(s)?`, opts.yes);
|
|
1541
|
+
ctx.output(summarizeSettings(await updateSyncSettings(integrations, ciId, patch)));
|
|
1542
|
+
}));
|
|
1543
|
+
parent.command("walk <provider>").description("Set up the connection question by question in the terminal: install, credentials, mappings, and settings. Go back with <, skip with >, open the menu with ?; each answer is saved as you go (changes live config)").action((providerId) => run(async () => {
|
|
1544
|
+
assertInteractive("`install`, `credentials`, `unmapped`, `map`, and `settings` with --yes (see `fluid connect --help`)");
|
|
1545
|
+
const provider = providerOf(providerId);
|
|
1546
|
+
ctx.output(await walkConnect({
|
|
1547
|
+
fluid: await fluidClient(ctx),
|
|
1548
|
+
integrations: await integrationsClient(ctx),
|
|
1549
|
+
provider,
|
|
1550
|
+
prompter: terminalPrompter()
|
|
1551
|
+
}));
|
|
1552
|
+
}));
|
|
1553
|
+
parent.command("unmapped <provider>").description("List only the data mappings a person still has to add; --auto-map first maps exact name matches (Exigo)").option("--auto-map", "Exigo: add rows where a Fluid value's name matches exactly one Exigo value (changes live config)", false).option("--yes", "Confirm --auto-map without a prompt", false).action((providerId, opts) => run(async () => {
|
|
1554
|
+
const provider = providerOf(providerId);
|
|
1555
|
+
const integrations = await integrationsClient(ctx);
|
|
1556
|
+
const ciId = await companyIntegrationId(integrations, provider);
|
|
1557
|
+
if (opts.autoMap) {
|
|
1558
|
+
if (!provider.targetOptions) throw new ConnectError(`--auto-map needs ${provider.label}'s own values, which only Exigo lists. Run without it to see what to map.`);
|
|
1559
|
+
await confirmChange(`Add ${provider.label} mappings for exact name matches?`, opts.yes);
|
|
1560
|
+
}
|
|
1561
|
+
const report = await unmappedReport(integrations, ciId, provider, opts.autoMap);
|
|
1562
|
+
ctx.output({
|
|
1563
|
+
provider: provider.id,
|
|
1564
|
+
done: report.every((r) => r.status === "covered"),
|
|
1565
|
+
mappings: report
|
|
1566
|
+
});
|
|
1567
|
+
if (report.some((r) => r.status === "error")) process.exitCode = 1;
|
|
1568
|
+
}));
|
|
1569
|
+
parent.command("mappings <provider> <type>").description("List one mapping type's rows (e.g. customer_type, warehouse); --options lists the values to choose from").option("--options", "List the Fluid values, and for Exigo the Exigo values, instead of the saved rows", false).action((providerId, type, opts) => run(async () => {
|
|
1570
|
+
const provider = providerOf(providerId);
|
|
1571
|
+
const integrations = await integrationsClient(ctx);
|
|
1572
|
+
const ciId = await companyIntegrationId(integrations, provider);
|
|
1573
|
+
ctx.output(opts.options ? await mappingOptions(integrations, ciId, type, provider) : await listMappings(integrations, ciId, type));
|
|
1574
|
+
}));
|
|
1575
|
+
parent.command("map <provider> <type>").description("Add a mapping row, e.g. --set source_type_slug=rep --set target_type_id=2 (changes live config)").option("--set <field=value>", "A field of the mapping row (repeatable)", collect, []).option("--yes", "Confirm without a prompt", false).action((providerId, type, opts) => run(async () => {
|
|
1576
|
+
const provider = providerOf(providerId);
|
|
1577
|
+
if (opts.set.length === 0) throw new ConnectError("Pass the row's fields with --set field=value.");
|
|
1578
|
+
const integrations = await integrationsClient(ctx);
|
|
1579
|
+
const ciId = await companyIntegrationId(integrations, provider);
|
|
1580
|
+
await confirmChange(`Add a ${type} mapping to ${provider.label}?`, opts.yes);
|
|
1581
|
+
ctx.output(await createMapping(integrations, ciId, type, parseAssignments(opts.set)));
|
|
1582
|
+
}));
|
|
1583
|
+
parent.command("unmap <provider> <type> <id>").description("Remove one mapping row (changes live config)").option("--yes", "Confirm without a prompt", false).action((providerId, type, id, opts) => run(async () => {
|
|
1584
|
+
const provider = providerOf(providerId);
|
|
1585
|
+
const rowId = Number(id);
|
|
1586
|
+
if (!Number.isInteger(rowId) || rowId <= 0) throw new ConnectError(`"${id}" isn't a mapping row id.`);
|
|
1587
|
+
const integrations = await integrationsClient(ctx);
|
|
1588
|
+
const ciId = await companyIntegrationId(integrations, provider);
|
|
1589
|
+
await confirmChange(`Remove ${type} mapping ${rowId} from ${provider.label}?`, opts.yes);
|
|
1590
|
+
ctx.output(await deleteMapping(integrations, ciId, type, rowId));
|
|
1591
|
+
}));
|
|
1592
|
+
}
|
|
1593
|
+
const plugin = {
|
|
1594
|
+
name: "@fluid-app/fluid-cli-connect",
|
|
1595
|
+
version: "0.1.0",
|
|
1596
|
+
register(ctx) {
|
|
1597
|
+
ctx.program.addCommand(createDomainCommand("connect", "Connect Exigo, InfoTrax, ByDesign, or Pillars: credentials, data mappings, and sync settings", registerConnectCommands));
|
|
1598
|
+
}
|
|
1599
|
+
};
|
|
1600
|
+
//#endregion
|
|
1601
|
+
export { confirmChange, plugin as default, registerConnectCommands };
|
|
1602
|
+
|
|
1603
|
+
//# sourceMappingURL=index.mjs.map
|