shakers-cli-beta 0.11.0 → 0.12.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/bin/certify.js +1 -1
- package/bin/login.js +44 -111
- package/package.json +1 -1
- package/src/agents-client.js +1 -1
- package/src/ai-profile-client.js +1 -1
- package/src/applications-client.js +1 -1
- package/src/availability-client.js +1 -1
- package/src/certifications-client.js +1 -1
- package/src/certify-dimension-client.js +0 -79
- package/src/certify-dimension-flow.js +24 -105
- package/src/config.js +31 -22
- package/src/find-projects-client.js +1 -1
- package/src/i18n.js +24 -78
- package/src/lang-client.js +1 -1
- package/src/mcp-device-login-tools.js +3 -3
- package/src/mcp-instructions.js +2 -2
- package/src/mcp-onboarding-tools.js +10 -16
- package/src/mcp-role-tools.js +13 -5
- package/src/mcp-signup-client.js +1 -1
- package/src/mcp-signup-tools.js +76 -18
- package/src/profile-client.js +1 -4
- package/src/rate-client.js +1 -2
- package/src/roles-client.js +0 -4
- package/src/saved-positions-client.js +1 -1
- package/src/show-project-client.js +1 -1
- package/src/signup-copy.js +34 -12
package/bin/certify.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
// `certify` — dimension
|
|
4
|
+
// `certify` — discover + pick a dimension, then hand over the web link (src/certify-dimension-flow.js).
|
|
5
5
|
|
|
6
6
|
const { detectReportLang, getCatalog } = require('../src/i18n');
|
|
7
7
|
const { loadAuthSession, sessionStatus } = require('../src/auth-session-store');
|
package/bin/login.js
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
// `login` / `logout` — a registered Talent identifies themselves to the CLI (talents-ai-score, issue 122 / ADR-044).
|
|
5
5
|
|
|
6
6
|
const { detectReportLang, getCatalog } = require('../src/i18n');
|
|
7
|
-
const {
|
|
8
|
-
const { requestLogin } = require('../src/auth-client');
|
|
7
|
+
const { getAuthTokenEndpoint, getDeviceAuthorizeEndpoint, getDeviceTokenEndpoint, getCompleteRegistrationEndpoint } = require('../src/config');
|
|
9
8
|
const { runDeviceLogin, appendProviderParam } = require('../src/device-login');
|
|
10
9
|
const { buildRegistrationContext } = require('../src/signup-client');
|
|
11
10
|
const { openPath } = require('../src/open-file');
|
|
@@ -15,7 +14,7 @@ const {
|
|
|
15
14
|
loadAuthSession,
|
|
16
15
|
sessionStatus,
|
|
17
16
|
} = require('../src/auth-session-store');
|
|
18
|
-
const {
|
|
17
|
+
const { normalizeEmail, loadConsentState, clearConsentState } = require('../src/share');
|
|
19
18
|
const { createStdinAsk } = require('../src/stdin-ask');
|
|
20
19
|
const { chooseAuthMethod } = require('../src/auth-method-picker');
|
|
21
20
|
|
|
@@ -50,23 +49,6 @@ async function chooseLoginMethod({ ask, stdinIsTTY, c, input, output }) {
|
|
|
50
49
|
return chooseAuthMethod({ flow: 'login', ask, stdinIsTTY, catalog: c, input, output });
|
|
51
50
|
}
|
|
52
51
|
|
|
53
|
-
// The SECRET prompt (issue 102) — falls back to the ordinary line read on a pipe, which `ask()` still services.
|
|
54
|
-
async function promptSecret(ask, label) {
|
|
55
|
-
const read = typeof ask.secret === 'function' ? ask.secret.bind(ask) : ask;
|
|
56
|
-
return String(await read(label)).trim();
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async function resolveEmail(opts, c, ask) {
|
|
60
|
-
if (opts.email) return String(opts.email).trim();
|
|
61
|
-
for (let attempt = 0; attempt < 5; attempt++) {
|
|
62
|
-
const raw = String(await ask(c.emailPrompt)).trim();
|
|
63
|
-
if (raw === '') return null; // EOF / empty line / cancel
|
|
64
|
-
if (isValidEmail(raw)) return raw;
|
|
65
|
-
process.stdout.write(` ${c.emailInvalid}\n`);
|
|
66
|
-
}
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
52
|
async function run(
|
|
71
53
|
argv = process.argv.slice(2),
|
|
72
54
|
{ ask: injectedAsk = null, openBrowser = undefined, stdinIsTTY: ttyOverride = undefined, input = undefined, output = undefined, sleep = undefined } = {},
|
|
@@ -75,75 +57,42 @@ async function run(
|
|
|
75
57
|
const lang = opts.lang || detectReportLang();
|
|
76
58
|
const c = getCatalog(lang).login;
|
|
77
59
|
|
|
78
|
-
|
|
79
|
-
|
|
60
|
+
// Every method signs in through the real web device flow (RFC 8628): the
|
|
61
|
+
// Talent reuses an existing web session or signs in on the `/cli-login` page,
|
|
62
|
+
// so no password is ever read in the terminal. Email now travels the same
|
|
63
|
+
// path Google/LinkedIn already use; only the `provider` query differs.
|
|
64
|
+
let method = null;
|
|
65
|
+
if (opts.google) method = 'google';
|
|
66
|
+
else if (opts.linkedin) method = 'linkedin';
|
|
67
|
+
else if (opts.provider === 'email' || !!opts.email) method = 'email';
|
|
80
68
|
|
|
81
|
-
|
|
82
|
-
if (!hasMethodFlag(opts)) {
|
|
69
|
+
if (!method) {
|
|
83
70
|
const stdinIsTTY = ttyOverride !== undefined ? ttyOverride : !!process.stdin.isTTY;
|
|
71
|
+
// ADR-044: an ACTIVE session is not silently overwritten, and the picker never shows.
|
|
72
|
+
if (sessionStatus(loadAuthSession()) === 'active') {
|
|
73
|
+
(output || process.stdout).write(`\n ${c.alreadyLoggedIn}\n\n`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
84
76
|
if (stdinIsTTY) {
|
|
85
|
-
|
|
86
|
-
process.stdout.write(`\n ${c.alreadyLoggedIn}\n\n`);
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
77
|
+
// ADR-041/046 — the picker offers Email, Google and LinkedIn.
|
|
89
78
|
const pickerAsk = injectedAsk || createStdinAsk();
|
|
90
|
-
let method;
|
|
91
79
|
try {
|
|
92
80
|
method = await chooseLoginMethod({ ask: pickerAsk, stdinIsTTY, c, input, output });
|
|
93
81
|
} finally {
|
|
94
82
|
if (!injectedAsk) pickerAsk.close();
|
|
95
83
|
}
|
|
96
84
|
if (method === null) return; // esc/ctrl-c: cancelled, nothing sent
|
|
97
|
-
|
|
98
|
-
|
|
85
|
+
} else {
|
|
86
|
+
method = 'email'; // non-interactive default (matches the picker's own non-TTY default)
|
|
99
87
|
}
|
|
100
88
|
}
|
|
101
89
|
|
|
102
|
-
|
|
103
|
-
if (!endpoint) {
|
|
104
|
-
process.stderr.write(`\n ${c.errorNoEndpoint}\n\n`);
|
|
105
|
-
process.exitCode = 1;
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// ADR-044: an ACTIVE session is not silently overwritten — the Talent is told, and pointed at `logout` to switch accounts.
|
|
110
|
-
const status = sessionStatus(loadAuthSession());
|
|
111
|
-
if (status === 'active') {
|
|
112
|
-
process.stdout.write(`\n ${c.alreadyLoggedIn}\n\n`);
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
process.stdout.write(`\n ${status === 'expired' ? c.sessionExpiredIntro : c.intro}\n`);
|
|
116
|
-
|
|
117
|
-
const ask = injectedAsk || createStdinAsk();
|
|
118
|
-
try {
|
|
119
|
-
const email = await resolveEmail(opts, c, ask);
|
|
120
|
-
if (!email || !isValidEmail(email)) {
|
|
121
|
-
process.stderr.write(`\n ${c.needInput}\n\n`);
|
|
122
|
-
process.exitCode = 1;
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
const password = await promptSecret(ask, c.passwordPrompt);
|
|
126
|
-
if (!password) {
|
|
127
|
-
process.stderr.write(`\n ${c.needInput}\n\n`);
|
|
128
|
-
process.exitCode = 1;
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const normalizedEmail = normalizeEmail(email);
|
|
133
|
-
// better-auth email login: sign-in captures the session cookie, then a hub
|
|
134
|
-
// JWT is minted from it (auth-client.js). `endpoint` is the sign-in URL.
|
|
135
|
-
const result = await requestLogin(
|
|
136
|
-
{ email: normalizedEmail, password },
|
|
137
|
-
{ signInEndpoint: endpoint, tokenEndpoint: getAuthTokenEndpoint() },
|
|
138
|
-
);
|
|
139
|
-
handleResult(result, c, normalizedEmail);
|
|
140
|
-
} finally {
|
|
141
|
-
if (!injectedAsk) ask.close();
|
|
142
|
-
}
|
|
90
|
+
return runDeviceFlow(method, c, { openBrowser, sleep, lang, output });
|
|
143
91
|
}
|
|
144
92
|
|
|
145
93
|
// Shared success path: persist the session (atomic 0600 store) and report.
|
|
146
|
-
|
|
94
|
+
// `output` lets a caller (and tests) redirect the human-facing lines; errors stay on stderr.
|
|
95
|
+
function persistAndReport(result, c, email = null, output = process.stdout) {
|
|
147
96
|
if (email) {
|
|
148
97
|
const prior = loadConsentState();
|
|
149
98
|
if (prior && prior.email && normalizeEmail(prior.email) !== normalizeEmail(email)) {
|
|
@@ -167,37 +116,16 @@ function persistAndReport(result, c, email = null) {
|
|
|
167
116
|
process.exitCode = 1;
|
|
168
117
|
return false;
|
|
169
118
|
}
|
|
170
|
-
|
|
171
|
-
if (result.expiresAt)
|
|
172
|
-
|
|
119
|
+
output.write(`\n ${c.success}\n`);
|
|
120
|
+
if (result.expiresAt) output.write(` ${c.expiresAt(result.expiresAt)}\n`);
|
|
121
|
+
output.write('\n');
|
|
173
122
|
return true;
|
|
174
123
|
}
|
|
175
124
|
|
|
176
|
-
// `
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
// One message per contract reason (ADR-044) — each names the cause (issue 109).
|
|
181
|
-
const messageFor = {
|
|
182
|
-
'invalid-credentials': c.errorInvalidCredentials,
|
|
183
|
-
'no-email-password': c.errorNoEmailPassword,
|
|
184
|
-
upstream: c.errorUpstream,
|
|
185
|
-
// Creds were good but the hub JWT could not be minted from the cookie.
|
|
186
|
-
'token-fetch-failed': c.errorUpstream,
|
|
187
|
-
timeout: c.errorUnreachable,
|
|
188
|
-
'network-error': c.errorUnreachable,
|
|
189
|
-
'invalid-url': c.errorConfig,
|
|
190
|
-
'no-endpoint': c.errorNoEndpoint,
|
|
191
|
-
'bad-response': c.errorGeneric,
|
|
192
|
-
'http-error': c.errorGeneric,
|
|
193
|
-
'local-error': c.errorGeneric,
|
|
194
|
-
};
|
|
195
|
-
process.stderr.write(`\n ${messageFor[result.reason] || c.errorGeneric}\n\n`);
|
|
196
|
-
process.exitCode = 1;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// `login --google` / `--linkedin` — one device flow (RFC 8628); only the `provider` query on the URL differs.
|
|
200
|
-
async function runSocial(provider, c, { openBrowser = openPath, sleep = undefined, lang = 'en' } = {}) {
|
|
125
|
+
// `login` for one method — one device flow (RFC 8628); only the `provider` query
|
|
126
|
+
// on the `/cli-login` URL differs (google | linkedin | email). Email reuses an
|
|
127
|
+
// existing web session or signs in on the page, with no password in the terminal.
|
|
128
|
+
async function runDeviceFlow(provider, c, { openBrowser = openPath, sleep = undefined, lang = 'en', output = process.stdout } = {}) {
|
|
201
129
|
const authorizeEndpoint = getDeviceAuthorizeEndpoint();
|
|
202
130
|
const tokenEndpoint = getDeviceTokenEndpoint();
|
|
203
131
|
const authTokenEndpoint = getAuthTokenEndpoint();
|
|
@@ -206,18 +134,21 @@ async function runSocial(provider, c, { openBrowser = openPath, sleep = undefine
|
|
|
206
134
|
process.exitCode = 1;
|
|
207
135
|
return;
|
|
208
136
|
}
|
|
209
|
-
// A new
|
|
137
|
+
// A new account also needs complete-registration before the JWT (freelanceType is a transient default).
|
|
210
138
|
const completeRegistrationEndpoint = getCompleteRegistrationEndpoint();
|
|
211
139
|
const registrationContext = buildRegistrationContext({ preferredLanguage: lang, newsletterConsent: false, freelanceType: 'POTENTIAL_FREELANCE' });
|
|
212
|
-
const providerName = provider === 'linkedin' ? c.methodLinkedin : c.methodGoogle;
|
|
140
|
+
const providerName = provider === 'linkedin' ? c.methodLinkedin : provider === 'email' ? c.methodEmail : c.methodGoogle;
|
|
213
141
|
|
|
214
|
-
//
|
|
142
|
+
// An ACTIVE session is not silently overwritten.
|
|
215
143
|
const status = sessionStatus(loadAuthSession());
|
|
216
144
|
if (status === 'active') {
|
|
217
|
-
|
|
145
|
+
output.write(`\n ${c.alreadyLoggedIn}\n\n`);
|
|
218
146
|
return;
|
|
219
147
|
}
|
|
220
|
-
|
|
148
|
+
const intro = status === 'expired'
|
|
149
|
+
? c.sessionExpiredIntro
|
|
150
|
+
: (provider === 'email' ? c.emailDeviceIntro : c.deviceIntro(providerName));
|
|
151
|
+
output.write(`\n ${intro}\n`);
|
|
221
152
|
|
|
222
153
|
const result = await runDeviceLogin(
|
|
223
154
|
{ authorizeEndpoint, tokenEndpoint, authTokenEndpoint, completeRegistrationEndpoint, registrationContext },
|
|
@@ -225,21 +156,23 @@ async function runSocial(provider, c, { openBrowser = openPath, sleep = undefine
|
|
|
225
156
|
...(sleep ? { sleep } : {}),
|
|
226
157
|
onPrompt: ({ verificationUriComplete, userCode }) => {
|
|
227
158
|
const url = appendProviderParam(verificationUriComplete, provider);
|
|
228
|
-
|
|
159
|
+
output.write(`\n ${c.deviceVisit}\n ${url}\n\n ${c.deviceCodeLabel(userCode)}\n\n ${c.deviceWaiting}\n`);
|
|
160
|
+
if (provider === 'email') output.write(` ${c.deviceApproveHint}\n`);
|
|
229
161
|
try { openBrowser(url); } catch { /* the printed URL covers it */ }
|
|
230
162
|
},
|
|
231
163
|
},
|
|
232
164
|
);
|
|
233
|
-
|
|
165
|
+
handleDeviceResult(result, c, output);
|
|
234
166
|
}
|
|
235
167
|
|
|
236
|
-
function
|
|
168
|
+
function handleDeviceResult(result, c, output = process.stdout) {
|
|
237
169
|
if (result.ok) {
|
|
238
170
|
// Map the device-flow JWT to the shared persist shape (no cookie).
|
|
239
171
|
return void persistAndReport(
|
|
240
172
|
{ accessToken: result.token, hubAccessToken: result.token, email: result.email || null },
|
|
241
173
|
c,
|
|
242
174
|
result.email || null,
|
|
175
|
+
output,
|
|
243
176
|
);
|
|
244
177
|
}
|
|
245
178
|
|
|
@@ -264,14 +197,14 @@ function handleSocialResult(result, c) {
|
|
|
264
197
|
}
|
|
265
198
|
|
|
266
199
|
// `logout` — local only (no endpoint, no network): forget the stored session.
|
|
267
|
-
async function runLogout(argv = process.argv.slice(2)) {
|
|
200
|
+
async function runLogout(argv = process.argv.slice(2), { output = process.stdout } = {}) {
|
|
268
201
|
const opts = parseArgs(argv);
|
|
269
202
|
const lang = opts.lang || detectReportLang();
|
|
270
203
|
const c = getCatalog(lang).login;
|
|
271
204
|
const had = loadAuthSession() !== null;
|
|
272
205
|
clearAuthSession();
|
|
273
206
|
clearConsentState();
|
|
274
|
-
|
|
207
|
+
output.write(`\n ${had ? c.loggedOut : c.notLoggedIn}\n\n`);
|
|
275
208
|
}
|
|
276
209
|
|
|
277
210
|
module.exports = { run, runLogout, chooseLoginMethod, parseArgs, hasMethodFlag };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shakers-cli-beta",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Shakers AI usage & skills evaluator: a local-first CLI (`shakers` binary) plus a local MCP server. Commands: ai-usage (scan + classify your AI-tool setup, T0-T7), certify (certify Skills from your code), share (branded LinkedIn card).",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
package/src/agents-client.js
CHANGED
|
@@ -95,7 +95,7 @@ async function requestDeclareAgent(
|
|
|
95
95
|
return { ok: true, agentId };
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
//
|
|
98
|
+
// DIRECT to Hub (ADR-041), replace-set.
|
|
99
99
|
async function requestRelateAgentPortfolios(
|
|
100
100
|
{ agentId, items, hubAccessToken } = {},
|
|
101
101
|
{ endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
|
package/src/ai-profile-client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
//
|
|
3
|
+
// The same read model the "My work with AI" front
|
|
4
4
|
// paints: { setup, usage, cell, isAiNative, traction, vision, howIWork }.
|
|
5
5
|
|
|
6
6
|
const { postJsonWithTimeout } = require('./backend-request');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// My-applications client
|
|
3
|
+
// My-applications client. Lists the
|
|
4
4
|
// positions the talent applied to, with the candidature status.
|
|
5
5
|
|
|
6
6
|
const { postJsonWithTimeout } = require('./backend-request');
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
// Availability client. VIEW = GET `works/me/availability`; SET = PUT
|
|
4
|
-
//
|
|
4
|
+
// (the same endpoint register uses).
|
|
5
5
|
|
|
6
6
|
const { postJsonWithTimeout } = require('./backend-request');
|
|
7
7
|
const { reasonForError } = require('./onboarding-client');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// My-certifications client
|
|
3
|
+
// My-certifications client. The
|
|
4
4
|
// "By role" page: main-role cluster + its dimension rows (state/band) + ratios.
|
|
5
5
|
|
|
6
6
|
const { postJsonWithTimeout } = require('./backend-request');
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
const { postJsonWithTimeout } = require('./backend-request');
|
|
6
6
|
const { reasonForError } = require('./onboarding-client');
|
|
7
|
-
const { toUpperLang } = require('./lang-codes');
|
|
8
7
|
|
|
9
8
|
const DEFAULT_TIMEOUT_MS = 20000;
|
|
10
9
|
|
|
@@ -154,87 +153,9 @@ async function discoverOfferableDimensions(deps = {}, { accessToken, hubAccessTo
|
|
|
154
153
|
return { ok: true, mainRole: mine.mainRole, offerable, unmatched };
|
|
155
154
|
}
|
|
156
155
|
|
|
157
|
-
async function requestCreateCertificationInterview(
|
|
158
|
-
{ dimensionKey, languageCode, accessToken } = {},
|
|
159
|
-
{ endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
|
|
160
|
-
) {
|
|
161
|
-
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
162
|
-
if (!dimensionKey) return { ok: false, reason: 'no-dimension' };
|
|
163
|
-
const body = { dimensionKey };
|
|
164
|
-
if (languageCode) body.languageCode = toUpperLang(languageCode);
|
|
165
|
-
let res;
|
|
166
|
-
try {
|
|
167
|
-
res = await postJsonWithTimeout(endpoint, body, timeoutMs, 'POST', null, bearerHeaders(accessToken));
|
|
168
|
-
} catch (e) {
|
|
169
|
-
return { ok: false, reason: (e && e.kind) || 'network-error' };
|
|
170
|
-
}
|
|
171
|
-
if (res.status < 200 || res.status >= 300) {
|
|
172
|
-
const reason = reasonForError(res.status, res.raw);
|
|
173
|
-
if (reason !== 'dimension-on-cooldown') return { ok: false, reason };
|
|
174
|
-
// certs puts the date in the message only: "... available on 2026-10-28T14:50:38.265Z".
|
|
175
|
-
const available = String(res.raw || '').match(/available on (\d{4}-\d{2}-\d{2}T[\d:.]+Z)/);
|
|
176
|
-
return { ok: false, reason, availableOn: available ? available[1] : null };
|
|
177
|
-
}
|
|
178
|
-
const data = payloadOf(res.raw);
|
|
179
|
-
const interviewId = pick(data || {}, ['interviewId', 'id']);
|
|
180
|
-
if (!interviewId) return { ok: false, reason: 'bad-response' };
|
|
181
|
-
return { ok: true, interviewId, language: pick(data || {}, ['language', 'languageCode']) || null };
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// Poll the verdict; a 2xx/404/425/202 with no band yet resolves `ready:false` (retriable).
|
|
185
|
-
async function requestCertificationReport(
|
|
186
|
-
{ interviewId, accessToken } = {},
|
|
187
|
-
{ base, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
|
|
188
|
-
) {
|
|
189
|
-
if (!base) return { ok: false, reason: 'no-endpoint' };
|
|
190
|
-
if (!interviewId) return { ok: false, reason: 'no-interview' };
|
|
191
|
-
const url = `${base.replace(/\/+$/, '')}/${encodeURIComponent(interviewId)}/report`;
|
|
192
|
-
let res;
|
|
193
|
-
try {
|
|
194
|
-
res = await postJsonWithTimeout(url, null, timeoutMs, 'GET', null, bearerHeaders(accessToken));
|
|
195
|
-
} catch (e) {
|
|
196
|
-
return { ok: false, reason: (e && e.kind) || 'network-error' };
|
|
197
|
-
}
|
|
198
|
-
if (res.status === 404 || res.status === 425 || res.status === 202) return { ok: true, ready: false, report: null };
|
|
199
|
-
if (res.status < 200 || res.status >= 300) return { ok: false, reason: reasonForError(res.status, res.raw) };
|
|
200
|
-
const data = payloadOf(res.raw);
|
|
201
|
-
if (data === undefined || data === null || typeof data !== 'object') return { ok: true, ready: false, report: null };
|
|
202
|
-
const band = pick(data, ['band', 'level', 'combinedLevel']);
|
|
203
|
-
// A report with too little evidence for a level has band null; it is still finished.
|
|
204
|
-
if (!band && !pick(data, ['evaluationId'])) return { ok: true, ready: false, report: null };
|
|
205
|
-
return { ok: true, ready: true, report: data };
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// The case the web preview shows before starting (works `get-dimension-case`): the exercise statement, else the
|
|
209
|
-
// version's brief. `case: null` for a template with neither.
|
|
210
|
-
async function requestDimensionCase(
|
|
211
|
-
{ dimensionKey, accessToken } = {},
|
|
212
|
-
{ base, timeoutMs = DEFAULT_TIMEOUT_MS } = {},
|
|
213
|
-
) {
|
|
214
|
-
if (!base) return { ok: false, reason: 'no-endpoint' };
|
|
215
|
-
if (!dimensionKey) return { ok: false, reason: 'no-dimension' };
|
|
216
|
-
const path = String(dimensionKey).split('/').map(encodeURIComponent).join('/');
|
|
217
|
-
const url = `${base.replace(/\/+$/, '')}/dimension/${path}/case`;
|
|
218
|
-
let res;
|
|
219
|
-
try {
|
|
220
|
-
res = await postJsonWithTimeout(url, null, timeoutMs, 'GET', null, bearerHeaders(accessToken));
|
|
221
|
-
} catch (e) {
|
|
222
|
-
return { ok: false, reason: (e && e.kind) || 'network-error' };
|
|
223
|
-
}
|
|
224
|
-
if (res.status < 200 || res.status >= 300) return { ok: false, reason: reasonForError(res.status, res.raw) };
|
|
225
|
-
const data = payloadOf(res.raw);
|
|
226
|
-
if (!data || typeof data !== 'object') return { ok: false, reason: 'bad-response' };
|
|
227
|
-
const text = (value) => (typeof value === 'string' && value.trim() ? value.trim() : null);
|
|
228
|
-
const brief = text(data.statement) || text(data.candidateBrief);
|
|
229
|
-
return { ok: true, case: brief ? { brief, testType: text(data.testType) } : null };
|
|
230
|
-
}
|
|
231
|
-
|
|
232
156
|
module.exports = {
|
|
233
157
|
requestMyCertifications,
|
|
234
158
|
requestTemplatesByDimension,
|
|
235
|
-
requestCreateCertificationInterview,
|
|
236
|
-
requestCertificationReport,
|
|
237
|
-
requestDimensionCase,
|
|
238
159
|
composeDimensionKey,
|
|
239
160
|
composeOfferableDimensions,
|
|
240
161
|
discoverOfferableDimensions,
|
|
@@ -1,28 +1,33 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// `certify` dimension flow: discover -> pick ->
|
|
3
|
+
// `certify` dimension flow: discover -> pick -> hand over the web deep link. The interview runs on the web.
|
|
4
4
|
|
|
5
5
|
const { runInteractiveMultiSelect } = require('./interactive-select');
|
|
6
6
|
|
|
7
7
|
function makeCertifyDimensionDeps(overrides = {}) {
|
|
8
8
|
const config = require('./config');
|
|
9
9
|
const client = require('./certify-dimension-client');
|
|
10
|
-
const onboardingFlow = require('./onboarding-flow');
|
|
11
10
|
return {
|
|
12
|
-
|
|
13
|
-
// `{certsBase}/interviews` — shared base for LiveKit start/complete + the report poll.
|
|
14
|
-
getInterviewsBase: config.getOnboardingInterviewsEndpoint,
|
|
11
|
+
getTalentProfileUrl: config.getTalentProfileUrl,
|
|
15
12
|
discoverOfferableDimensions: (opts) => client.discoverOfferableDimensions({}, opts),
|
|
16
|
-
|
|
17
|
-
requestCertificationReport: client.requestCertificationReport,
|
|
18
|
-
requestDimensionCase: client.requestDimensionCase,
|
|
19
|
-
conductLivekitInterview: onboardingFlow.conductLivekitInterview,
|
|
20
|
-
onboardingDeps: onboardingFlow.makeDeps(),
|
|
21
|
-
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
13
|
+
buildWebLink,
|
|
22
14
|
...overrides,
|
|
23
15
|
};
|
|
24
16
|
}
|
|
25
17
|
|
|
18
|
+
// The web route validates the decoded `dimension` param against `^[^/]+\/[^/]+$`,
|
|
19
|
+
// so the separator slash stays literal and each segment is url-encoded.
|
|
20
|
+
function buildWebLink(profileUrl, { clusterId, slug } = {}) {
|
|
21
|
+
if (!profileUrl || !clusterId || !slug) return null;
|
|
22
|
+
let origin;
|
|
23
|
+
try {
|
|
24
|
+
origin = new URL(profileUrl).origin;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return `${origin}/certifications?dimension=${encodeURIComponent(clusterId)}/${encodeURIComponent(slug)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
26
31
|
// Numbered fallback when `ask` can't release stdin for raw mode.
|
|
27
32
|
async function askNumberedChoice(ask, promptText, count) {
|
|
28
33
|
const raw = (await ask(promptText)).trim();
|
|
@@ -62,53 +67,6 @@ function resolveDimensionArg(arg, items) {
|
|
|
62
67
|
return items.find((d) => d.dimensionKey === raw || d.slug === raw) || undefined;
|
|
63
68
|
}
|
|
64
69
|
|
|
65
|
-
// The evaluation lands one to two minutes after the interview ends, four when the queue is busy.
|
|
66
|
-
async function pollReport(deps, { interviewId, accessToken }, { base, attempts = 60, intervalMs = 5000 } = {}) {
|
|
67
|
-
for (let i = 0; i < attempts; i++) {
|
|
68
|
-
const res = await deps.requestCertificationReport({ interviewId, accessToken }, { base });
|
|
69
|
-
if (!res.ok) return { ok: false, reason: res.reason };
|
|
70
|
-
if (res.ready) return { ok: true, report: res.report };
|
|
71
|
-
if (i < attempts - 1) await deps.sleep(intervalMs);
|
|
72
|
-
}
|
|
73
|
-
return { ok: false, reason: 'timeout' };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function formatDay(iso, lang) {
|
|
77
|
-
const d = iso ? new Date(iso) : null;
|
|
78
|
-
if (!d || Number.isNaN(d.getTime())) return null;
|
|
79
|
-
return d.toLocaleDateString(lang === 'en' ? 'en-GB' : 'es-ES', { day: 'numeric', month: 'long', year: 'numeric' });
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function renderVerdict(report, cd) {
|
|
83
|
-
const lines = [];
|
|
84
|
-
const band = report.band || report.level || report.combinedLevel;
|
|
85
|
-
const dimension = report.dimensionName || report.dimension || report.dimensionKey;
|
|
86
|
-
if (dimension) lines.push(cd.verdictDimension(dimension));
|
|
87
|
-
if (band) lines.push(cd.verdictBand(band));
|
|
88
|
-
if (typeof report.certified === 'boolean') lines.push(report.certified ? cd.verdictCertified : cd.verdictNotCertified);
|
|
89
|
-
const summary = report.summary || report.rationale || report.feedback;
|
|
90
|
-
if (typeof summary === 'string' && summary.trim()) lines.push(summary.trim());
|
|
91
|
-
const areas = Array.isArray(report.areas) ? report.areas : null;
|
|
92
|
-
if (areas && areas.length) {
|
|
93
|
-
for (const a of areas) {
|
|
94
|
-
const name = a && (a.name || a.area || a.tag);
|
|
95
|
-
const note = a && (a.note || a.comment);
|
|
96
|
-
if (name) lines.push(`- ${name}${note ? `: ${note}` : ''}`);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
return lines.join('\n ');
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// The statement is Markdown; the terminal shows it without the emphasis markers.
|
|
103
|
-
function renderCase(dimensionCase, cd) {
|
|
104
|
-
const brief = dimensionCase.brief.replace(/(\*\*|__)(.+?)\1/g, '$2');
|
|
105
|
-
const expectations = cd.caseExpectations[dimensionCase.testType];
|
|
106
|
-
const lines = brief.split('\n');
|
|
107
|
-
if (expectations) lines.push('', cd.caseExpectationsTitle, ...Object.values(expectations).map((e) => `- ${e}`));
|
|
108
|
-
lines.push('', cd.caseBeforeStart);
|
|
109
|
-
return lines.join('\n ');
|
|
110
|
-
}
|
|
111
|
-
|
|
112
70
|
async function runCertifyDimension({ io, ask, stdinIsTTY, session, lang, catalog, opts = {}, deps = makeCertifyDimensionDeps() }) {
|
|
113
71
|
const cd = catalog.certifyDimension;
|
|
114
72
|
const accessToken = session ? session.accessToken : null;
|
|
@@ -157,60 +115,21 @@ async function runCertifyDimension({ io, ask, stdinIsTTY, session, lang, catalog
|
|
|
157
115
|
}
|
|
158
116
|
}
|
|
159
117
|
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
{ endpoint: createEndpoint },
|
|
165
|
-
));
|
|
166
|
-
if (!created.ok) {
|
|
167
|
-
io.error(created.reason === 'dimension-on-cooldown'
|
|
168
|
-
? cd.onCooldown(formatDay(created.availableOn, lang))
|
|
169
|
-
: cd.createFailed(created.reason));
|
|
170
|
-
return { ok: false, reason: created.reason, step: 'create' };
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// A failed read leaves the run as it was before the case existed, like the web preview does.
|
|
174
|
-
const caseRead = await deps.requestDimensionCase(
|
|
175
|
-
{ dimensionKey: chosen.dimensionKey, accessToken },
|
|
176
|
-
{ base: deps.getInterviewsBase() },
|
|
177
|
-
);
|
|
178
|
-
if (caseRead.ok && caseRead.case) {
|
|
179
|
-
io.section(cd.caseTitle);
|
|
180
|
-
io.notify(renderCase(caseRead.case, cd));
|
|
181
|
-
if (stdinIsTTY) await ask(` ${cd.caseReadyPrompt}`);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const interview = await deps.conductLivekitInterview(io, deps.onboardingDeps, {
|
|
185
|
-
interviewId: created.interviewId,
|
|
186
|
-
language: lang,
|
|
187
|
-
plain: true,
|
|
188
|
-
});
|
|
189
|
-
if (!interview.ok) {
|
|
190
|
-
io.warn(interview.reason === 'needs-web' ? cd.needsWeb : cd.interviewUnavailable(interview.reason));
|
|
191
|
-
return { ok: false, reason: interview.reason, step: 'interview' };
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const reportBase = deps.getInterviewsBase();
|
|
195
|
-
const polled = await io.withProgress(cd.reportPolling, () =>
|
|
196
|
-
pollReport(deps, { interviewId: created.interviewId, accessToken }, { base: reportBase }));
|
|
197
|
-
if (!polled.ok) {
|
|
198
|
-
io.warn(polled.reason === 'timeout' ? cd.reportTimeout : cd.reportFailed(polled.reason));
|
|
199
|
-
return { ok: true, completed: 'interview', verdict: null };
|
|
118
|
+
const link = deps.buildWebLink(deps.getTalentProfileUrl(), { clusterId: chosen.clusterId, slug: chosen.slug });
|
|
119
|
+
if (!link) {
|
|
120
|
+
io.warn(cd.webHandoffNoLink);
|
|
121
|
+
return { ok: true, handoff: null, dimension: chosen.dimensionKey };
|
|
200
122
|
}
|
|
201
123
|
|
|
202
|
-
io.
|
|
203
|
-
io.notify(
|
|
204
|
-
|
|
205
|
-
return { ok: true, completed: 'interview', verdict: polled.report };
|
|
124
|
+
io.notify(cd.webHandoff);
|
|
125
|
+
io.notify(cd.webHandoffLink(link));
|
|
126
|
+
return { ok: true, handoff: link, dimension: chosen.dimensionKey };
|
|
206
127
|
}
|
|
207
128
|
|
|
208
129
|
module.exports = {
|
|
209
130
|
makeCertifyDimensionDeps,
|
|
210
131
|
runCertifyDimension,
|
|
132
|
+
buildWebLink,
|
|
211
133
|
chooseDimension,
|
|
212
134
|
resolveDimensionArg,
|
|
213
|
-
pollReport,
|
|
214
|
-
renderVerdict,
|
|
215
|
-
renderCase,
|
|
216
135
|
};
|