@audienti/cli 0.1.19 → 0.1.21
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/CHANGELOG.md +12 -0
- package/README.md +28 -6
- package/package.json +1 -1
- package/skills/audienti/SKILL.md +14 -0
- package/src/api-client.js +35 -6
- package/src/cli.js +235 -182
- package/src/config.js +62 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ All notable changes to the Audienti CLI are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.1.21] - 2026-07-20
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Show scoped help when a CLI command prefix is entered without the required remaining command or arguments, accept `account` as an alias for `accounts`, make `writer test-run` build the timeline without drafting every message by default, back writer test runs with a reusable report session instead of a CLI-local cache, run writer report work through a queued job that the CLI polls instead of holding one long HTTP request open, add `--report <rprt_id>` to continue a writer session, add `--no-wait` for launching queued writer work and coming back later, add `writer test-run show <prsp_id> <rprt_id>` to retrieve queued writer output after it finishes, let the workspace `bin/cli` reuse the global account selection without carrying bearer tokens across hosts, and explain API network failures with the configured host.
|
|
12
|
+
|
|
13
|
+
## [0.1.20] - 2026-07-19
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Track the account API contract update that limits root account routes to supported actions and adds scoped account show responses.
|
|
18
|
+
|
|
7
19
|
## [0.1.19] - 2026-07-17
|
|
8
20
|
|
|
9
21
|
### Added
|
package/README.md
CHANGED
|
@@ -148,23 +148,45 @@ and one AccountProspect.created_at cohort:
|
|
|
148
148
|
audienti analytics users --user me --start 2026-07-01 --end 2026-07-07 --cohort-start 2026-06-01 --cohort-end 2026-06-30 --motion <motn_id>
|
|
149
149
|
```
|
|
150
150
|
|
|
151
|
-
To
|
|
152
|
-
planned actions, channel changes, and
|
|
151
|
+
To start a report-backed writer session for one prospect, including the no-reply
|
|
152
|
+
path, planned actions, channel changes, and planned message rows:
|
|
153
153
|
|
|
154
154
|
```bash
|
|
155
155
|
audienti writer test-run <prsp_id>
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
-
|
|
158
|
+
The output includes a `Report: rprt_...` id. Treat that report id as the writing
|
|
159
|
+
session handle while the generated report is retained.
|
|
160
|
+
|
|
161
|
+
To draft every message into that report-backed session, opt into full report
|
|
162
|
+
mode:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
audienti writer test-run <prsp_id> --mode report --report <rprt_id>
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
For writer debugging, draft only one selected timeline row on one branch into
|
|
169
|
+
the same report:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
audienti writer test-run <prsp_id> --mode step --branch no-accept --step 3 --report <rprt_id>
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The CLI queues the report job on the API and polls until the report finishes, so
|
|
176
|
+
slower writer calls do not depend on a single long HTTP request. Use
|
|
177
|
+
`--timeout-seconds <n>` to adjust the CLI wait budget.
|
|
178
|
+
|
|
179
|
+
To queue work into the report and come back later:
|
|
159
180
|
|
|
160
181
|
```bash
|
|
161
|
-
audienti writer test-run <prsp_id> --mode
|
|
182
|
+
audienti writer test-run <prsp_id> --mode step --branch no-accept --step 3 --report <rprt_id> --no-wait
|
|
162
183
|
```
|
|
163
184
|
|
|
164
|
-
|
|
185
|
+
If the CLI stops waiting before the server job finishes, fetch the completed
|
|
186
|
+
report later with:
|
|
165
187
|
|
|
166
188
|
```bash
|
|
167
|
-
audienti writer test-run <prsp_id>
|
|
189
|
+
audienti writer test-run show <prsp_id> <rprt_id>
|
|
168
190
|
```
|
|
169
191
|
|
|
170
192
|
To update a prospect's attached profile channels through the same paths used by
|
package/package.json
CHANGED
package/skills/audienti/SKILL.md
CHANGED
|
@@ -72,4 +72,18 @@ audienti tools linkedin-review --url https://www.linkedin.com/in/example --icp <
|
|
|
72
72
|
audienti tools linkedin-review reports --json
|
|
73
73
|
audienti tools linkedin-review show <rprt_id> --json
|
|
74
74
|
audienti tools linkedin-review status <rprt_id> --json
|
|
75
|
+
audienti writer test-run <prsp_id>
|
|
76
|
+
audienti writer test-run <prsp_id> --mode report --report <rprt_id>
|
|
77
|
+
audienti writer test-run <prsp_id> --mode step --branch no-accept --step 3 --report <rprt_id>
|
|
78
|
+
audienti writer test-run <prsp_id> --mode step --branch no-accept --step 3 --report <rprt_id> --no-wait
|
|
79
|
+
audienti writer test-run show <prsp_id> <rprt_id>
|
|
75
80
|
```
|
|
81
|
+
|
|
82
|
+
`audienti writer test-run <prsp_id>` starts a report-backed writing session and
|
|
83
|
+
builds the campaign timeline without drafting every message. The printed
|
|
84
|
+
`Report: rprt_...` id is the session handle. Pass `--report <rprt_id>` when
|
|
85
|
+
drafting every message with `--mode report` or one selected row with
|
|
86
|
+
`--mode step`; the server report supplies prior drafted rows as context. Use
|
|
87
|
+
`--timeout-seconds <n>` for longer waits, or `--no-wait` to launch and return
|
|
88
|
+
immediately. Use `writer test-run show` with the report id to fetch the
|
|
89
|
+
completed report later.
|
package/src/api-client.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
export const DEFAULT_HOST = "https://app.audienti.com";
|
|
2
2
|
|
|
3
3
|
export class ApiError extends Error {
|
|
4
|
-
constructor(message, { status, body } = {}) {
|
|
4
|
+
constructor(message, { status, body, cause } = {}) {
|
|
5
5
|
super(message);
|
|
6
6
|
this.name = "ApiError";
|
|
7
7
|
this.status = status;
|
|
8
8
|
this.body = body;
|
|
9
|
+
this.cause = cause;
|
|
9
10
|
}
|
|
10
11
|
}
|
|
11
12
|
|
|
@@ -433,6 +434,17 @@ export class AudientiClient {
|
|
|
433
434
|
});
|
|
434
435
|
}
|
|
435
436
|
|
|
437
|
+
createProspectSequenceExportJob(accountId, prospectId, body = {}) {
|
|
438
|
+
return this.requestJson(accountPath(accountId, ["prospects", prospectId, "sequence_export_jobs"]), {
|
|
439
|
+
method: "POST",
|
|
440
|
+
body
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
prospectSequenceExportJob(accountId, prospectId, jobId) {
|
|
445
|
+
return this.requestJson(accountPath(accountId, ["prospects", prospectId, "sequence_export_jobs", jobId]));
|
|
446
|
+
}
|
|
447
|
+
|
|
436
448
|
addProspectNote(accountId, prospectId, body) {
|
|
437
449
|
return this.requestJson(accountPath(accountId, ["prospects", prospectId, "add_note"]), {
|
|
438
450
|
method: "POST",
|
|
@@ -516,11 +528,18 @@ export class AudientiClient {
|
|
|
516
528
|
}
|
|
517
529
|
|
|
518
530
|
async requestJson(path, { method = "GET", body } = {}) {
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
531
|
+
const url = new URL(path, `${this.host}/`);
|
|
532
|
+
let response;
|
|
533
|
+
|
|
534
|
+
try {
|
|
535
|
+
response = await this.fetchImpl(url, {
|
|
536
|
+
method,
|
|
537
|
+
headers: this.headers(body),
|
|
538
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
539
|
+
});
|
|
540
|
+
} catch (error) {
|
|
541
|
+
throw new ApiError(networkErrorMessage(url, error), { cause: error });
|
|
542
|
+
}
|
|
524
543
|
|
|
525
544
|
const responseBody = await parseBody(response);
|
|
526
545
|
|
|
@@ -608,3 +627,13 @@ function errorMessage(status, body) {
|
|
|
608
627
|
|
|
609
628
|
return `Audienti API request failed with HTTP ${status}.`;
|
|
610
629
|
}
|
|
630
|
+
|
|
631
|
+
function networkErrorMessage(url, error) {
|
|
632
|
+
const detail = error?.message ? ` (${error.message})` : "";
|
|
633
|
+
return [
|
|
634
|
+
`Unable to reach Audienti API at ${url.origin}.`,
|
|
635
|
+
"Check that the app is running and the configured host is correct.",
|
|
636
|
+
"For workspace-local CLI auth, start the workspace app server first or pass `--host <url>` to `auth token`.",
|
|
637
|
+
detail
|
|
638
|
+
].join(" ").trim();
|
|
639
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parseArgs } from "node:util";
|
|
2
|
-
import {
|
|
3
|
-
import { basename,
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
4
|
import { ApiError, AudientiClient, DEFAULT_HOST, normalizeHost } from "./api-client.js";
|
|
5
5
|
import { configPath, deleteConfig, maskToken, readConfig, writeConfig } from "./config.js";
|
|
6
6
|
|
|
@@ -17,6 +17,8 @@ const DEFAULT_LIST_LIMIT = 20;
|
|
|
17
17
|
const API_MAX_LIST_LIMIT = 100;
|
|
18
18
|
const DEFAULT_LOOKUP_TIMEOUT_SECONDS = 60;
|
|
19
19
|
const DEFAULT_LOOKUP_POLL_INTERVAL_SECONDS = 2;
|
|
20
|
+
const DEFAULT_WRITER_TEST_RUN_TIMEOUT_SECONDS = 180;
|
|
21
|
+
const DEFAULT_WRITER_TEST_RUN_POLL_INTERVAL_SECONDS = 2;
|
|
20
22
|
const PACKAGE_NAME = "@audienti/cli";
|
|
21
23
|
const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
|
|
22
24
|
const DEFAULT_PROFILE_IDENTIFIERS = [
|
|
@@ -53,7 +55,8 @@ const USERS_ACTIVITY_USAGE = "Usage: audienti users activity [account_user_id|me
|
|
|
53
55
|
const OFFERS_SHOW_USAGE = "Usage: audienti offers show <offr_id> [--json] [--account <acct_id>]";
|
|
54
56
|
const OFFERS_UPDATE_USAGE = "Usage: audienti offers update <offr_id> [--name <text>] [--description <text>] [--url <url>] [--json] [--account <acct_id>]";
|
|
55
57
|
const OFFERS_DELETE_USAGE = "Usage: audienti offers delete <offr_id> --confirm <yes|true|Y|y> [--json] [--account <acct_id>]";
|
|
56
|
-
const WRITER_TEST_RUN_USAGE = "Usage: audienti writer test-run <prsp_id> [--json] [--mode <report|
|
|
58
|
+
const WRITER_TEST_RUN_USAGE = "Usage: audienti writer test-run <prsp_id> [--json] [--mode <plan|report|step>] [--branch <both|no-accept|accepted>] [--step <step_key|row_number>] [--report <rprt_id>] [--no-wait] [--timeout-seconds <n>] [--poll-interval-seconds <n>] [--account <acct_id>]";
|
|
59
|
+
const WRITER_TEST_RUN_SHOW_USAGE = "Usage: audienti writer test-run show <prsp_id> <rprt_id> [--json] [--account <acct_id>]";
|
|
57
60
|
const MOTIONS_ANALYTICS_USAGE = "Usage: audienti motions analytics <motn_id> [--window 30d] [--json] [--account <acct_id>]";
|
|
58
61
|
const MOTIONS_UPDATE_USAGE = "Usage: audienti motions update <motn_id> [--status <draft|preparing|active|paused|archived>] [--tags <tag[,tag...]>] [--own-post-engagement <true|false>] [--json] [--account <acct_id>]";
|
|
59
62
|
const CONTENT_PROGRAMS_USAGE = "Usage: audienti content programs [--user <account_user_id|email|name|me>] [--json] [--account <acct_id>]";
|
|
@@ -103,7 +106,6 @@ const COHORT_STAGE_ORDER = [
|
|
|
103
106
|
"cancel"
|
|
104
107
|
];
|
|
105
108
|
const DAY_OF_WEEK_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
106
|
-
const WRITER_TEST_RUN_CACHE_VERSION = 1;
|
|
107
109
|
const SEQUENCE_EXPORT_CSV_COLUMNS = [
|
|
108
110
|
"prospect_id",
|
|
109
111
|
"prospect_name",
|
|
@@ -158,6 +160,12 @@ async function dispatch(argv, context) {
|
|
|
158
160
|
return 0;
|
|
159
161
|
}
|
|
160
162
|
|
|
163
|
+
const implicitHelpTopic = incompleteHelpTopicFromArgs(args);
|
|
164
|
+
if (implicitHelpTopic) {
|
|
165
|
+
writeLine(context.stdout, helpFor(implicitHelpTopic));
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
161
169
|
const [resource, action, ...rest] = args;
|
|
162
170
|
const normalizedResource = normalizeResource(resource);
|
|
163
171
|
|
|
@@ -303,7 +311,42 @@ function helpTopicFromArgs(args) {
|
|
|
303
311
|
return normalizeTopicParts(args.slice(0, helpIndex));
|
|
304
312
|
}
|
|
305
313
|
|
|
314
|
+
function incompleteHelpTopicFromArgs(args) {
|
|
315
|
+
if (args.length === 0 || args[0] === "help") return null;
|
|
316
|
+
if (args.some((arg) => arg === "--help" || arg === "-h" || arg.startsWith("-"))) return null;
|
|
317
|
+
|
|
318
|
+
const topicParts = normalizeTopicParts(args);
|
|
319
|
+
const topic = topicParts.join(" ").trim();
|
|
320
|
+
const helpText = HELP_TOPICS.get(topic);
|
|
321
|
+
if (!helpText) return null;
|
|
322
|
+
|
|
323
|
+
return usageAllowsExactCommand(helpText, topic) ? null : topicParts;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function usageAllowsExactCommand(helpText, topic) {
|
|
327
|
+
return usageCommandsFor(helpText).some((command) => usageCommandAllowsExactTopic(command, topic));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function usageCommandsFor(helpText) {
|
|
331
|
+
return helpText.split("\n").flatMap((line) => {
|
|
332
|
+
const trimmed = line.trim();
|
|
333
|
+
if (trimmed.startsWith("audienti ")) return [trimmed.slice("audienti ".length)];
|
|
334
|
+
|
|
335
|
+
const oneLineUsage = trimmed.match(/^Usage:\s+audienti\s+(.+)$/);
|
|
336
|
+
return oneLineUsage ? [oneLineUsage[1]] : [];
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function usageCommandAllowsExactTopic(command, topic) {
|
|
341
|
+
if (command === topic) return true;
|
|
342
|
+
if (!command.startsWith(`${topic} `)) return false;
|
|
343
|
+
|
|
344
|
+
const remainder = command.slice(topic.length).trimStart();
|
|
345
|
+
return remainder.startsWith("[");
|
|
346
|
+
}
|
|
347
|
+
|
|
306
348
|
function normalizeTopicParts(parts) {
|
|
349
|
+
if (parts[0] === "account") return ["accounts", ...parts.slice(1)];
|
|
307
350
|
if (parts[0] === "plays") return ["motions", ...parts.slice(1)];
|
|
308
351
|
if (parts[0] === "principals") return ["users", ...parts.slice(1)];
|
|
309
352
|
if (parts[0] === "writers") return ["writer", ...parts.slice(1)];
|
|
@@ -311,6 +354,7 @@ function normalizeTopicParts(parts) {
|
|
|
311
354
|
}
|
|
312
355
|
|
|
313
356
|
function normalizeResource(resource) {
|
|
357
|
+
if (resource === "account") return "accounts";
|
|
314
358
|
if (resource === "principals") return "users";
|
|
315
359
|
if (resource === "writers") return "writer";
|
|
316
360
|
if (resource === "company_rules" || resource === "company-rules") return "company-rules";
|
|
@@ -2000,6 +2044,10 @@ async function prospectsSequencePreview(args, context, { accountOverride } = {})
|
|
|
2000
2044
|
}
|
|
2001
2045
|
|
|
2002
2046
|
async function writerTestRun(args, context, { accountOverride } = {}) {
|
|
2047
|
+
if (["show", "status"].includes(args[0])) {
|
|
2048
|
+
return writerTestRunShow(args.slice(1), context, { accountOverride });
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2003
2051
|
const { values, positionals } = parseCommandArgs(args, {
|
|
2004
2052
|
...jsonOptions(),
|
|
2005
2053
|
branch: { type: "string" },
|
|
@@ -2007,8 +2055,10 @@ async function writerTestRun(args, context, { accountOverride } = {}) {
|
|
|
2007
2055
|
mode: { type: "string" },
|
|
2008
2056
|
step: { type: "string" },
|
|
2009
2057
|
"angle-index": { type: "string" },
|
|
2010
|
-
|
|
2011
|
-
"
|
|
2058
|
+
report: { type: "string" },
|
|
2059
|
+
"timeout-seconds": { type: "string" },
|
|
2060
|
+
"poll-interval-seconds": { type: "string" },
|
|
2061
|
+
"no-wait": { type: "boolean" }
|
|
2012
2062
|
});
|
|
2013
2063
|
if (positionals.length !== 1) throw new CommandError(WRITER_TEST_RUN_USAGE);
|
|
2014
2064
|
if (values.branch && values.branches) throw new CommandError("Choose one branch filter: use either --branch or --branches.");
|
|
@@ -2019,28 +2069,65 @@ async function writerTestRun(args, context, { accountOverride } = {}) {
|
|
|
2019
2069
|
const { client, accountId } = await requireAccountContext(context, { accountOverride });
|
|
2020
2070
|
const prospectId = positionals[0];
|
|
2021
2071
|
const branchFilter = values.branches || values.branch || "both";
|
|
2022
|
-
const
|
|
2023
|
-
if (useCache && values["clear-cache"]) await clearWriterTestRunCache(context, { accountId, prospectId });
|
|
2024
|
-
const cache = useCache ? await loadWriterTestRunCache(context, { accountId, prospectId }) : emptyWriterTestRunCache(context, { accountId, prospectId });
|
|
2025
|
-
const cachedDrafts = useCache ? writerCachedDraftsForRequest(cache, branchFilter) : [];
|
|
2026
|
-
|
|
2027
|
-
const payload = await client.prospectSequenceExport(accountId, prospectId, compactObject({
|
|
2072
|
+
const requestBody = compactObject({
|
|
2028
2073
|
branches: branchFilter,
|
|
2029
2074
|
angle_index: values["angle-index"],
|
|
2030
2075
|
draft_mode: draftMode,
|
|
2031
2076
|
target_step: values.step,
|
|
2032
|
-
|
|
2033
|
-
})
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
await
|
|
2077
|
+
session_report_id: values.report
|
|
2078
|
+
});
|
|
2079
|
+
|
|
2080
|
+
let payload;
|
|
2081
|
+
try {
|
|
2082
|
+
const startedPayload = await client.createProspectSequenceExportJob(accountId, prospectId, requestBody);
|
|
2083
|
+
if (values["no-wait"]) {
|
|
2084
|
+
if (values.json) return writeJson(context.stdout, startedPayload);
|
|
2085
|
+
|
|
2086
|
+
const completedPayload = sequenceExportJobResultPayload(startedPayload);
|
|
2087
|
+
if (completedPayload) return renderWriterTestRun(completedPayload, context);
|
|
2088
|
+
|
|
2089
|
+
return renderWriterTestRunJobStatus(startedPayload, context, {
|
|
2090
|
+
prospectId,
|
|
2091
|
+
reportId: startedPayload?.report?.prefix_id || startedPayload?.report?.id
|
|
2092
|
+
});
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
payload = await waitForProspectSequenceExportJob(client, accountId, prospectId, startedPayload, {
|
|
2096
|
+
timeoutSeconds: normalizePositiveInteger(values["timeout-seconds"]) || DEFAULT_WRITER_TEST_RUN_TIMEOUT_SECONDS,
|
|
2097
|
+
pollIntervalSeconds: normalizePositiveInteger(values["poll-interval-seconds"]) || DEFAULT_WRITER_TEST_RUN_POLL_INTERVAL_SECONDS,
|
|
2098
|
+
sleepImpl: context.sleep
|
|
2099
|
+
});
|
|
2100
|
+
} catch (error) {
|
|
2101
|
+
if (error instanceof ApiError && error.status === 404) {
|
|
2102
|
+
throw new CommandError(writerReportApiUnavailableMessage({ host: client.host }));
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
if (error instanceof ApiError && error.status === 504 && draftMode === "target") {
|
|
2106
|
+
throw new CommandError(writerTestRunStepTimeoutMessage({ prospectId, branchFilter, step: values.step }));
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
throw error;
|
|
2038
2110
|
}
|
|
2039
2111
|
if (values.json) return writeJson(context.stdout, payload);
|
|
2040
2112
|
|
|
2041
2113
|
renderWriterTestRun(payload, context);
|
|
2042
2114
|
}
|
|
2043
2115
|
|
|
2116
|
+
async function writerTestRunShow(args, context, { accountOverride } = {}) {
|
|
2117
|
+
const { values, positionals } = parseCommandArgs(args, jsonOptions());
|
|
2118
|
+
if (positionals.length !== 2) throw new CommandError(WRITER_TEST_RUN_SHOW_USAGE);
|
|
2119
|
+
|
|
2120
|
+
const { client, accountId } = await requireAccountContext(context, { accountOverride });
|
|
2121
|
+
const [prospectId, reportId] = positionals;
|
|
2122
|
+
const response = await client.prospectSequenceExportJob(accountId, prospectId, reportId);
|
|
2123
|
+
if (values.json) return writeJson(context.stdout, response);
|
|
2124
|
+
|
|
2125
|
+
const payload = sequenceExportJobResultPayload(response);
|
|
2126
|
+
if (payload) return renderWriterTestRun(payload, context);
|
|
2127
|
+
|
|
2128
|
+
renderWriterTestRunJobStatus(response, context, { prospectId, reportId });
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2044
2131
|
async function runSequencePreviewCommand(args, context, { accountOverride, usageText, title }) {
|
|
2045
2132
|
const { values, positionals } = parseCommandArgs(args, {
|
|
2046
2133
|
...jsonOptions(),
|
|
@@ -2090,7 +2177,7 @@ async function prospectsSequenceExport(args, context, { accountOverride } = {})
|
|
|
2090
2177
|
}
|
|
2091
2178
|
|
|
2092
2179
|
function normalizeWriterTestRunMode(value) {
|
|
2093
|
-
const normalized = String(value || "
|
|
2180
|
+
const normalized = String(value || "plan").trim().toLowerCase();
|
|
2094
2181
|
if (["report", "draft", "drafts", "all", "full"].includes(normalized)) return "all";
|
|
2095
2182
|
if (normalized === "plan") return "plan";
|
|
2096
2183
|
if (["step", "target"].includes(normalized)) return "target";
|
|
@@ -2098,159 +2185,20 @@ function normalizeWriterTestRunMode(value) {
|
|
|
2098
2185
|
throw new CommandError("Unsupported writer test-run mode. Use report, plan, or step.");
|
|
2099
2186
|
}
|
|
2100
2187
|
|
|
2101
|
-
function
|
|
2102
|
-
return
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
entries: {}
|
|
2108
|
-
};
|
|
2109
|
-
}
|
|
2110
|
-
|
|
2111
|
-
async function loadWriterTestRunCache(context, { accountId, prospectId }) {
|
|
2112
|
-
const cache = emptyWriterTestRunCache(context, { accountId, prospectId });
|
|
2113
|
-
|
|
2114
|
-
try {
|
|
2115
|
-
const parsed = JSON.parse(await readFile(cache.path, "utf8"));
|
|
2116
|
-
if (parsed?.version !== WRITER_TEST_RUN_CACHE_VERSION) return cache;
|
|
2117
|
-
|
|
2118
|
-
return {
|
|
2119
|
-
...cache,
|
|
2120
|
-
entries: parsed.entries && typeof parsed.entries === "object" ? parsed.entries : {}
|
|
2121
|
-
};
|
|
2122
|
-
} catch (error) {
|
|
2123
|
-
if (error.code === "ENOENT") return cache;
|
|
2124
|
-
if (error instanceof SyntaxError) return cache;
|
|
2125
|
-
|
|
2126
|
-
throw error;
|
|
2127
|
-
}
|
|
2128
|
-
}
|
|
2129
|
-
|
|
2130
|
-
async function clearWriterTestRunCache(context, { accountId, prospectId }) {
|
|
2131
|
-
await rm(writerTestRunCachePath(context, { accountId, prospectId }), { force: true });
|
|
2132
|
-
}
|
|
2133
|
-
|
|
2134
|
-
function writerTestRunCachePath(context, { accountId, prospectId }) {
|
|
2135
|
-
const dir = context.env.AUDIENTI_WRITER_TEST_RUN_CACHE_DIR || join(context.cwd, "tmp", "writer-test-run-cache");
|
|
2136
|
-
return join(dir, `${safeCacheSegment(accountId)}-${safeCacheSegment(prospectId)}.json`);
|
|
2137
|
-
}
|
|
2138
|
-
|
|
2139
|
-
function safeCacheSegment(value) {
|
|
2140
|
-
return String(value || "unknown").replace(/[^a-zA-Z0-9_.-]/g, "_");
|
|
2141
|
-
}
|
|
2142
|
-
|
|
2143
|
-
function writerCachedDraftsForRequest(cache, branchFilter) {
|
|
2144
|
-
const branchKeys = writerRequestedBranchKeys(branchFilter);
|
|
2145
|
-
return Object.values(cache.entries || {})
|
|
2146
|
-
.filter((entry) => branchKeys.includes(entry.branch))
|
|
2147
|
-
.filter((entry) => entry.key && (entry.body || entry.text || entry.subject || writerCacheQualityFailure(entry)))
|
|
2148
|
-
.map((entry) => compactObject({
|
|
2149
|
-
branch: entry.branch,
|
|
2150
|
-
key: entry.key,
|
|
2151
|
-
stage: entry.stage,
|
|
2152
|
-
channel: entry.channel,
|
|
2153
|
-
platform: entry.platform,
|
|
2154
|
-
message_mode: entry.message_mode,
|
|
2155
|
-
subject: entry.subject,
|
|
2156
|
-
body: entry.body,
|
|
2157
|
-
text: entry.text,
|
|
2158
|
-
status: entry.status,
|
|
2159
|
-
quality_codes: entry.quality_codes,
|
|
2160
|
-
blank_reason: entry.blank_reason,
|
|
2161
|
-
writer_path: entry.writer_path,
|
|
2162
|
-
generated_at: entry.generated_at,
|
|
2163
|
-
writer_engine: entry.writer_engine,
|
|
2164
|
-
target: entry.target,
|
|
2165
|
-
metadata: entry.metadata
|
|
2166
|
-
}));
|
|
2167
|
-
}
|
|
2168
|
-
|
|
2169
|
-
function writerRequestedBranchKeys(branchFilter) {
|
|
2170
|
-
const values = String(branchFilter || "both").split(",").map((value) => value.trim()).filter(Boolean);
|
|
2171
|
-
if (values.length === 0 || values.includes("both")) return ["no_accept", "accepted"];
|
|
2172
|
-
|
|
2173
|
-
return values.map((value) => {
|
|
2174
|
-
const normalized = value.replaceAll("-", "_");
|
|
2175
|
-
if (["default", "no_accept", "not_connected"].includes(normalized)) return "no_accept";
|
|
2176
|
-
if (normalized === "accepted") return "accepted";
|
|
2177
|
-
return normalized;
|
|
2178
|
-
});
|
|
2179
|
-
}
|
|
2180
|
-
|
|
2181
|
-
function writerCacheMeta(cache, cachedDrafts) {
|
|
2182
|
-
return {
|
|
2183
|
-
enabled: true,
|
|
2184
|
-
path: cache.path,
|
|
2185
|
-
entry_count: Object.keys(cache.entries || {}).length,
|
|
2186
|
-
sent_draft_count: cachedDrafts.length
|
|
2187
|
-
};
|
|
2188
|
-
}
|
|
2189
|
-
|
|
2190
|
-
async function persistWriterDraftsFromPayload(context, { cache, payload }) {
|
|
2191
|
-
const entries = { ...(cache.entries || {}) };
|
|
2192
|
-
let changed = false;
|
|
2193
|
-
|
|
2194
|
-
for (const branch of Array.isArray(payload?.branches) ? payload.branches : []) {
|
|
2195
|
-
const branchKey = String(branch?.key || "").trim();
|
|
2196
|
-
if (!branchKey) continue;
|
|
2197
|
-
|
|
2198
|
-
for (const step of Array.isArray(branch?.steps) ? branch.steps : []) {
|
|
2199
|
-
const entry = writerCacheEntryFromStep(step, { branchKey, generatedAt: branch.generated_at || payload?.generated_at });
|
|
2200
|
-
if (!entry) continue;
|
|
2201
|
-
|
|
2202
|
-
entries[writerCacheEntryKey(entry)] = entry;
|
|
2203
|
-
changed = true;
|
|
2204
|
-
}
|
|
2205
|
-
}
|
|
2206
|
-
|
|
2207
|
-
if (!changed) return;
|
|
2208
|
-
|
|
2209
|
-
const nextCache = {
|
|
2210
|
-
version: WRITER_TEST_RUN_CACHE_VERSION,
|
|
2211
|
-
account_id: cache.account_id,
|
|
2212
|
-
prospect_id: cache.prospect_id,
|
|
2213
|
-
updated_at: new Date().toISOString(),
|
|
2214
|
-
entries
|
|
2215
|
-
};
|
|
2216
|
-
await mkdir(dirname(cache.path), { recursive: true });
|
|
2217
|
-
await writeFile(cache.path, `${JSON.stringify(nextCache, null, 2)}\n`, "utf8");
|
|
2218
|
-
cache.entries = entries;
|
|
2219
|
-
}
|
|
2220
|
-
|
|
2221
|
-
function writerCacheEntryFromStep(step, { branchKey, generatedAt }) {
|
|
2222
|
-
if (step?.kind !== "message") return null;
|
|
2223
|
-
if (!step.key) return null;
|
|
2224
|
-
if (!(step.body || step.text || step.subject || writerCacheQualityFailure(step))) return null;
|
|
2225
|
-
if (step.status === "planned" || step.status === "unavailable" || step.status === "error") return null;
|
|
2226
|
-
|
|
2227
|
-
return compactObject({
|
|
2228
|
-
branch: branchKey,
|
|
2229
|
-
key: step.key,
|
|
2230
|
-
stage: step.stage,
|
|
2231
|
-
channel: step.channel,
|
|
2232
|
-
platform: step.platform,
|
|
2233
|
-
message_mode: step.message_mode,
|
|
2234
|
-
subject: step.subject,
|
|
2235
|
-
body: step.body,
|
|
2236
|
-
text: step.text,
|
|
2237
|
-
status: step.status,
|
|
2238
|
-
quality_codes: Array.isArray(step.quality_codes) ? step.quality_codes.filter(Boolean) : undefined,
|
|
2239
|
-
blank_reason: step.blank_reason,
|
|
2240
|
-
writer_path: step.writer_path,
|
|
2241
|
-
generated_at: step.generated_at || generatedAt || new Date().toISOString(),
|
|
2242
|
-
writer_engine: step.writer_engine || step?.metadata?.writer_engine,
|
|
2243
|
-
target: step.target,
|
|
2244
|
-
metadata: step.metadata
|
|
2245
|
-
});
|
|
2246
|
-
}
|
|
2247
|
-
|
|
2248
|
-
function writerCacheEntryKey(entry) {
|
|
2249
|
-
return `${entry.branch}:${entry.key}`;
|
|
2188
|
+
function writerTestRunStepTimeoutMessage({ prospectId, branchFilter, step }) {
|
|
2189
|
+
return [
|
|
2190
|
+
`Timed out while drafting writer step ${display(step)} on branch ${display(branchFilter)}.`,
|
|
2191
|
+
"The timeline command is working; this timeout happened during the selected writer generation, not account selection or API connectivity.",
|
|
2192
|
+
`Inspect the timeline with \`audienti writer test-run ${prospectId}\`, then retry the row or use its step key, for example \`--step connection_request\`.`
|
|
2193
|
+
].join(" ");
|
|
2250
2194
|
}
|
|
2251
2195
|
|
|
2252
|
-
function
|
|
2253
|
-
return
|
|
2196
|
+
function writerReportApiUnavailableMessage({ host }) {
|
|
2197
|
+
return [
|
|
2198
|
+
`The configured Audienti API at ${host} does not support report-backed writer sessions yet.`,
|
|
2199
|
+
"This CLI expects the /sequence_export_jobs writer report API.",
|
|
2200
|
+
"Use a local app server running this branch and re-auth with `audienti auth token <token> --host <url>`, or deploy this branch before pointing the CLI at production."
|
|
2201
|
+
].join(" ");
|
|
2254
2202
|
}
|
|
2255
2203
|
|
|
2256
2204
|
async function prospectsImport(args, context, { accountOverride } = {}) {
|
|
@@ -3260,6 +3208,79 @@ function importFinished(payload) {
|
|
|
3260
3208
|
return payload?.ready === true || payload?.status === "completed" || payload?.status === "failed";
|
|
3261
3209
|
}
|
|
3262
3210
|
|
|
3211
|
+
async function waitForProspectSequenceExportJob(client, accountId, prospectId, startedPayload, {
|
|
3212
|
+
timeoutSeconds = DEFAULT_WRITER_TEST_RUN_TIMEOUT_SECONDS,
|
|
3213
|
+
pollIntervalSeconds = DEFAULT_WRITER_TEST_RUN_POLL_INTERVAL_SECONDS,
|
|
3214
|
+
sleepImpl = sleep
|
|
3215
|
+
} = {}) {
|
|
3216
|
+
const firstResult = sequenceExportJobResultPayload(startedPayload);
|
|
3217
|
+
if (firstResult) return firstResult;
|
|
3218
|
+
|
|
3219
|
+
const reportId = startedPayload?.report?.prefix_id || startedPayload?.report?.id;
|
|
3220
|
+
if (!reportId) {
|
|
3221
|
+
throw new CommandError("Writer test run did not return a sequence export job id.");
|
|
3222
|
+
}
|
|
3223
|
+
if (sequenceExportJobFailed(startedPayload)) {
|
|
3224
|
+
throw new CommandError(writerTestRunJobFailureMessage(startedPayload, { reportId }));
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
const timeoutAt = Date.now() + (timeoutSeconds * 1000);
|
|
3228
|
+
let latest = startedPayload;
|
|
3229
|
+
|
|
3230
|
+
while (Date.now() < timeoutAt) {
|
|
3231
|
+
await sleepImpl(pollIntervalSeconds * 1000);
|
|
3232
|
+
latest = await client.prospectSequenceExportJob(accountId, prospectId, reportId);
|
|
3233
|
+
const result = sequenceExportJobResultPayload(latest);
|
|
3234
|
+
if (result) return result;
|
|
3235
|
+
if (sequenceExportJobFailed(latest)) {
|
|
3236
|
+
throw new CommandError(writerTestRunJobFailureMessage(latest, { reportId }));
|
|
3237
|
+
}
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3240
|
+
throw new CommandError(`Timed out after ${timeoutSeconds} seconds waiting for writer test run ${reportId}. The server job may still finish. Check it with: audienti writer test-run show ${prospectId} ${reportId}`);
|
|
3241
|
+
}
|
|
3242
|
+
|
|
3243
|
+
function sequenceExportJobResultPayload(payload) {
|
|
3244
|
+
if (payload?.report?.status !== "completed") return null;
|
|
3245
|
+
|
|
3246
|
+
const result = payload?.content?.payload;
|
|
3247
|
+
if (!result || typeof result !== "object") return null;
|
|
3248
|
+
|
|
3249
|
+
result.meta ||= {};
|
|
3250
|
+
result.meta.report_id = payload?.report?.prefix_id || payload?.report?.id || result.meta.report_id;
|
|
3251
|
+
return result;
|
|
3252
|
+
}
|
|
3253
|
+
|
|
3254
|
+
function sequenceExportJobFailed(payload) {
|
|
3255
|
+
const status = payload?.report?.status || payload?.run?.status;
|
|
3256
|
+
return status === "failed" || status === "canceled";
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
function writerTestRunJobFailureMessage(payload, { reportId }) {
|
|
3260
|
+
const reason = payload?.content?.flat_payload?.error || payload?.error || "The sequence export job failed.";
|
|
3261
|
+
return `Writer test run ${reportId} failed: ${reason}`;
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
function renderWriterTestRunJobStatus(payload, context, { prospectId, reportId }) {
|
|
3265
|
+
const report = payload?.report || {};
|
|
3266
|
+
const run = payload?.run || {};
|
|
3267
|
+
const status = report.status || run.status || "unknown";
|
|
3268
|
+
|
|
3269
|
+
writeLine(context.stdout, "Writer test run job");
|
|
3270
|
+
writeLine(context.stdout, `Report: ${display(report.prefix_id || reportId)} (${display(status)})`);
|
|
3271
|
+
if (report.stage) writeLine(context.stdout, `Stage: ${display(report.stage)}`);
|
|
3272
|
+
if (run.status) writeLine(context.stdout, `Run: ${display(run.status)}`);
|
|
3273
|
+
if (report.updated_at) writeLine(context.stdout, `Updated: ${display(report.updated_at)}`);
|
|
3274
|
+
|
|
3275
|
+
const error = payload?.content?.flat_payload?.error || payload?.error;
|
|
3276
|
+
if (error) {
|
|
3277
|
+
writeLine(context.stdout, `Error: ${display(error)}`);
|
|
3278
|
+
return;
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3281
|
+
writeLine(context.stdout, `Not complete yet. Check later: audienti writer test-run show ${prospectId} ${report.prefix_id || reportId}`);
|
|
3282
|
+
}
|
|
3283
|
+
|
|
3263
3284
|
function parseProspectTotalLimit(value) {
|
|
3264
3285
|
const parsed = normalizePositiveInteger(value);
|
|
3265
3286
|
if (parsed === null) return MAX_ALL_PROSPECTS;
|
|
@@ -4035,6 +4056,7 @@ function renderWriterTestRun(payload, context) {
|
|
|
4035
4056
|
|
|
4036
4057
|
writeLine(context.stdout, "Writer campaign simulator");
|
|
4037
4058
|
writeLine(context.stdout, `Prospect: ${display(prospect.display_name || prospect.name)} (${display(prospect.prefix_id)})`);
|
|
4059
|
+
if (payload?.meta?.report_id) writeLine(context.stdout, `Report: ${display(payload.meta.report_id)}`);
|
|
4038
4060
|
if (payload?.context?.source) writeLine(context.stdout, `Context: ${payload.context.source}`);
|
|
4039
4061
|
if (payload?.context?.message) writeLine(context.stdout, payload.context.message);
|
|
4040
4062
|
if (payload?.context?.motion_name) writeLine(context.stdout, `Motion: ${payload.context.motion_name}`);
|
|
@@ -4042,10 +4064,6 @@ function renderWriterTestRun(payload, context) {
|
|
|
4042
4064
|
if (payload?.context?.offer_name) writeLine(context.stdout, `Offer: ${payload.context.offer_name}`);
|
|
4043
4065
|
writeLine(context.stdout, `Mode: ${display(draftMode)}`);
|
|
4044
4066
|
if (targetStep) writeLine(context.stdout, `Target step: ${display(targetStep)}`);
|
|
4045
|
-
if (payload?.meta?.cache?.enabled) {
|
|
4046
|
-
writeLine(context.stdout, `Cache: ${display(payload.meta.cache.path)}`);
|
|
4047
|
-
writeLine(context.stdout, `Cached drafts sent: ${display(payload.meta.cache.sent_draft_count || 0)}`);
|
|
4048
|
-
}
|
|
4049
4067
|
writeLine(context.stdout, `Start: ${isoDate(currentDate(context))}`);
|
|
4050
4068
|
writeLine(context.stdout, "DATE: step execution date; for WAIT rows, the wait clears on that date.");
|
|
4051
4069
|
writeLine(context.stdout, "Scenario: simulate the full path if the prospect does not reply.");
|
|
@@ -7318,10 +7336,11 @@ const HELP_TOPICS = new Map([
|
|
|
7318
7336
|
"Status: implemented",
|
|
7319
7337
|
"",
|
|
7320
7338
|
"Purpose:",
|
|
7321
|
-
" Run a writer
|
|
7339
|
+
" Run a report-backed writer session for one prospect: resolve current context, build the no-reply timeline, and optionally draft selected rows.",
|
|
7322
7340
|
"",
|
|
7323
7341
|
"Commands:",
|
|
7324
7342
|
" audienti writer test-run <prsp_id>",
|
|
7343
|
+
" audienti writer test-run show <prsp_id> <rprt_id>",
|
|
7325
7344
|
"",
|
|
7326
7345
|
"Alias:",
|
|
7327
7346
|
" audienti writers test-run <prsp_id>"
|
|
@@ -7334,26 +7353,60 @@ const HELP_TOPICS = new Map([
|
|
|
7334
7353
|
"Status: implemented",
|
|
7335
7354
|
"",
|
|
7336
7355
|
"Purpose:",
|
|
7337
|
-
" Run the prospect-scoped writer
|
|
7356
|
+
" Run or continue the prospect-scoped writer session report for a single prospect.",
|
|
7357
|
+
"",
|
|
7358
|
+
"Commands:",
|
|
7359
|
+
" audienti writer test-run <prsp_id>",
|
|
7360
|
+
" audienti writer test-run show <prsp_id> <rprt_id>",
|
|
7338
7361
|
"",
|
|
7339
7362
|
"Behavior:",
|
|
7340
|
-
"
|
|
7363
|
+
" Creates or updates a server report for the writing session. The default plan mode writes the timeline to that report without drafting every message. Step mode drafts one selected row against the same report when --report <rprt_id> is passed. Drafting every message requires --mode report.",
|
|
7364
|
+
"",
|
|
7365
|
+
"Local workflow:",
|
|
7366
|
+
" 1. Start the local app server for the workspace, for example: direnv exec . bin/dev",
|
|
7367
|
+
" 2. Confirm bin/cli is pointed at the local API: bin/cli config list --json",
|
|
7368
|
+
" 3. Start or reopen the timeline report: bin/cli writer test-run <prsp_id>",
|
|
7369
|
+
" 4. Save the printed Report id, then draft one row into that same session:",
|
|
7370
|
+
" bin/cli writer test-run <prsp_id> --mode step --branch no-accept --step <row_number|step_key> --report <rprt_id>",
|
|
7371
|
+
" 5. To launch work and come back later, add --no-wait, then inspect it with:",
|
|
7372
|
+
" bin/cli writer test-run show <prsp_id> <rprt_id>",
|
|
7373
|
+
"",
|
|
7374
|
+
"Report workflow:",
|
|
7375
|
+
" The report is the session cache. Plan mode writes the timeline to the report. Step mode reads prior drafted rows from the same report and writes the selected row back to it. Reports expire after the server retention window.",
|
|
7341
7376
|
"",
|
|
7342
7377
|
"Options:",
|
|
7343
|
-
" --mode <mode> report drafts every message,
|
|
7378
|
+
" --mode <mode> plan skips drafting, report drafts every message, step drafts one selected step",
|
|
7344
7379
|
" --branch <branch> Optional branch filter: both | no-accept | accepted",
|
|
7345
7380
|
" --step <step_key|row_number> Required with --mode step. Row numbers come from the # column.",
|
|
7346
|
-
" --
|
|
7347
|
-
" --
|
|
7381
|
+
" --report <rprt_id> Continue an existing writer session report",
|
|
7382
|
+
" --no-wait Queue the writer report job, print the report id, and exit",
|
|
7383
|
+
" --timeout-seconds <n> Wait budget before the command fails. Default: 180",
|
|
7384
|
+
" --poll-interval-seconds <n> Status poll interval. Default: 2",
|
|
7348
7385
|
"",
|
|
7349
7386
|
"Output shape:",
|
|
7350
7387
|
" branches[].key: no_accept | accepted",
|
|
7351
7388
|
" branches[].steps[]: ordered wait/action/message/terminal steps for that simulated path",
|
|
7352
|
-
" branches[].steps[].body: draft copy for message steps when
|
|
7389
|
+
" branches[].steps[].body: draft copy for message steps only in report mode, step mode, or when already present in the report",
|
|
7353
7390
|
" branches[].summary: channel sequence, touch counts, duration, terminal disposition",
|
|
7354
7391
|
"",
|
|
7355
7392
|
"API:",
|
|
7356
|
-
" POST /api/v1/accounts/:account_id/prospects/:id/
|
|
7393
|
+
" POST /api/v1/accounts/:account_id/prospects/:id/sequence_export_jobs.json, then poll GET /api/v1/accounts/:account_id/prospects/:id/sequence_export_jobs/:job_id.json"
|
|
7394
|
+
].join("\n")],
|
|
7395
|
+
|
|
7396
|
+
["writer test-run show", [
|
|
7397
|
+
"Usage:",
|
|
7398
|
+
` ${WRITER_TEST_RUN_SHOW_USAGE.slice("Usage: ".length)}`,
|
|
7399
|
+
"",
|
|
7400
|
+
"Status: implemented",
|
|
7401
|
+
"",
|
|
7402
|
+
"Purpose:",
|
|
7403
|
+
" Fetch a writer session report by report id and render the saved output when it is complete.",
|
|
7404
|
+
"",
|
|
7405
|
+
"Behavior:",
|
|
7406
|
+
" Completed jobs render the same campaign simulator output as the original test-run command. Pending or processing jobs print report status and the check-later command. Failed jobs print the persisted failure reason.",
|
|
7407
|
+
"",
|
|
7408
|
+
"API:",
|
|
7409
|
+
" GET /api/v1/accounts/:account_id/prospects/:id/sequence_export_jobs/:job_id.json"
|
|
7357
7410
|
].join("\n")],
|
|
7358
7411
|
|
|
7359
7412
|
["prospects sequence-export", [
|
package/src/config.js
CHANGED
|
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
|
|
5
5
|
const CONFIG_FILENAME = "config.json";
|
|
6
|
+
const ACCOUNT_CONFIG_KEYS = ["accountId", "accountName", "accountUserId", "accountUserName", "accountUserEmail"];
|
|
6
7
|
|
|
7
8
|
export class ConfigError extends Error {
|
|
8
9
|
constructor(message) {
|
|
@@ -19,8 +20,25 @@ export function configPath(env = process.env) {
|
|
|
19
20
|
return join(configDirectory(env), CONFIG_FILENAME);
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
export function fallbackConfigDirectory(env = process.env) {
|
|
24
|
+
return env.AUDIENTI_CONFIG_FALLBACK_HOME || null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function fallbackConfigPath(env = process.env) {
|
|
28
|
+
const directory = fallbackConfigDirectory(env);
|
|
29
|
+
return directory ? join(directory, CONFIG_FILENAME) : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
export async function readConfig({ env = process.env } = {}) {
|
|
23
|
-
const
|
|
33
|
+
const config = await readConfigFile(configPath(env));
|
|
34
|
+
const fallbackPath = fallbackConfigPath(env);
|
|
35
|
+
if (!fallbackPath || fallbackPath === configPath(env)) return applyConfigOverrides(config, env);
|
|
36
|
+
|
|
37
|
+
const fallbackConfig = await readConfigFile(fallbackPath);
|
|
38
|
+
return applyConfigOverrides(mergeFallbackConfig(config, fallbackConfig), env);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readConfigFile(filePath) {
|
|
24
42
|
|
|
25
43
|
try {
|
|
26
44
|
return JSON.parse(await readFile(filePath, "utf8"));
|
|
@@ -34,6 +52,49 @@ export async function readConfig({ env = process.env } = {}) {
|
|
|
34
52
|
}
|
|
35
53
|
}
|
|
36
54
|
|
|
55
|
+
function mergeFallbackConfig(config, fallbackConfig) {
|
|
56
|
+
const merged = { ...config };
|
|
57
|
+
|
|
58
|
+
if (!merged.host && !merged.token && fallbackConfig.host && fallbackConfig.token) {
|
|
59
|
+
merged.host = fallbackConfig.host;
|
|
60
|
+
merged.token = fallbackConfig.token;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!merged.accountId && fallbackConfig.accountId) {
|
|
64
|
+
for (const key of ACCOUNT_CONFIG_KEYS) {
|
|
65
|
+
if (fallbackConfig[key]) merged[key] = fallbackConfig[key];
|
|
66
|
+
}
|
|
67
|
+
} else if (merged.accountId && merged.accountId === fallbackConfig.accountId) {
|
|
68
|
+
for (const key of ACCOUNT_CONFIG_KEYS) {
|
|
69
|
+
if (!merged[key] && fallbackConfig[key]) merged[key] = fallbackConfig[key];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return merged;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function applyConfigOverrides(config, env) {
|
|
77
|
+
if (!env.AUDIENTI_CONFIG_HOST_OVERRIDE) return config;
|
|
78
|
+
|
|
79
|
+
const overridden = {
|
|
80
|
+
...config,
|
|
81
|
+
host: env.AUDIENTI_CONFIG_HOST_OVERRIDE
|
|
82
|
+
};
|
|
83
|
+
if (config.token && !sameCredentialOrigin(config.host, overridden.host)) delete overridden.token;
|
|
84
|
+
|
|
85
|
+
return overridden;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function sameCredentialOrigin(left, right) {
|
|
89
|
+
if (!left || !right) return false;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
return new URL(left).origin === new URL(right).origin;
|
|
93
|
+
} catch {
|
|
94
|
+
return String(left).replace(/\/+$/, "") === String(right).replace(/\/+$/, "");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
37
98
|
export async function writeConfig(config, { env = process.env } = {}) {
|
|
38
99
|
const filePath = configPath(env);
|
|
39
100
|
const dir = dirname(filePath);
|