@atlassian-dc-mcp/common 0.17.1 → 0.18.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/CHANGELOG.md +11 -0
- package/build/__tests__/describe-error.test.d.ts +2 -0
- package/build/__tests__/describe-error.test.d.ts.map +1 -0
- package/build/__tests__/describe-error.test.js +54 -0
- package/build/__tests__/describe-error.test.js.map +1 -0
- package/build/__tests__/setup-cli.test.js +151 -44
- package/build/__tests__/setup-cli.test.js.map +1 -1
- package/build/config/source.d.ts +1 -0
- package/build/config/source.d.ts.map +1 -1
- package/build/config/source.js.map +1 -1
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/setup/describe-error.d.ts +2 -0
- package/build/setup/describe-error.d.ts.map +1 -0
- package/build/setup/describe-error.js +50 -0
- package/build/setup/describe-error.js.map +1 -0
- package/build/setup/value-validator.d.ts +8 -0
- package/build/setup/value-validator.d.ts.map +1 -0
- package/build/setup/value-validator.js +57 -0
- package/build/setup/value-validator.js.map +1 -0
- package/build/setup-cli.d.ts +15 -0
- package/build/setup-cli.d.ts.map +1 -1
- package/build/setup-cli.js +140 -27
- package/build/setup-cli.js.map +1 -1
- package/jest.config.js +1 -0
- package/package.json +2 -2
- package/src/__tests__/describe-error.test.ts +70 -0
- package/src/__tests__/setup-cli.test.ts +181 -38
- package/src/config/source.ts +1 -0
- package/src/index.ts +1 -0
- package/src/setup/describe-error.ts +67 -0
- package/src/setup/value-validator.ts +59 -0
- package/src/setup-cli.ts +191 -30
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const NETWORK_CODE_HINTS: Record<string, string> = {
|
|
2
|
+
ENOTFOUND: 'could not resolve host',
|
|
3
|
+
EAI_AGAIN: 'DNS lookup temporarily failed',
|
|
4
|
+
ECONNREFUSED: 'connection refused',
|
|
5
|
+
ECONNRESET: 'connection reset by peer',
|
|
6
|
+
ETIMEDOUT: 'connection timed out',
|
|
7
|
+
ECONNABORTED: 'connection aborted',
|
|
8
|
+
EHOSTUNREACH: 'host is unreachable',
|
|
9
|
+
ENETUNREACH: 'network is unreachable',
|
|
10
|
+
EPROTO: 'TLS protocol error',
|
|
11
|
+
CERT_HAS_EXPIRED: 'server TLS certificate has expired',
|
|
12
|
+
DEPTH_ZERO_SELF_SIGNED_CERT: 'server uses a self-signed TLS certificate',
|
|
13
|
+
SELF_SIGNED_CERT_IN_CHAIN: 'server TLS certificate chain is self-signed',
|
|
14
|
+
UNABLE_TO_VERIFY_LEAF_SIGNATURE: 'server TLS certificate could not be verified',
|
|
15
|
+
ERR_TLS_CERT_ALTNAME_INVALID: 'server TLS certificate does not match the host name',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type ApiLikeError = {
|
|
19
|
+
status: number;
|
|
20
|
+
statusText?: string;
|
|
21
|
+
url?: string;
|
|
22
|
+
body?: unknown;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type CauseLikeError = {
|
|
26
|
+
code?: string;
|
|
27
|
+
message?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function describeValidationError(error: unknown): string {
|
|
31
|
+
if (isApiLikeError(error)) {
|
|
32
|
+
return formatApiError(error);
|
|
33
|
+
}
|
|
34
|
+
if (isAbortError(error)) {
|
|
35
|
+
return 'request was aborted (possibly timed out)';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const err = error as { message?: string; cause?: CauseLikeError } | undefined;
|
|
39
|
+
const cause = err?.cause;
|
|
40
|
+
if (cause?.code) {
|
|
41
|
+
const hint = NETWORK_CODE_HINTS[cause.code] ?? 'network error';
|
|
42
|
+
const detail = cause.message ?? err?.message ?? 'unknown';
|
|
43
|
+
return `${hint} (${cause.code}: ${detail})`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return err?.message ?? String(error);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatApiError(error: ApiLikeError): string {
|
|
50
|
+
const status = `${error.status}${error.statusText ? ` ${error.statusText}` : ''}`.trim();
|
|
51
|
+
const url = error.url ? `${error.url} ` : '';
|
|
52
|
+
const hint = status.startsWith('401') || status.startsWith('403')
|
|
53
|
+
? ' Check the host, API base path, and API token.'
|
|
54
|
+
: '';
|
|
55
|
+
return `${url}returned ${status}.${hint}`.trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isApiLikeError(error: unknown): error is ApiLikeError {
|
|
59
|
+
return Boolean(error)
|
|
60
|
+
&& typeof error === 'object'
|
|
61
|
+
&& 'status' in (error as object)
|
|
62
|
+
&& typeof (error as ApiLikeError).status === 'number';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isAbortError(error: unknown): boolean {
|
|
66
|
+
return Boolean(error && typeof error === 'object' && (error as { name?: string }).name === 'AbortError');
|
|
67
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export type ValidationResult = true | string;
|
|
2
|
+
|
|
3
|
+
export class SetupValueValidator {
|
|
4
|
+
static host(raw: string): ValidationResult {
|
|
5
|
+
const value = raw.trim();
|
|
6
|
+
if (value.length === 0) {
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
if (/\s/.test(value)) {
|
|
10
|
+
return 'must not contain whitespace';
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
const url = new URL(/^https?:\/\//i.test(value) ? value : `https://${value}`);
|
|
14
|
+
if (!url.hostname) {
|
|
15
|
+
return 'must include a host name';
|
|
16
|
+
}
|
|
17
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
18
|
+
return 'must use http or https';
|
|
19
|
+
}
|
|
20
|
+
return true;
|
|
21
|
+
} catch {
|
|
22
|
+
return 'enter a host name or http(s) URL';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static apiBasePath(raw: string): ValidationResult {
|
|
27
|
+
const value = raw.trim();
|
|
28
|
+
if (value.length === 0) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
if (/\s/.test(value)) {
|
|
32
|
+
return 'must not contain whitespace';
|
|
33
|
+
}
|
|
34
|
+
if (/^https?:\/\//i.test(value)) {
|
|
35
|
+
try {
|
|
36
|
+
new URL(value);
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return 'enter a valid http(s) URL';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return value.startsWith('/') ? true : 'enter a path starting with / or a full http(s) URL';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
static token(raw: string): ValidationResult {
|
|
46
|
+
const value = raw.trim();
|
|
47
|
+
if (value.length === 0) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
return /\s/.test(value) ? 'must not contain whitespace' : true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
static pageSize(raw: string): ValidationResult {
|
|
54
|
+
const trimmed = raw.trim();
|
|
55
|
+
return /^\d+$/.test(trimmed) && Number.parseInt(trimmed, 10) > 0
|
|
56
|
+
? true
|
|
57
|
+
: 'enter a positive integer';
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/setup-cli.ts
CHANGED
|
@@ -8,19 +8,45 @@ import type {
|
|
|
8
8
|
} from './config/source.js';
|
|
9
9
|
import { HomeFileSource, getHomeFilePath } from './config/sources/home-file.js';
|
|
10
10
|
import { MacosKeychainSource } from './config/sources/macos-keychain.js';
|
|
11
|
+
import { SetupValueValidator } from './setup/value-validator.js';
|
|
11
12
|
|
|
12
13
|
const FALLBACK_PAGE_SIZE = 25;
|
|
14
|
+
const MAX_VALIDATION_ATTEMPTS = 3;
|
|
15
|
+
|
|
16
|
+
type PromptDefaults = {
|
|
17
|
+
host?: string;
|
|
18
|
+
apiBasePath?: string;
|
|
19
|
+
token?: string;
|
|
20
|
+
defaultPageSize?: number;
|
|
21
|
+
};
|
|
13
22
|
|
|
14
23
|
type PromptResult = {
|
|
15
24
|
host: string;
|
|
16
25
|
apiBasePath: string;
|
|
17
26
|
defaultPageSize: string;
|
|
18
|
-
|
|
27
|
+
tokenToWrite: string | undefined;
|
|
28
|
+
tokenForValidation: string | undefined;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type TokenPromptResult = Pick<PromptResult, 'tokenToWrite' | 'tokenForValidation'>;
|
|
32
|
+
|
|
33
|
+
export type CredentialValidationContext = {
|
|
34
|
+
host: string;
|
|
35
|
+
apiBasePath: string;
|
|
36
|
+
token: string;
|
|
19
37
|
};
|
|
20
38
|
|
|
39
|
+
export type CredentialValidationResult =
|
|
40
|
+
| { ok: true; detail?: string }
|
|
41
|
+
| { ok: false; message: string };
|
|
42
|
+
|
|
43
|
+
export type ValidateCredentials = (
|
|
44
|
+
context: CredentialValidationContext,
|
|
45
|
+
) => Promise<CredentialValidationResult>;
|
|
46
|
+
|
|
21
47
|
export type SetupPrompts = {
|
|
22
48
|
input: (opts: { message: string; default?: string; validate?: (raw: string) => true | string }) => Promise<string>;
|
|
23
|
-
password: (opts: { message: string; mask?: string }) => Promise<string>;
|
|
49
|
+
password: (opts: { message: string; mask?: string; validate?: (raw: string) => true | string }) => Promise<string>;
|
|
24
50
|
confirm: (opts: { message: string; default?: boolean }) => Promise<boolean>;
|
|
25
51
|
};
|
|
26
52
|
|
|
@@ -29,6 +55,7 @@ export type SetupDeps = {
|
|
|
29
55
|
log?: (message: string) => void;
|
|
30
56
|
exit?: (code: number) => void;
|
|
31
57
|
prompts?: SetupPrompts;
|
|
58
|
+
validateCredentials?: ValidateCredentials;
|
|
32
59
|
};
|
|
33
60
|
|
|
34
61
|
const DEFAULT_PROMPTS: SetupPrompts = {
|
|
@@ -51,23 +78,112 @@ export async function runSetup(product: ProductDefinition, deps: SetupDeps = {})
|
|
|
51
78
|
const current = getProductRuntimeConfig(product);
|
|
52
79
|
printCurrent(log, registry, product, current);
|
|
53
80
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
} catch (error) {
|
|
58
|
-
if (isUserCancel(error)) {
|
|
59
|
-
exit(130);
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
throw error;
|
|
81
|
+
const answers = await collectAnswersWithValidation(product, deps, prompts, current, log, exit);
|
|
82
|
+
if (!answers) {
|
|
83
|
+
return;
|
|
63
84
|
}
|
|
64
85
|
|
|
65
86
|
const homeFile = requireHomeFile(registry);
|
|
66
87
|
writeNonSecretFields(registry, product, answers, homeFile, log);
|
|
67
|
-
const tokenWriter = await writeToken(registry, product, answers.
|
|
88
|
+
const tokenWriter = await writeToken(registry, product, answers.tokenToWrite, homeFile, log, prompts);
|
|
68
89
|
printSummary(log, product, answers, tokenWriter);
|
|
69
90
|
}
|
|
70
91
|
|
|
92
|
+
async function collectAnswersWithValidation(
|
|
93
|
+
product: ProductDefinition,
|
|
94
|
+
deps: SetupDeps,
|
|
95
|
+
prompts: SetupPrompts,
|
|
96
|
+
current: ReturnType<typeof getProductRuntimeConfig>,
|
|
97
|
+
log: (message: string) => void,
|
|
98
|
+
exit: (code: number) => void,
|
|
99
|
+
): Promise<PromptResult | undefined> {
|
|
100
|
+
let defaults: PromptDefaults = current;
|
|
101
|
+
|
|
102
|
+
for (let attempt = 1; ; attempt++) {
|
|
103
|
+
let answers: PromptResult;
|
|
104
|
+
try {
|
|
105
|
+
answers = await promptForValues(prompts, product, defaults);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (isUserCancel(error)) {
|
|
108
|
+
exit(130);
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const answerErrors = validateAnswers(product, answers);
|
|
115
|
+
if (answerErrors.length > 0) {
|
|
116
|
+
for (const message of answerErrors) {
|
|
117
|
+
log(`Validation failed: ${message}`);
|
|
118
|
+
}
|
|
119
|
+
const retry = await confirmRetry(prompts, 'Try again?');
|
|
120
|
+
if (retry) {
|
|
121
|
+
defaults = answersAsDefaults(answers);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
exit(1);
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!deps.validateCredentials || !answers.tokenForValidation) {
|
|
129
|
+
return answers;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const result = await deps.validateCredentials({
|
|
133
|
+
host: answers.host,
|
|
134
|
+
apiBasePath: answers.apiBasePath,
|
|
135
|
+
token: answers.tokenForValidation,
|
|
136
|
+
});
|
|
137
|
+
if (result.ok) {
|
|
138
|
+
log(result.detail ? `Validation succeeded: ${result.detail}` : 'Validation succeeded.');
|
|
139
|
+
return answers;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
log(`Validation failed: ${result.message}`);
|
|
143
|
+
const outcome = await offerRetryAfterFailure(prompts, attempt);
|
|
144
|
+
if (outcome === 'retry') {
|
|
145
|
+
defaults = answersAsDefaults(answers);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (outcome === 'save-anyway') {
|
|
149
|
+
return answers;
|
|
150
|
+
}
|
|
151
|
+
exit(1);
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function answersAsDefaults(answers: PromptResult): PromptDefaults {
|
|
157
|
+
const pageSize = Number.parseInt(answers.defaultPageSize, 10);
|
|
158
|
+
return {
|
|
159
|
+
host: answers.host,
|
|
160
|
+
apiBasePath: answers.apiBasePath,
|
|
161
|
+
token: answers.tokenForValidation,
|
|
162
|
+
defaultPageSize: Number.isFinite(pageSize) && pageSize > 0 ? pageSize : undefined,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function confirmRetry(prompts: SetupPrompts, message: string): Promise<boolean> {
|
|
167
|
+
return prompts.confirm({ message, default: true });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function offerRetryAfterFailure(
|
|
171
|
+
prompts: SetupPrompts,
|
|
172
|
+
attempt: number,
|
|
173
|
+
): Promise<'retry' | 'save-anyway' | 'abort'> {
|
|
174
|
+
if (attempt < MAX_VALIDATION_ATTEMPTS) {
|
|
175
|
+
const retry = await confirmRetry(prompts, 'Try again with different values?');
|
|
176
|
+
if (retry) {
|
|
177
|
+
return 'retry';
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const saveAnyway = await prompts.confirm({
|
|
181
|
+
message: 'Save configuration anyway?',
|
|
182
|
+
default: false,
|
|
183
|
+
});
|
|
184
|
+
return saveAnyway ? 'save-anyway' : 'abort';
|
|
185
|
+
}
|
|
186
|
+
|
|
71
187
|
function requireHomeFile(registry: ConfigRegistry): HomeFileSource {
|
|
72
188
|
const homeFile = registry.getWritableSource(
|
|
73
189
|
(s): s is HomeFileSource => s instanceof HomeFileSource,
|
|
@@ -98,42 +214,84 @@ function printCurrent(
|
|
|
98
214
|
async function promptForValues(
|
|
99
215
|
prompts: SetupPrompts,
|
|
100
216
|
product: ProductDefinition,
|
|
101
|
-
|
|
217
|
+
defaults: PromptDefaults,
|
|
102
218
|
): Promise<PromptResult> {
|
|
103
219
|
const host = await prompts.input({
|
|
104
220
|
message: 'Host (e.g. jira.example.com):',
|
|
105
|
-
default:
|
|
221
|
+
default: defaults.host ?? '',
|
|
222
|
+
validate: SetupValueValidator.host,
|
|
106
223
|
});
|
|
107
224
|
const apiBasePath = await prompts.input({
|
|
108
225
|
message: 'API base path:',
|
|
109
|
-
default:
|
|
226
|
+
default: defaults.apiBasePath ?? product.defaultApiBasePath ?? '',
|
|
227
|
+
validate: SetupValueValidator.apiBasePath,
|
|
110
228
|
});
|
|
111
229
|
const defaultPageSize = await prompts.input({
|
|
112
230
|
message: 'Default page size:',
|
|
113
|
-
default: String(
|
|
114
|
-
validate:
|
|
115
|
-
/^\d+$/.test(raw.trim()) && Number.parseInt(raw.trim(), 10) > 0
|
|
116
|
-
? true
|
|
117
|
-
: 'Enter a positive integer',
|
|
231
|
+
default: String(defaults.defaultPageSize ?? FALLBACK_PAGE_SIZE),
|
|
232
|
+
validate: SetupValueValidator.pageSize,
|
|
118
233
|
});
|
|
119
|
-
const token = await promptForToken(prompts,
|
|
120
|
-
return {
|
|
234
|
+
const token = await promptForToken(prompts, defaults.token);
|
|
235
|
+
return {
|
|
236
|
+
host: host.trim(),
|
|
237
|
+
apiBasePath: apiBasePath.trim(),
|
|
238
|
+
defaultPageSize: defaultPageSize.trim(),
|
|
239
|
+
...token,
|
|
240
|
+
};
|
|
121
241
|
}
|
|
122
242
|
|
|
123
243
|
async function promptForToken(
|
|
124
244
|
prompts: SetupPrompts,
|
|
125
245
|
existing: string | undefined,
|
|
126
|
-
): Promise<
|
|
127
|
-
const entered = await prompts.password({
|
|
246
|
+
): Promise<TokenPromptResult> {
|
|
247
|
+
const entered = await prompts.password({
|
|
248
|
+
message: 'API token:',
|
|
249
|
+
mask: '*',
|
|
250
|
+
validate: SetupValueValidator.token,
|
|
251
|
+
});
|
|
128
252
|
const trimmed = entered.trim();
|
|
129
253
|
if (trimmed.length > 0) {
|
|
130
|
-
return trimmed;
|
|
254
|
+
return { tokenToWrite: trimmed, tokenForValidation: trimmed };
|
|
131
255
|
}
|
|
132
256
|
if (!existing) {
|
|
133
|
-
return undefined;
|
|
257
|
+
return { tokenToWrite: undefined, tokenForValidation: undefined };
|
|
134
258
|
}
|
|
135
|
-
await prompts.confirm({ message: 'Keep existing token?', default: true });
|
|
136
|
-
return
|
|
259
|
+
const keepExisting = await prompts.confirm({ message: 'Keep existing token?', default: true });
|
|
260
|
+
return {
|
|
261
|
+
tokenToWrite: undefined,
|
|
262
|
+
tokenForValidation: keepExisting ? existing : undefined,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function validateAnswers(product: ProductDefinition, answers: PromptResult): string[] {
|
|
267
|
+
const errors: string[] = [];
|
|
268
|
+
for (const [label, value, validator] of [
|
|
269
|
+
['host', answers.host, SetupValueValidator.host],
|
|
270
|
+
['API base path', answers.apiBasePath, SetupValueValidator.apiBasePath],
|
|
271
|
+
['API token', answers.tokenForValidation ?? '', SetupValueValidator.token],
|
|
272
|
+
] as const) {
|
|
273
|
+
const result = validator(value);
|
|
274
|
+
if (result !== true) {
|
|
275
|
+
errors.push(`${label}: ${result}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const pageSize = SetupValueValidator.pageSize(answers.defaultPageSize);
|
|
280
|
+
if (pageSize !== true) {
|
|
281
|
+
errors.push(`default page size: ${pageSize}`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!answers.tokenForValidation) {
|
|
285
|
+
errors.push(`API token is required (${product.envVars.token}).`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const hasHost = answers.host.length > 0;
|
|
289
|
+
const hasFullApiBasePath = /^https?:\/\//i.test(answers.apiBasePath);
|
|
290
|
+
if (!hasHost && !hasFullApiBasePath) {
|
|
291
|
+
errors.push(`Enter ${product.envVars.host}, or enter a full URL for ${product.envVars.apiBasePath}.`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return errors;
|
|
137
295
|
}
|
|
138
296
|
|
|
139
297
|
function writeNonSecretFields(
|
|
@@ -208,7 +366,10 @@ async function tryWrite(
|
|
|
208
366
|
message: 'Fall back to plaintext home file with mode 0600?',
|
|
209
367
|
default: false,
|
|
210
368
|
});
|
|
211
|
-
|
|
369
|
+
if (fallback) {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
throw new Error('Token was not saved because keychain write failed and plaintext fallback was declined');
|
|
212
373
|
}
|
|
213
374
|
return false;
|
|
214
375
|
}
|
|
@@ -254,7 +415,7 @@ function printSummary(
|
|
|
254
415
|
log(` apiBasePath: ${answers.apiBasePath || '(unchanged)'}`);
|
|
255
416
|
log(` defaultPageSize: ${answers.defaultPageSize || '(unchanged)'}`);
|
|
256
417
|
if (tokenWriter) {
|
|
257
|
-
log(` token: ${maskToken(answers.
|
|
418
|
+
log(` token: ${maskToken(answers.tokenToWrite)} (stored in ${describeWriter(tokenWriter, product)})`);
|
|
258
419
|
} else {
|
|
259
420
|
log(' token: (unchanged)');
|
|
260
421
|
}
|