@coworker-jp/aidr 0.1.289 → 0.1.292
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/package.json +1 -1
- package/src/binary-fetcher.mjs +61 -8
- package/src/cli.mjs +66 -2
- package/src/install-status.mjs +114 -0
- package/src/verify.mjs +28 -0
package/package.json
CHANGED
package/src/binary-fetcher.mjs
CHANGED
|
@@ -40,10 +40,27 @@ export function detectOpengrepPlatform() {
|
|
|
40
40
|
throw new Error(`Unsupported platform for Opengrep: ${os}/${arch}`);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* An HTTP status the download endpoint actually returned, carried on the error
|
|
45
|
+
* so callers can tell "this release does not exist" (404) apart from "the key
|
|
46
|
+
* was rejected" (403), "the service is unwell" (5xx) and "the network died"
|
|
47
|
+
* (no status at all). A plain `Error` with the status only in its message forces
|
|
48
|
+
* callers to regex-match prose, which is how a credential problem ends up
|
|
49
|
+
* being handled as a release problem.
|
|
50
|
+
*/
|
|
51
|
+
export class HttpStatusError extends Error {
|
|
52
|
+
constructor(url, status) {
|
|
53
|
+
super(`GET ${url} -> HTTP ${status}`);
|
|
54
|
+
this.name = "HttpStatusError";
|
|
55
|
+
this.status = status;
|
|
56
|
+
this.url = url;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
async function fetchBuffer(url, accessKey) {
|
|
44
61
|
const headers = accessKey ? { "x-access-key": accessKey } : {};
|
|
45
62
|
const res = await fetch(url, { redirect: "follow", headers });
|
|
46
|
-
if (!res.ok) throw new
|
|
63
|
+
if (!res.ok) throw new HttpStatusError(url, res.status);
|
|
47
64
|
const ab = await res.arrayBuffer();
|
|
48
65
|
return Buffer.from(ab);
|
|
49
66
|
}
|
|
@@ -100,16 +117,52 @@ export async function fetchAsset(binUrl, destPath, { accessKey } = {}) {
|
|
|
100
117
|
|
|
101
118
|
/**
|
|
102
119
|
* Download ai-scanner for the current platform into `destPath`.
|
|
103
|
-
*
|
|
120
|
+
*
|
|
121
|
+
* TWO KEYS, ONE FALLBACK (issue #1687)
|
|
122
|
+
* ------------------------------------
|
|
123
|
+
* The PROD npm package pins its own version, so it asks for `v{X.Y.Z}/{platform}`.
|
|
124
|
+
* That key exists only once the release's 4-platform upload has actually landed
|
|
125
|
+
* in S3 — and the version bump and the upload fire on the same push with no
|
|
126
|
+
* ordering between them, so for a window (7.8 hours on 2026-08-22's v0.1.289)
|
|
127
|
+
* the pinned key is a definite 404 while the *unpinned* key
|
|
128
|
+
* `{platform}` — the one the verify Lambda gates, which serves the last
|
|
129
|
+
* fully-published release (#176 / #387) — is serving a working binary the whole
|
|
130
|
+
* time. Without a fallback the client is the only party that does not know this.
|
|
131
|
+
*
|
|
132
|
+
* ONLY a definite 404 falls back:
|
|
133
|
+
* - 403 means the access key was rejected. The unpinned key would reject it
|
|
134
|
+
* too, and retrying would blur a credential problem into a release problem.
|
|
135
|
+
* - 5xx / network failure is NOT evidence of absence — same rule, and the same
|
|
136
|
+
* reasoning, as the release-readiness gate's fail-open
|
|
137
|
+
* (`aidr_common/release_publication.py`: "Only a *definite* not-found
|
|
138
|
+
* suppresses the version").
|
|
139
|
+
* The pin itself is NOT weakened: a healthy pinned fetch is used as-is and never
|
|
140
|
+
* consults the unpinned key. The pin exists so a machine installing during a
|
|
141
|
+
* release gets the release it asked for; the fallback only covers the case where
|
|
142
|
+
* that release provably has no bytes behind it.
|
|
143
|
+
*
|
|
144
|
+
* Returns { path, platform, sha256, verified, requestedVersion, servedUnpinned }.
|
|
145
|
+
* `servedUnpinned: true` means the caller must NOT claim the pinned version was
|
|
146
|
+
* installed — what landed is whatever the endpoint currently publishes.
|
|
104
147
|
*/
|
|
105
|
-
export async function fetchBinary(destPath, { env = "prod", accessKey, version } = {}) {
|
|
148
|
+
export async function fetchBinary(destPath, { env = "prod", accessKey, version, baseUrl } = {}) {
|
|
106
149
|
const platform = detectPlatform();
|
|
107
|
-
const base = downloadBase(env);
|
|
108
|
-
const
|
|
109
|
-
|
|
150
|
+
const base = baseUrl || downloadBase(env);
|
|
151
|
+
const unpinnedUrl = `${base}/${platform}`;
|
|
152
|
+
|
|
153
|
+
if (!version) {
|
|
154
|
+
const result = await fetchAsset(unpinnedUrl, destPath, { accessKey });
|
|
155
|
+
return { ...result, platform, requestedVersion: null, servedUnpinned: false };
|
|
156
|
+
}
|
|
110
157
|
|
|
111
|
-
|
|
112
|
-
|
|
158
|
+
try {
|
|
159
|
+
const result = await fetchAsset(`${base}/v${version}/${platform}`, destPath, { accessKey });
|
|
160
|
+
return { ...result, platform, requestedVersion: version, servedUnpinned: false };
|
|
161
|
+
} catch (e) {
|
|
162
|
+
if (e?.status !== 404) throw e;
|
|
163
|
+
const result = await fetchAsset(unpinnedUrl, destPath, { accessKey });
|
|
164
|
+
return { ...result, platform, requestedVersion: version, servedUnpinned: true };
|
|
165
|
+
}
|
|
113
166
|
}
|
|
114
167
|
|
|
115
168
|
// Pinned Opengrep release used by aidr. Must be updated in lockstep with
|
package/src/cli.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { fetchBinary, fetchOpengrep } from "./binary-fetcher.mjs";
|
|
|
14
14
|
import { detectInstalled } from "./detect.mjs";
|
|
15
15
|
import { evaluateInstallGate, formatLocalPcGuidance } from "./env-gate.mjs";
|
|
16
16
|
import { resolveCallerHome, isRunningUnderSudo, chownToCaller } from "./sudo-user.mjs";
|
|
17
|
+
import { formatMissingBinaryStatus, formatPartialBinaryStatus } from "./install-status.mjs";
|
|
17
18
|
|
|
18
19
|
// Package identity — read at module load from the bundled package.json.
|
|
19
20
|
// The PROD publish workflow renames the package to `@coworker-jp/aidr`;
|
|
@@ -31,6 +32,11 @@ const IS_PROD_PACKAGE = PKG.name === "@coworker-jp/aidr";
|
|
|
31
32
|
const PINNED_VERSION = IS_PROD_PACKAGE ? PKG.version : undefined;
|
|
32
33
|
const DEFAULT_ENV = IS_PROD_PACKAGE ? "prod" : "dev";
|
|
33
34
|
|
|
35
|
+
// The command to quote back at the user when they have to re-run. Built from
|
|
36
|
+
// the package that is actually running, so the DEV package never tells anyone
|
|
37
|
+
// to install PROD.
|
|
38
|
+
const INSTALL_COMMAND_HINT = `npx ${PKG.name} install --agent <agents> --key <access key>`;
|
|
39
|
+
|
|
34
40
|
const ACCESS_KEY_RE = /^ak_[A-Za-z0-9_-]{43}$/;
|
|
35
41
|
|
|
36
42
|
function validateKey(key) {
|
|
@@ -164,6 +170,12 @@ async function cmdInstall(opts) {
|
|
|
164
170
|
}
|
|
165
171
|
}
|
|
166
172
|
|
|
173
|
+
// Outcome of the binary step, read again at the end of the install to write
|
|
174
|
+
// the closing status. `needed` distinguishes "no binary was asked for"
|
|
175
|
+
// (--skip-binary, stub-only agent set) from "a binary was asked for and did
|
|
176
|
+
// not arrive" — the two must never print the same closing line.
|
|
177
|
+
const binaryOutcome = { needed: false, scannerInstalled: false, error: null };
|
|
178
|
+
|
|
167
179
|
if (!opts.skipBinary && !opts.dryRun) {
|
|
168
180
|
const agentBinDirs = [];
|
|
169
181
|
for (const name of agents) {
|
|
@@ -175,6 +187,7 @@ async function cmdInstall(opts) {
|
|
|
175
187
|
if (agentBinDirs.length === 0) {
|
|
176
188
|
console.error("no agents with binary support selected; skipping binary fetch");
|
|
177
189
|
} else {
|
|
190
|
+
binaryOutcome.needed = true;
|
|
178
191
|
try {
|
|
179
192
|
const [primaryDir, ...restDirs] = agentBinDirs;
|
|
180
193
|
const fetchOpts = {
|
|
@@ -188,7 +201,15 @@ async function cmdInstall(opts) {
|
|
|
188
201
|
// PATH. Users can point AI_SCANNER_OPENGREP elsewhere to substitute
|
|
189
202
|
// their own LGPL-2.1 build.
|
|
190
203
|
const scannerRes = await fetchBinary(path.join(primaryDir, "ai-scanner"), fetchOpts);
|
|
204
|
+
binaryOutcome.scannerInstalled = true;
|
|
191
205
|
console.log(`binary: ${scannerRes.path} (${scannerRes.platform}${scannerRes.verified ? ", sha256 verified" : ""})`);
|
|
206
|
+
if (scannerRes.servedUnpinned) {
|
|
207
|
+
// Say what was asked for, what arrived, and how to check — the pinned
|
|
208
|
+
// build genuinely has no bytes behind it, but protection IS in place,
|
|
209
|
+
// so this is a note, not an alarm.
|
|
210
|
+
console.error(`note: release v${scannerRes.requestedVersion} of the scanner is not published for ${scannerRes.platform} yet, so it could not be the one installed.`);
|
|
211
|
+
console.error(`note: the currently published build was installed instead, so scanning is active. Check which build you have with: ${scannerRes.path} version`);
|
|
212
|
+
}
|
|
192
213
|
// Opengrep is fetched unmodified from its public upstream GitHub
|
|
193
214
|
// release (LGPL-2.1); no access-key gating is needed here.
|
|
194
215
|
const opengrepRes = await fetchOpengrep(path.join(primaryDir, "opengrep"));
|
|
@@ -226,8 +247,9 @@ async function cmdInstall(opts) {
|
|
|
226
247
|
);
|
|
227
248
|
await Promise.all(copyOps);
|
|
228
249
|
} catch (e) {
|
|
250
|
+
binaryOutcome.error = e;
|
|
229
251
|
console.error(`binary fetch failed: ${e.message}`);
|
|
230
|
-
if (!opts.binaryOnly) console.error("continuing with hook install
|
|
252
|
+
if (!opts.binaryOnly) console.error("continuing with hook install; the closing status below says what this machine is protected by.");
|
|
231
253
|
else process.exit(1);
|
|
232
254
|
}
|
|
233
255
|
}
|
|
@@ -248,7 +270,14 @@ async function cmdInstall(opts) {
|
|
|
248
270
|
for (const bp of backups) {
|
|
249
271
|
console.log(`[${name}] existing file backed up to ${bp}`);
|
|
250
272
|
}
|
|
251
|
-
|
|
273
|
+
// Derived, never declared: an agent can re-fetch a missing binary at the
|
|
274
|
+
// start of its next session iff its install actually wrote a
|
|
275
|
+
// start_scanner.sh (templates.mjs — it pulls from the UNPINNED key). A
|
|
276
|
+
// boolean on `meta` would rot the first time an agent gains or loses that
|
|
277
|
+
// script; reading the written file list cannot.
|
|
278
|
+
const selfHeals = Boolean(res && Array.isArray(res.installed)
|
|
279
|
+
&& res.installed.some((p) => path.basename(p) === "start_scanner.sh"));
|
|
280
|
+
return { name, displayName: mod.meta.displayName || name, selfHeals, needsBinary: Boolean(mod.meta.agentDir) };
|
|
252
281
|
} catch (e) {
|
|
253
282
|
console.error(`[${name}] install failed: ${e.message}`);
|
|
254
283
|
process.exit(1);
|
|
@@ -306,6 +335,41 @@ async function cmdInstall(opts) {
|
|
|
306
335
|
console.log("Click Allow — the scanner uses this to collect endpoint info (login items).");
|
|
307
336
|
}
|
|
308
337
|
}
|
|
338
|
+
|
|
339
|
+
// ── Closing status (issue #1687) ──────────────────────────────────────
|
|
340
|
+
// LAST, on purpose. The per-agent lines above all say "installed", and a
|
|
341
|
+
// warning printed before them scrolls out of sight on a real terminal. If a
|
|
342
|
+
// binary was asked for and did not arrive, the run ends by saying so.
|
|
343
|
+
if (binaryOutcome.needed && binaryOutcome.error) {
|
|
344
|
+
const reason = binaryOutcome.error.message;
|
|
345
|
+
if (binaryOutcome.scannerInstalled) {
|
|
346
|
+
console.error(formatPartialBinaryStatus({ reason, installCommand: INSTALL_COMMAND_HINT }));
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const needBinary = installResults.filter((r) => r.needsBinary);
|
|
350
|
+
const selfHealing = needBinary.filter((r) => r.selfHeals).map((r) => r.displayName);
|
|
351
|
+
const stranded = needBinary.filter((r) => !r.selfHeals).map((r) => r.displayName);
|
|
352
|
+
const binPaths = (await Promise.all(needBinary.map(async (r) => {
|
|
353
|
+
const mod = await loadAgent(r.name);
|
|
354
|
+
return mod.meta.agentDir ? path.join(home, mod.meta.agentDir, "bin", "ai-scanner") : null;
|
|
355
|
+
}))).filter(Boolean);
|
|
356
|
+
console.error(formatMissingBinaryStatus({
|
|
357
|
+
reason, selfHealing, stranded, binPaths, installCommand: INSTALL_COMMAND_HINT,
|
|
358
|
+
}));
|
|
359
|
+
// EXIT CODE. Default installs stay 0 when every agent that needs the
|
|
360
|
+
// binary can fetch it itself at the next session start: the hooks really
|
|
361
|
+
// are installed, they really do self-heal, and turning that red would
|
|
362
|
+
// break scripted installs for a condition that usually resolves on its
|
|
363
|
+
// own. But when at least one installed agent needs the binary and has NO
|
|
364
|
+
// self-heal path (gemini's hook is `[ ! -x "$BIN" ] && exit 0`; standalone
|
|
365
|
+
// has no hooks at all), nothing on this machine will ever fetch it again.
|
|
366
|
+
// Exiting 0 there is the CLI asserting an install that did not happen, so
|
|
367
|
+
// it exits 1. Verified against the consumers in this repo: both smoke
|
|
368
|
+
// harnesses (tests/smoke/aidr-matrix.sh — `--skip-binary`;
|
|
369
|
+
// tests/smoke/aidr-upgrade.sh — `--agent claude`, self-healing) are
|
|
370
|
+
// unaffected in either direction.
|
|
371
|
+
if (stranded.length > 0) process.exit(1);
|
|
372
|
+
}
|
|
309
373
|
}
|
|
310
374
|
|
|
311
375
|
async function cmdUninstall(opts) {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Closing status for `aidr install` — the last thing the user reads.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS IS A SEPARATE, TESTABLE MODULE (issue #1687)
|
|
4
|
+
// ----------------------------------------------------
|
|
5
|
+
// Before this existed, an install that obtained no scanner binary printed one
|
|
6
|
+
// warning line and then `continuing with hook install`, and exited 0. Forty
|
|
7
|
+
// lines of per-agent output later, the run looked exactly like a successful
|
|
8
|
+
// one. The machine was configured and unprotected at the same time, and the
|
|
9
|
+
// only sentence about it had scrolled away.
|
|
10
|
+
//
|
|
11
|
+
// docs/product/UI_COPY.md, two 【非交渉】 rules, both of which apply here:
|
|
12
|
+
// 1. A failure sentence never ends at the fact. It carries the next action.
|
|
13
|
+
// Rewriting it into a success sentence is explicitly forbidden.
|
|
14
|
+
// 2. "Unknown" must not read as "safe", and must not be defended with a
|
|
15
|
+
// negation ("this does not mean ..."). The uncertainty is stated on the
|
|
16
|
+
// fact side instead.
|
|
17
|
+
// Both are load-bearing here: whether the hooks' own download succeeds at the
|
|
18
|
+
// next session start is genuinely unknown at install time, and saying nothing
|
|
19
|
+
// about it is what made this defect ship.
|
|
20
|
+
|
|
21
|
+
const RULE = "─".repeat(72);
|
|
22
|
+
|
|
23
|
+
function bulletList(names) {
|
|
24
|
+
return names.join(", ");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The machine has no scanner binary. `selfHealing` are the installed agents
|
|
29
|
+
* whose hooks re-try the download at the start of their next session
|
|
30
|
+
* (start_scanner.sh, unpinned key); `stranded` are the installed agents that
|
|
31
|
+
* need the binary and have no such retry — for them nothing on this machine
|
|
32
|
+
* will ever fetch it again on its own.
|
|
33
|
+
*/
|
|
34
|
+
export function formatMissingBinaryStatus({
|
|
35
|
+
reason,
|
|
36
|
+
selfHealing = [],
|
|
37
|
+
stranded = [],
|
|
38
|
+
binPaths = [],
|
|
39
|
+
installCommand = "npx @coworker-jp/aidr install ...",
|
|
40
|
+
}) {
|
|
41
|
+
const lines = [
|
|
42
|
+
"",
|
|
43
|
+
RULE,
|
|
44
|
+
"This machine is NOT being scanned yet — the ai-scanner binary is not in place.",
|
|
45
|
+
"",
|
|
46
|
+
` what stopped it: ${reason}`,
|
|
47
|
+
"",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
if (selfHealing.length > 0) {
|
|
51
|
+
lines.push(
|
|
52
|
+
`The hooks for ${bulletList(selfHealing)} are installed, and they try to`,
|
|
53
|
+
"download the binary once at the start of the next session.",
|
|
54
|
+
"Whether that download will succeed is unknown from here: nothing on this",
|
|
55
|
+
"machine has checked it yet, and until it lands, tool calls are unscanned.",
|
|
56
|
+
"",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (stranded.length > 0) {
|
|
60
|
+
lines.push(
|
|
61
|
+
`${bulletList(stranded)} will not re-try: the hooks for those agents exit`,
|
|
62
|
+
"without scanning while the binary is missing, and nothing on this machine",
|
|
63
|
+
"fetches it again.",
|
|
64
|
+
"Re-running this installer is the only thing that puts it in place.",
|
|
65
|
+
"",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
lines.push("Do this next:");
|
|
70
|
+
lines.push(` 1. Re-run the installer once the network and the access key are working:`);
|
|
71
|
+
lines.push(` ${installCommand}`);
|
|
72
|
+
if (binPaths.length > 0) {
|
|
73
|
+
lines.push(" 2. Confirm the binary is there afterwards:");
|
|
74
|
+
for (const p of binPaths) lines.push(` ls -l ${p}`);
|
|
75
|
+
lines.push(" 3. If it is still missing, contact your IT administrator and show them");
|
|
76
|
+
lines.push(" the 'binary fetch failed' line above.");
|
|
77
|
+
} else {
|
|
78
|
+
lines.push(" 2. If it still does not arrive, contact your IT administrator and show");
|
|
79
|
+
lines.push(" them the 'binary fetch failed' line above.");
|
|
80
|
+
}
|
|
81
|
+
lines.push(RULE);
|
|
82
|
+
lines.push("");
|
|
83
|
+
return lines.join("\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The scanner binary IS in place, but a later step of the binary install threw
|
|
88
|
+
* (the bundled Opengrep engine, or the copy into the other agents' bin dirs).
|
|
89
|
+
* Protection exists, so this must not be dressed up as an outage — but the
|
|
90
|
+
* install is incomplete and saying "installed" alone would hide that.
|
|
91
|
+
*/
|
|
92
|
+
export function formatPartialBinaryStatus({
|
|
93
|
+
reason,
|
|
94
|
+
installCommand = "npx @coworker-jp/aidr install ...",
|
|
95
|
+
}) {
|
|
96
|
+
return [
|
|
97
|
+
"",
|
|
98
|
+
RULE,
|
|
99
|
+
"Scanning is active on this machine, but the binary step did not finish.",
|
|
100
|
+
"",
|
|
101
|
+
` what stopped it: ${reason}`,
|
|
102
|
+
"",
|
|
103
|
+
"Which agents received their own copy of the scanner, and whether the",
|
|
104
|
+
"code-analysis engine (opengrep) is present, is unknown from here. While",
|
|
105
|
+
"opengrep is absent the code-analysis rules stay switched off.",
|
|
106
|
+
"",
|
|
107
|
+
"Do this next:",
|
|
108
|
+
` 1. Re-run the installer to complete it: ${installCommand}`,
|
|
109
|
+
" 2. If it stops at the same place, contact your IT administrator and show",
|
|
110
|
+
" them the 'binary fetch failed' line above.",
|
|
111
|
+
RULE,
|
|
112
|
+
"",
|
|
113
|
+
].join("\n");
|
|
114
|
+
}
|
package/src/verify.mjs
CHANGED
|
@@ -14,7 +14,35 @@ export function verifyEndpoint(env = "prod") {
|
|
|
14
14
|
return `${base}/verify`;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// `AIDR_DOWNLOAD_BASE` redirects the licensed download endpoint, but ONLY to
|
|
18
|
+
// loopback. It is a TEST SEAM, not a configuration knob: the object it fetches
|
|
19
|
+
// IS the protection, so an env var able to point it at an arbitrary host would
|
|
20
|
+
// be a supply-chain hole in a security product. Restricted to loopback, anyone
|
|
21
|
+
// who can set it already owns the machine and gains nothing they did not have.
|
|
22
|
+
// A non-loopback value is refused *out loud* rather than silently ignored — a
|
|
23
|
+
// mis-set variable that quietly does nothing is the "silently lies" shape this
|
|
24
|
+
// repo keeps paying for.
|
|
25
|
+
const LOOPBACK_BASE_RE = /^http:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::\d+)?(?:\/|$)/;
|
|
26
|
+
let warnedAboutDownloadBase = false;
|
|
27
|
+
|
|
28
|
+
export function downloadBaseOverride(env = process.env) {
|
|
29
|
+
const raw = env.AIDR_DOWNLOAD_BASE;
|
|
30
|
+
if (!raw) return null;
|
|
31
|
+
if (LOOPBACK_BASE_RE.test(raw)) return raw.replace(/\/+$/, "");
|
|
32
|
+
if (!warnedAboutDownloadBase) {
|
|
33
|
+
warnedAboutDownloadBase = true;
|
|
34
|
+
console.warn(
|
|
35
|
+
`warning: AIDR_DOWNLOAD_BASE is set to a non-loopback URL and was refused; ` +
|
|
36
|
+
`the built-in download endpoint is being used instead. ` +
|
|
37
|
+
`Unset it, or point it at http://127.0.0.1:<port> if you are running tests.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
17
43
|
export function downloadBase(env = "prod") {
|
|
44
|
+
const override = downloadBaseOverride();
|
|
45
|
+
if (override) return `${override}/download`;
|
|
18
46
|
const base = ENDPOINTS[env] || ENDPOINTS.prod;
|
|
19
47
|
return `${base}/download`;
|
|
20
48
|
}
|