@brass-build/cli 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.
Files changed (70) hide show
  1. package/AGENTS.md +170 -0
  2. package/CHANGELOG.md +12 -0
  3. package/LICENSE +21 -0
  4. package/README.md +172 -0
  5. package/dist/api.d.ts +73 -0
  6. package/dist/api.d.ts.map +1 -0
  7. package/dist/api.js +97 -0
  8. package/dist/api.js.map +1 -0
  9. package/dist/args.d.ts +10 -0
  10. package/dist/args.d.ts.map +1 -0
  11. package/dist/args.js +94 -0
  12. package/dist/args.js.map +1 -0
  13. package/dist/auth.d.ts +5 -0
  14. package/dist/auth.d.ts.map +1 -0
  15. package/dist/auth.js +12 -0
  16. package/dist/auth.js.map +1 -0
  17. package/dist/bin/brass.d.ts +3 -0
  18. package/dist/bin/brass.d.ts.map +1 -0
  19. package/dist/bin/brass.js +11 -0
  20. package/dist/bin/brass.js.map +1 -0
  21. package/dist/cli.d.ts +4 -0
  22. package/dist/cli.d.ts.map +1 -0
  23. package/dist/cli.js +364 -0
  24. package/dist/cli.js.map +1 -0
  25. package/dist/commands.d.ts +94 -0
  26. package/dist/commands.d.ts.map +1 -0
  27. package/dist/commands.js +559 -0
  28. package/dist/commands.js.map +1 -0
  29. package/dist/config.d.ts +40 -0
  30. package/dist/config.d.ts.map +1 -0
  31. package/dist/config.js +76 -0
  32. package/dist/config.js.map +1 -0
  33. package/dist/log.d.ts +9 -0
  34. package/dist/log.d.ts.map +1 -0
  35. package/dist/log.js +32 -0
  36. package/dist/log.js.map +1 -0
  37. package/dist/login.d.ts +21 -0
  38. package/dist/login.d.ts.map +1 -0
  39. package/dist/login.js +158 -0
  40. package/dist/login.js.map +1 -0
  41. package/dist/project.d.ts +32 -0
  42. package/dist/project.d.ts.map +1 -0
  43. package/dist/project.js +129 -0
  44. package/dist/project.js.map +1 -0
  45. package/dist/session.d.ts +47 -0
  46. package/dist/session.d.ts.map +1 -0
  47. package/dist/session.js +224 -0
  48. package/dist/session.js.map +1 -0
  49. package/dist/store.d.ts +17 -0
  50. package/dist/store.d.ts.map +1 -0
  51. package/dist/store.js +90 -0
  52. package/dist/store.js.map +1 -0
  53. package/dist/version.d.ts +3 -0
  54. package/dist/version.d.ts.map +1 -0
  55. package/dist/version.js +8 -0
  56. package/dist/version.js.map +1 -0
  57. package/package.json +42 -0
  58. package/src/api.ts +195 -0
  59. package/src/args.ts +107 -0
  60. package/src/auth.ts +16 -0
  61. package/src/bin/brass.ts +11 -0
  62. package/src/cli.ts +422 -0
  63. package/src/commands.ts +864 -0
  64. package/src/config.ts +132 -0
  65. package/src/log.ts +41 -0
  66. package/src/login.ts +211 -0
  67. package/src/project.ts +176 -0
  68. package/src/session.ts +319 -0
  69. package/src/store.ts +123 -0
  70. package/src/version.ts +8 -0
package/src/session.ts ADDED
@@ -0,0 +1,319 @@
1
+ // Browser sign-in for the CLI, and the session-backed auth provider it
2
+ // produces. `brass login` runs the RFC 8628 device-authorization grant: the
3
+ // CLI mints a grant, opens the browser to the approval page with the user
4
+ // code prefilled (and prints the URL + code as a fallback for a headless
5
+ // box), then polls until the human approves. Approval mints the same tokens
6
+ // `/refresh` returns, including the opaque `session_token` the CLI stores to
7
+ // keep refreshing. The CLI's OAuth client is the seeded
8
+ // `brass_app_internal_cli`; its redirect allowlist is the loopback wildcards,
9
+ // which the `/refresh` Origin gate checks the CLI's portless 127.0.0.1 origin
10
+ // against on every later token refresh.
11
+
12
+ import { spawn } from 'node:child_process';
13
+ import { BrassApiError } from './api.js';
14
+ import type { AuthProvider } from './auth.js';
15
+
16
+ // The seeded CLI OAuth client (see infra/lib/api-stack.ts). Stable across
17
+ // environments, so one CLI build signs in against prod or dev.
18
+ export const CLI_APP_ID = 'brass_app_internal_cli';
19
+
20
+ // The Origin the CLI presents on every `/refresh` call (a command refreshing
21
+ // its access token from the stored session). The CLI app's loopback wildcard
22
+ // allowlist matches any 127.0.0.1 origin, port ignored, so this portless
23
+ // loopback origin passes the `/refresh` Origin gate.
24
+ const REFRESH_ORIGIN = 'http://127.0.0.1';
25
+
26
+ const realSleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
27
+
28
+ export interface RefreshedTokens {
29
+ accessToken: string;
30
+ idToken: string;
31
+ expiresAt: number;
32
+ // Present only on the initial device-grant token exchange: the opaque
33
+ // session pointer the CLI persists and echoes on later refreshes.
34
+ sessionToken?: string;
35
+ }
36
+
37
+ interface RefreshWire {
38
+ access_token: string;
39
+ id_token: string;
40
+ expires_in: number;
41
+ session_token?: string;
42
+ }
43
+
44
+ // Refresh an access token at `/refresh` from the stored session pointer
45
+ // (`sid`). A form-encoded body keeps this a CORS simple request (the auth API
46
+ // serves no preflight); the Origin header is required by the endpoint's
47
+ // allowlist and satisfied by a loopback origin.
48
+ export async function postRefresh(
49
+ authBaseUrl: string,
50
+ sid: string,
51
+ origin: string = REFRESH_ORIGIN,
52
+ now: number = Date.now(),
53
+ ): Promise<RefreshedTokens> {
54
+ const body = new URLSearchParams({ sid });
55
+ const url = `${authBaseUrl}/refresh?${new URLSearchParams({ app_id: CLI_APP_ID }).toString()}`;
56
+ let response: Response;
57
+ try {
58
+ response = await fetch(url, {
59
+ method: 'POST',
60
+ headers: { 'content-type': 'application/x-www-form-urlencoded', origin },
61
+ body,
62
+ });
63
+ } catch (cause) {
64
+ throw new BrassApiError(0, `Network error reaching ${authBaseUrl}: ${String(cause)}`);
65
+ }
66
+ if (!response.ok) {
67
+ // Carry the status, and name what a 401 means. Every command reaches the
68
+ // data API through this refresh, so this error is what the caller
69
+ // classifies the whole attempt by, and a status-free error here is
70
+ // indistinguishable from a request that never completed: that is what
71
+ // turns an expired session into a report of an unreachable API.
72
+ throw new BrassApiError(
73
+ response.status,
74
+ response.status === 401
75
+ ? `Your Brass sign-in is no longer valid (401). Run 'brass login' to sign in again.`
76
+ : `Sign-in exchange failed (${response.status}).`,
77
+ );
78
+ }
79
+ const json = (await response.json()) as RefreshWire;
80
+ return {
81
+ accessToken: json.access_token,
82
+ idToken: json.id_token,
83
+ // Default the lifetime when the server omits it: `undefined * 1000` is
84
+ // `NaN`, which makes `expiresAt` NaN and the `Date.now() >= expiresAt`
85
+ // refresh guard permanently false, so the in-process token would never
86
+ // re-refresh. `pollDeviceToken` already guards the same field.
87
+ expiresAt: now + (json.expires_in ?? 3600) * 1000,
88
+ ...(json.session_token ? { sessionToken: json.session_token } : {}),
89
+ };
90
+ }
91
+
92
+ // Decode the email claim from a Cognito id token, best-effort (used only for
93
+ // the "Signed in as ..." confirmation; never for authorization).
94
+ export function decodeEmail(idToken: string): string | undefined {
95
+ const part = idToken.split('.')[1];
96
+ if (part === undefined) return undefined;
97
+ try {
98
+ const claims = JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) as {
99
+ email?: unknown;
100
+ };
101
+ return typeof claims.email === 'string' ? claims.email : undefined;
102
+ } catch {
103
+ return undefined;
104
+ }
105
+ }
106
+
107
+ export interface LoginResult {
108
+ sessionToken: string;
109
+ email: string | undefined;
110
+ }
111
+
112
+ // --- Device-authorization grant (RFC 8628) ---
113
+ // The CLI shows a URL + short code (and opens the URL with the code
114
+ // prefilled), the human approves in any browser, and the CLI polls until the
115
+ // grant is approved. The code lets the human confirm they are approving the
116
+ // sign-in they just started, the grant's phishing defence.
117
+
118
+ export interface DeviceAuthorization {
119
+ deviceCode: string;
120
+ userCode: string;
121
+ verificationUri: string;
122
+ verificationUriComplete?: string;
123
+ intervalSeconds: number;
124
+ expiresAt: number;
125
+ }
126
+
127
+ interface DeviceAuthorizeWire {
128
+ device_code: string;
129
+ user_code: string;
130
+ verification_uri: string;
131
+ verification_uri_complete?: string;
132
+ interval: number;
133
+ expires_in: number;
134
+ }
135
+
136
+ export async function deviceAuthorize(
137
+ authBaseUrl: string,
138
+ now: number = Date.now(),
139
+ ): Promise<DeviceAuthorization> {
140
+ const body = new URLSearchParams({ app_id: CLI_APP_ID });
141
+ let response: Response;
142
+ try {
143
+ response = await fetch(`${authBaseUrl}/device/authorize`, {
144
+ method: 'POST',
145
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
146
+ body,
147
+ });
148
+ } catch (cause) {
149
+ throw new Error(`Network error reaching ${authBaseUrl}: ${String(cause)}`);
150
+ }
151
+ if (!response.ok) throw new Error(`Device sign-in could not start (${response.status})`);
152
+ const json = (await response.json()) as DeviceAuthorizeWire;
153
+ return {
154
+ deviceCode: json.device_code,
155
+ userCode: json.user_code,
156
+ verificationUri: json.verification_uri,
157
+ ...(json.verification_uri_complete
158
+ ? { verificationUriComplete: json.verification_uri_complete }
159
+ : {}),
160
+ // Guard both fields the poll loop depends on. The server always sends
161
+ // them, but a missing `interval` makes `sleep(NaN * 1000)` a zero-delay
162
+ // tight poll of /device/token, and a missing `expires_in` makes the
163
+ // `now() >= expiresAt` deadline check always false, so the loop never
164
+ // times out. The token-exchange path already guards `expires_in` the same
165
+ // way. RFC 8628 defaults: 5s poll interval, 600s grant lifetime.
166
+ intervalSeconds: json.interval ?? 5,
167
+ expiresAt: now + (json.expires_in ?? 600) * 1000,
168
+ };
169
+ }
170
+
171
+ // One `/device/token` poll, classified into the four wire states a caller
172
+ // acts on. The blocking `pollDeviceToken` loop and the two-phase
173
+ // `brass login --check` both consume this, so the classification of the
174
+ // grant's wire states lives in one place.
175
+ export type DevicePollOutcome =
176
+ | { state: 'approved'; tokens: RefreshedTokens }
177
+ | { state: 'pending' }
178
+ | { state: 'slow_down' }
179
+ | { state: 'denied' };
180
+
181
+ export async function pollDeviceTokenOnce(
182
+ authBaseUrl: string,
183
+ deviceCode: string,
184
+ now: number = Date.now(),
185
+ ): Promise<DevicePollOutcome> {
186
+ const body = new URLSearchParams({ device_code: deviceCode, app_id: CLI_APP_ID });
187
+ let response: Response;
188
+ try {
189
+ response = await fetch(`${authBaseUrl}/device/token`, {
190
+ method: 'POST',
191
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
192
+ body,
193
+ });
194
+ } catch {
195
+ // A transient network error mid-poll: the device grant is still valid
196
+ // server-side, so report "still pending" and let the caller poll again
197
+ // rather than aborting a sign-in a retry would complete.
198
+ // (`deviceAuthorize` / `postRefresh` guard their fetches the same way.)
199
+ return { state: 'pending' };
200
+ }
201
+ const json = (await response.json().catch(() => ({}))) as Partial<RefreshWire> & {
202
+ error?: string;
203
+ };
204
+ if (response.ok && json.access_token && json.id_token) {
205
+ return {
206
+ state: 'approved',
207
+ tokens: {
208
+ accessToken: json.access_token,
209
+ idToken: json.id_token,
210
+ expiresAt: now + (json.expires_in ?? 3600) * 1000,
211
+ ...(json.session_token ? { sessionToken: json.session_token } : {}),
212
+ },
213
+ };
214
+ }
215
+ // The user declined: the one terminal refusal.
216
+ if (json.error === 'access_denied') return { state: 'denied' };
217
+ // RFC 8628 §3.5: polling too fast. The caller backs off.
218
+ if (json.error === 'slow_down') return { state: 'slow_down' };
219
+ // Everything else — `authorization_pending`, a transient non-2xx (5xx/429),
220
+ // a non-JSON body, or an error code this pinned CLI does not recognize — is
221
+ // "keep waiting". The caller's deadline check is the single terminal bound,
222
+ // so an unknown error or a server blip does not abort a sign-in the user is
223
+ // one poll away from completing; a genuinely expired grant simply ends
224
+ // there as a clean timeout.
225
+ return { state: 'pending' };
226
+ }
227
+
228
+ // Poll `/device/token` until the grant is approved, or reject on denial /
229
+ // expiry. Honors the server-supplied interval; treats `authorization_pending`
230
+ // as "keep waiting".
231
+ export async function pollDeviceToken(
232
+ authBaseUrl: string,
233
+ auth: DeviceAuthorization,
234
+ opts: { sleep?: (ms: number) => Promise<void>; now?: () => number } = {},
235
+ ): Promise<RefreshedTokens> {
236
+ const sleep = opts.sleep ?? realSleep;
237
+ const now = opts.now ?? ((): number => Date.now());
238
+ // Server-supplied poll interval, bumped on `slow_down` per RFC 8628 §3.5.
239
+ let intervalSeconds = auth.intervalSeconds;
240
+ for (;;) {
241
+ await sleep(intervalSeconds * 1000);
242
+ if (now() >= auth.expiresAt) throw new Error('Device sign-in timed out. Run `brass login` again.');
243
+ const outcome = await pollDeviceTokenOnce(authBaseUrl, auth.deviceCode, now());
244
+ if (outcome.state === 'approved') return outcome.tokens;
245
+ if (outcome.state === 'denied') throw new Error('Sign-in was denied.');
246
+ if (outcome.state === 'slow_down') intervalSeconds += 5;
247
+ }
248
+ }
249
+
250
+ export interface DeviceLoginOptions {
251
+ authBaseUrl: string;
252
+ // Shows the verification URL + user code to the human; defaults to stderr.
253
+ onPrompt?: (auth: DeviceAuthorization) => void;
254
+ // Opens the approval page (user code prefilled) in a browser; defaults to
255
+ // the OS opener. A no-op on a headless box, where the printed URL + code is
256
+ // the fallback.
257
+ openBrowser?: (url: string) => void;
258
+ sleep?: (ms: number) => Promise<void>;
259
+ }
260
+
261
+ export async function loginDevice(options: DeviceLoginOptions): Promise<LoginResult> {
262
+ const auth = await deviceAuthorize(options.authBaseUrl);
263
+ (options.onPrompt ?? defaultDevicePrompt)(auth);
264
+ (options.openBrowser ?? openBrowser)(auth.verificationUriComplete ?? auth.verificationUri);
265
+ const tokens = await pollDeviceToken(
266
+ options.authBaseUrl,
267
+ auth,
268
+ options.sleep ? { sleep: options.sleep } : {},
269
+ );
270
+ if (!tokens.sessionToken) throw new Error('Device sign-in did not return a session token.');
271
+ return { sessionToken: tokens.sessionToken, email: decodeEmail(tokens.idToken) };
272
+ }
273
+
274
+ function defaultDevicePrompt(auth: DeviceAuthorization): void {
275
+ const target = auth.verificationUriComplete ?? auth.verificationUri;
276
+ process.stderr.write(
277
+ '\nOpening your browser to approve this sign-in.\n' +
278
+ `If it doesn't open, go to:\n ${target}\n` +
279
+ `and confirm the code: ${auth.userCode}\n` +
280
+ '\nWaiting for approval...\n',
281
+ );
282
+ }
283
+
284
+ function openBrowser(url: string): void {
285
+ const platform = process.platform;
286
+ const [cmd, args] =
287
+ platform === 'darwin'
288
+ ? ['open', [url]]
289
+ : platform === 'win32'
290
+ ? ['cmd', ['/c', 'start', '', url]]
291
+ : ['xdg-open', [url]];
292
+ try {
293
+ const child = spawn(cmd as string, args as string[], { detached: true, stdio: 'ignore' });
294
+ // A missing opener (`xdg-open` absent on a headless box) surfaces ENOENT
295
+ // as an ASYNC 'error' event, not a throw; an unhandled one crashes the
296
+ // process. Swallow it: the URL is already printed, so the user opens it
297
+ // themselves.
298
+ child.on('error', () => {});
299
+ child.unref();
300
+ } catch {
301
+ // The URL was already printed; a failed auto-open is not fatal.
302
+ }
303
+ }
304
+
305
+ // An auth provider backed by a stored login session: refresh the access
306
+ // token on first use and whenever it is within the skew window of expiry,
307
+ // caching it in memory for the process's lifetime.
308
+ export function sessionAuth(authBaseUrl: string, sid: string): AuthProvider {
309
+ let cached: RefreshedTokens | null = null;
310
+ const SKEW_MS = 60 * 1000;
311
+ return {
312
+ async headers() {
313
+ if (cached === null || Date.now() >= cached.expiresAt - SKEW_MS) {
314
+ cached = await postRefresh(authBaseUrl, sid);
315
+ }
316
+ return { authorization: `Bearer ${cached.accessToken}`, 'x-id-token': cached.idToken };
317
+ },
318
+ };
319
+ }
package/src/store.ts ADDED
@@ -0,0 +1,123 @@
1
+ import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import type { CredentialsFile, Profile, StoredCredential } from './config.js';
5
+
6
+ // Where `brass login` persists its credential, and where
7
+ // `publish` / `schema` / `whoami` look for it when no `--token` /
8
+ // `BRASS_SERVICE_TOKEN` was given. Honors `XDG_CONFIG_HOME`, else
9
+ // `~/.config/brass/credentials.json`.
10
+ export function credentialsFilePath(env: NodeJS.ProcessEnv = process.env): string {
11
+ const base = env['XDG_CONFIG_HOME'];
12
+ const root = base && base.trim() !== '' ? base : join(homedir(), '.config');
13
+ return join(root, 'brass', 'credentials.json');
14
+ }
15
+
16
+ export async function readCredentialsFile(
17
+ env: NodeJS.ProcessEnv = process.env,
18
+ ): Promise<CredentialsFile | null> {
19
+ try {
20
+ const raw = await readFile(credentialsFilePath(env), 'utf8');
21
+ const parsed = JSON.parse(raw) as CredentialsFile;
22
+ if (parsed.version !== 1 || typeof parsed.credentials !== 'object') return null;
23
+ return parsed;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ // Merge one profile's stored credential into the file (0600 so the session
30
+ // pointer is not world-readable), or clear it when `credential` is null.
31
+ // Preserves the other env's entry.
32
+ export async function writeStoredCredential(
33
+ target: Profile,
34
+ credential: StoredCredential | null,
35
+ env: NodeJS.ProcessEnv = process.env,
36
+ ): Promise<void> {
37
+ const existing = (await readCredentialsFile(env)) ?? { version: 1 as const, credentials: {} };
38
+ const credentials = { ...existing.credentials };
39
+ if (credential === null) delete credentials[target];
40
+ else credentials[target] = credential;
41
+ await writeOwnerOnlyJson(credentialsFilePath(env), { version: 1, credentials } satisfies CredentialsFile);
42
+ }
43
+
44
+ // A device-authorization grant `brass login --start` has minted but the
45
+ // human has not yet approved. `brass login --check` reloads it to make its
46
+ // one poll; approval, denial, or expiry clears it.
47
+ export interface PendingLogin {
48
+ authBaseUrl: string;
49
+ deviceCode: string;
50
+ userCode: string;
51
+ verificationUri: string;
52
+ verificationUriComplete?: string;
53
+ intervalSeconds: number;
54
+ expiresAt: number;
55
+ }
56
+
57
+ interface PendingLoginsFile {
58
+ version: 1;
59
+ pending: Record<Profile, PendingLogin>;
60
+ }
61
+
62
+ // Sibling of the credentials file: the in-flight sign-in per profile. Kept
63
+ // out of credentials.json so starting a new sign-in never disturbs a stored
64
+ // session until the new grant is approved.
65
+ export function pendingLoginFilePath(env: NodeJS.ProcessEnv = process.env): string {
66
+ return join(dirname(credentialsFilePath(env)), 'pending-login.json');
67
+ }
68
+
69
+ async function readPendingLoginsFile(env: NodeJS.ProcessEnv): Promise<PendingLoginsFile | null> {
70
+ try {
71
+ const raw = await readFile(pendingLoginFilePath(env), 'utf8');
72
+ const parsed = JSON.parse(raw) as PendingLoginsFile;
73
+ if (parsed.version !== 1 || typeof parsed.pending !== 'object') return null;
74
+ return parsed;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ export async function readPendingLogin(
81
+ profile: Profile,
82
+ env: NodeJS.ProcessEnv = process.env,
83
+ ): Promise<PendingLogin | null> {
84
+ const file = await readPendingLoginsFile(env);
85
+ const entry = file?.pending[profile];
86
+ if (
87
+ entry === undefined ||
88
+ typeof entry.authBaseUrl !== 'string' ||
89
+ typeof entry.deviceCode !== 'string' ||
90
+ typeof entry.expiresAt !== 'number'
91
+ ) {
92
+ return null;
93
+ }
94
+ return entry;
95
+ }
96
+
97
+ // Merge one profile's pending grant into the file (0600: the device code is
98
+ // redeemable for tokens once the human approves), or clear it when `pending`
99
+ // is null. Preserves the other profiles' entries.
100
+ export async function writePendingLogin(
101
+ profile: Profile,
102
+ pending: PendingLogin | null,
103
+ env: NodeJS.ProcessEnv = process.env,
104
+ ): Promise<void> {
105
+ const existing = (await readPendingLoginsFile(env)) ?? { version: 1 as const, pending: {} };
106
+ const entries = { ...existing.pending };
107
+ if (pending === null) delete entries[profile];
108
+ else entries[profile] = pending;
109
+ await writeOwnerOnlyJson(pendingLoginFilePath(env), {
110
+ version: 1,
111
+ pending: entries,
112
+ } satisfies PendingLoginsFile);
113
+ }
114
+
115
+ async function writeOwnerOnlyJson(path: string, payload: unknown): Promise<void> {
116
+ await mkdir(dirname(path), { recursive: true });
117
+ await writeFile(path, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
118
+ // writeFile's `mode` applies only when it CREATES the file; an existing
119
+ // file (an older CLI wrote it, or a permissive umask) keeps its old perms.
120
+ // chmod unconditionally so a rewrite tightens a file that is already too
121
+ // open, rather than leaving the secret group/world-readable.
122
+ await chmod(path, 0o600);
123
+ }
package/src/version.ts ADDED
@@ -0,0 +1,8 @@
1
+ // The package version the CLI reports on every data-API call (the
2
+ // `x-brass-client` header; a Node process has no CORS so a header is
3
+ // free here, unlike the SDK's query-param channel). Lets the platform
4
+ // measure the CLI version distribution, in particular pinned CI copies.
5
+ // A unit test pins this to package.json's version.
6
+ export const VERSION = '0.1.0';
7
+
8
+ export const CLI_CLIENT_ID = `cli/${VERSION}`;