shakers-cli-beta 0.10.3 → 0.11.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/shakers.js +25 -6
- package/bin/update-check-worker.js +70 -0
- package/bin/update.js +111 -0
- package/package.json +1 -1
- package/src/certify-dimension-client.js +79 -0
- package/src/certify-dimension-flow.js +104 -20
- package/src/command-audit.js +105 -0
- package/src/config.js +9 -0
- package/src/i18n.js +166 -32
- package/src/mcp-ai-usage-tool.js +13 -32
- package/src/mcp-certify-dimension-tools.js +7 -1
- package/src/mcp-choice.js +41 -19
- package/src/mcp-install.js +2 -1
- package/src/mcp-instructions.js +9 -9
- package/src/mcp-register-tools.js +87 -29
- package/src/mcp-role-tools.js +79 -11
- package/src/mcp-signup-tools.js +187 -59
- package/src/register-context.js +3 -1
- package/src/signup-copy.js +49 -17
- package/src/update-flow.js +223 -0
- package/src/update-notifier.js +113 -0
package/bin/shakers.js
CHANGED
|
@@ -46,7 +46,11 @@ const { run: runRegister } = require('./register');
|
|
|
46
46
|
const { run: runOnboarding } = require('./onboarding');
|
|
47
47
|
const { run: runConfig } = require('./config');
|
|
48
48
|
const { run: runMcp } = require('./mcp');
|
|
49
|
+
const { run: runUpdate } = require('./update');
|
|
49
50
|
const { checkOnboardingCompleted } = require('../src/onboarding-availability');
|
|
51
|
+
const { emitCommandAudit } = require('../src/command-audit');
|
|
52
|
+
const { readPackageInfo } = require('../src/update-flow');
|
|
53
|
+
const { isSuppressed, maybeNotify, triggerBackgroundCheck } = require('../src/update-notifier');
|
|
50
54
|
|
|
51
55
|
let VERSION = '';
|
|
52
56
|
try {
|
|
@@ -95,13 +99,14 @@ const HANDLERS = {
|
|
|
95
99
|
register: runRegister,
|
|
96
100
|
onboarding: runOnboarding,
|
|
97
101
|
config: runConfig,
|
|
102
|
+
update: runUpdate,
|
|
98
103
|
login: runLogin,
|
|
99
104
|
logout: runLogout,
|
|
100
105
|
superadmin: runSuperadmin,
|
|
101
106
|
mcp: runMcp,
|
|
102
107
|
};
|
|
103
108
|
|
|
104
|
-
const SESSION_EXEMPT = new Set(['ai-usage', 'register', 'config', 'login', 'logout', 'superadmin', 'mcp', 'share', 'alma']);
|
|
109
|
+
const SESSION_EXEMPT = new Set(['ai-usage', 'register', 'config', 'login', 'logout', 'superadmin', 'mcp', 'share', 'alma', 'update']);
|
|
105
110
|
|
|
106
111
|
// Command FAMILIES — a navigation layer over the flat commands (which all stay
|
|
107
112
|
// valid as aliases). `shakers <family> <sub>` resolves to the flat handler; every
|
|
@@ -119,7 +124,7 @@ const FAMILIES = {
|
|
|
119
124
|
invitations: 'invitations', applications: 'applications',
|
|
120
125
|
},
|
|
121
126
|
usage: { run: 'ai-usage', report: 'report', share: 'share' },
|
|
122
|
-
account: { login: 'login', logout: 'logout', register: 'register', config: 'config' },
|
|
127
|
+
account: { login: 'login', logout: 'logout', register: 'register', config: 'config', update: 'update' },
|
|
123
128
|
certs: { list: 'certifications', certify: 'certify' },
|
|
124
129
|
};
|
|
125
130
|
|
|
@@ -212,12 +217,13 @@ function usageText(lang, { hideOnboarding = false } = {}) {
|
|
|
212
217
|
+ ` Sesión:\n`
|
|
213
218
|
+ ` login Inicia sesión en tu cuenta de Shakers\n`
|
|
214
219
|
+ ` logout Cierra la sesión\n`
|
|
215
|
-
+ ` config Lee/escribe la configuración (bases de hub/certs)\n
|
|
220
|
+
+ ` config Lee/escribe la configuración (bases de hub/certs)\n`
|
|
221
|
+
+ ` update Actualiza el CLI a la última versión publicada (--check solo informa)\n\n`
|
|
216
222
|
+ ` Familias (agrupan los comandos de arriba; los nombres planos siguen valiendo como alias):\n`
|
|
217
223
|
+ ` add <skill|agent|project|role> · role <add|change>\n`
|
|
218
224
|
+ ` profile <show|rate|languages|socials|experiences|portfolios|availability|certifications>\n`
|
|
219
225
|
+ ` projects <find|show|save|unsave|invitations|applications>\n`
|
|
220
|
-
+ ` usage <run|report|share> · certs <list|certify> · account <login|logout|register|config>\n`
|
|
226
|
+
+ ` usage <run|report|share> · certs <list|certify> · account <login|logout|register|config|update>\n`
|
|
221
227
|
+ ` Ejecuta \`shakers <familia>\` (sin subcomando) para ver sus subcomandos.\n\n`
|
|
222
228
|
+ ` Ejecuta shakers <comando> --help para las opciones de cada comando.\n\n`;
|
|
223
229
|
}
|
|
@@ -255,12 +261,13 @@ function usageText(lang, { hideOnboarding = false } = {}) {
|
|
|
255
261
|
+ ` Session:\n`
|
|
256
262
|
+ ` login Sign in to your Shakers account\n`
|
|
257
263
|
+ ` logout Sign out\n`
|
|
258
|
-
+ ` config Read/write configuration (hub/certs bases)\n
|
|
264
|
+
+ ` config Read/write configuration (hub/certs bases)\n`
|
|
265
|
+
+ ` update Update the CLI to the latest published version (--check only reports)\n\n`
|
|
259
266
|
+ ` Families (group the commands above; the flat names still work as aliases):\n`
|
|
260
267
|
+ ` add <skill|agent|project|role> · role <add|change>\n`
|
|
261
268
|
+ ` profile <show|rate|languages|socials|experiences|portfolios|availability|certifications>\n`
|
|
262
269
|
+ ` projects <find|show|save|unsave|invitations|applications>\n`
|
|
263
|
-
+ ` usage <run|report|share> · certs <list|certify> · account <login|logout|register|config>\n`
|
|
270
|
+
+ ` usage <run|report|share> · certs <list|certify> · account <login|logout|register|config|update>\n`
|
|
264
271
|
+ ` Run \`shakers <family>\` (no subcommand) to list its subcommands.\n\n`
|
|
265
272
|
+ ` Run \`shakers <command> --help\` for a command's options.\n\n`;
|
|
266
273
|
}
|
|
@@ -295,6 +302,18 @@ async function main() {
|
|
|
295
302
|
try { await ensureFreshSession(process.env); } catch { /* never blocks a command */ }
|
|
296
303
|
|
|
297
304
|
const token = argv.find((a) => !a.startsWith('-'));
|
|
305
|
+
|
|
306
|
+
// Single dispatch choke-point (ADR-060): audit EXACTLY once before branching, fire-and-forget (never awaited). MCP tool calls run in-process and never reach here.
|
|
307
|
+
const auditCommand =
|
|
308
|
+
!token || token.toLowerCase() === 'help' ? 'help' : token.toLowerCase();
|
|
309
|
+
emitCommandAudit({ command: auditCommand, argv, env: process.env });
|
|
310
|
+
|
|
311
|
+
// Non-blocking update notice: reads a cached version synchronously, registry check runs in a detached worker; stderr-only, suppressed for --json/non-TTY/CI/update/opt-out.
|
|
312
|
+
if (!isSuppressed({ argv, command: auditCommand, env: process.env })) {
|
|
313
|
+
maybeNotify({ currentVersion: VERSION, lang, env: process.env });
|
|
314
|
+
triggerBackgroundCheck({ name: readPackageInfo().name, env: process.env });
|
|
315
|
+
}
|
|
316
|
+
|
|
298
317
|
if (!token || token.toLowerCase() === 'help') {
|
|
299
318
|
let hideOnboarding = false;
|
|
300
319
|
try {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Detached worker: fetches the latest version from the PUBLIC, unauthenticated npm registry, writes the `{lastCheck, latest}` cache, fails silent.
|
|
5
|
+
|
|
6
|
+
const https = require('https');
|
|
7
|
+
const { writeCache } = require('../src/update-notifier');
|
|
8
|
+
|
|
9
|
+
const FETCH_TIMEOUT_MS = 10000;
|
|
10
|
+
const MAX_BODY_BYTES = 3 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
// Scoped names (`@scope/pkg`) address the registry as `@scope%2fpkg`, `@` literal; install-v1 metadata still carries `dist-tags`.
|
|
13
|
+
function registryUrl(name) {
|
|
14
|
+
const p = name.startsWith('@') ? `@${name.slice(1).replace('/', '%2f')}` : name;
|
|
15
|
+
return `https://registry.npmjs.org/${p}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function fetchLatest(name, timeoutMs) {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
let settled = false;
|
|
21
|
+
const done = (v) => {
|
|
22
|
+
if (!settled) {
|
|
23
|
+
settled = true;
|
|
24
|
+
resolve(v);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
let req;
|
|
28
|
+
try {
|
|
29
|
+
req = https.get(
|
|
30
|
+
registryUrl(name),
|
|
31
|
+
{ headers: { Accept: 'application/vnd.npm.install-v1+json' } },
|
|
32
|
+
(res) => {
|
|
33
|
+
if (res.statusCode !== 200) {
|
|
34
|
+
res.resume();
|
|
35
|
+
return done(null);
|
|
36
|
+
}
|
|
37
|
+
let body = '';
|
|
38
|
+
res.setEncoding('utf8');
|
|
39
|
+
res.on('data', (c) => {
|
|
40
|
+
body += c;
|
|
41
|
+
if (body.length > MAX_BODY_BYTES) req.destroy();
|
|
42
|
+
});
|
|
43
|
+
res.on('end', () => {
|
|
44
|
+
try {
|
|
45
|
+
const json = JSON.parse(body);
|
|
46
|
+
const latest = json && json['dist-tags'] && json['dist-tags'].latest;
|
|
47
|
+
done(typeof latest === 'string' && latest ? latest : null);
|
|
48
|
+
} catch {
|
|
49
|
+
done(null);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
} catch {
|
|
55
|
+
return done(null);
|
|
56
|
+
}
|
|
57
|
+
req.setTimeout(timeoutMs, () => req.destroy());
|
|
58
|
+
req.on('error', () => done(null));
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function main() {
|
|
63
|
+
const name = process.argv[2];
|
|
64
|
+
if (!name) return;
|
|
65
|
+
const latest = await fetchLatest(name, FETCH_TIMEOUT_MS);
|
|
66
|
+
if (!latest) return;
|
|
67
|
+
writeCache({ lastCheck: Date.now(), latest });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
main();
|
package/bin/update.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// `update` — update the CLI to the latest published version (CLI-only, never an MCP tool); `--check` reports without installing. Logic in src/update-flow.js.
|
|
5
|
+
|
|
6
|
+
const { detectReportLang, getCatalog } = require('../src/i18n');
|
|
7
|
+
const updateFlow = require('../src/update-flow');
|
|
8
|
+
|
|
9
|
+
function parseUpdateArgs(argv = []) {
|
|
10
|
+
const o = { help: false, check: false, json: false, lang: null };
|
|
11
|
+
for (let i = 0; i < argv.length; i++) {
|
|
12
|
+
const a = argv[i];
|
|
13
|
+
if (a === '--help' || a === '-h') o.help = true;
|
|
14
|
+
else if (a === '--check' || a === '--dry-run') o.check = true;
|
|
15
|
+
else if (a === '--json') o.json = true;
|
|
16
|
+
else if (a === '--lang') {
|
|
17
|
+
const v = argv[++i];
|
|
18
|
+
o.lang = v === 'es' || v === 'en' ? v : null;
|
|
19
|
+
} else if (a.startsWith('--lang=')) {
|
|
20
|
+
const v = a.slice(7);
|
|
21
|
+
o.lang = v === 'es' || v === 'en' ? v : null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return o;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function run(
|
|
28
|
+
argv = process.argv.slice(2),
|
|
29
|
+
{ out = null, env = process.env, deps = null } = {},
|
|
30
|
+
) {
|
|
31
|
+
const opts = parseUpdateArgs(argv);
|
|
32
|
+
const lang = opts.lang || detectReportLang();
|
|
33
|
+
const t = getCatalog(lang).cli.update;
|
|
34
|
+
const write = out || ((s) => process.stdout.write(s));
|
|
35
|
+
const flow = deps || updateFlow;
|
|
36
|
+
|
|
37
|
+
if (opts.help) {
|
|
38
|
+
write(t.help + '\n');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const info = flow.readPackageInfo();
|
|
43
|
+
const install = flow.detectInstall(env);
|
|
44
|
+
const current = info.version;
|
|
45
|
+
|
|
46
|
+
if (!opts.json) write(` ${t.checking}\n`);
|
|
47
|
+
const latest = flow.fetchLatestVersion(info);
|
|
48
|
+
|
|
49
|
+
if (opts.json) {
|
|
50
|
+
const updateAvailable =
|
|
51
|
+
latest.ok && current ? flow.compareVersions(current, latest.version) < 0 : null;
|
|
52
|
+
write(
|
|
53
|
+
`${JSON.stringify({
|
|
54
|
+
package: info.name,
|
|
55
|
+
current: current || null,
|
|
56
|
+
latest: latest.ok ? latest.version : null,
|
|
57
|
+
install: install.kind,
|
|
58
|
+
updateAvailable,
|
|
59
|
+
reason: latest.ok ? null : latest.reason,
|
|
60
|
+
})}\n`,
|
|
61
|
+
);
|
|
62
|
+
if (!latest.ok) process.exitCode = 1;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (current) write(` ${t.current(current)}\n`);
|
|
67
|
+
|
|
68
|
+
if (!latest.ok) {
|
|
69
|
+
write(` ${t.latestUnknown(latest.reason)}\n`);
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!current || flow.compareVersions(current, latest.version) >= 0) {
|
|
75
|
+
write(` ${t.upToDate(latest.version)}\n`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
write(` ${t.available(current, latest.version)}\n`);
|
|
80
|
+
|
|
81
|
+
// `--check`: report only, never install.
|
|
82
|
+
if (opts.check) {
|
|
83
|
+
write(` ${t.checkHint}\n`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const plan = flow.buildUpdatePlan(install, info, env);
|
|
88
|
+
if (!plan) {
|
|
89
|
+
if (install.kind === 'source') write(` ${t.sourceCheckout}\n`);
|
|
90
|
+
else write(` ${t.unknownInstall(flow.manualCommand(info))}\n`);
|
|
91
|
+
process.exitCode = 1;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
write(` ${t.detected(plan.label)}\n`);
|
|
96
|
+
write(` ${t.updating(info.name, current, latest.version)}\n\n`);
|
|
97
|
+
|
|
98
|
+
const result = flow.runInstall(plan, env);
|
|
99
|
+
if (result.ok) {
|
|
100
|
+
write(`\n ${t.success(latest.version)}\n ${t.reRun}\n`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
write(`\n ${t.failInstall(result.reason)}\n ${t.failHint(flow.manualCommand(info))}\n`);
|
|
104
|
+
process.exitCode = 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { run, parseUpdateArgs };
|
|
108
|
+
|
|
109
|
+
if (require.main === module) {
|
|
110
|
+
run();
|
|
111
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shakers-cli-beta",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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": {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const { postJsonWithTimeout } = require('./backend-request');
|
|
6
6
|
const { reasonForError } = require('./onboarding-client');
|
|
7
|
+
const { toUpperLang } = require('./lang-codes');
|
|
7
8
|
|
|
8
9
|
const DEFAULT_TIMEOUT_MS = 20000;
|
|
9
10
|
|
|
@@ -153,9 +154,87 @@ async function discoverOfferableDimensions(deps = {}, { accessToken, hubAccessTo
|
|
|
153
154
|
return { ok: true, mainRole: mine.mainRole, offerable, unmatched };
|
|
154
155
|
}
|
|
155
156
|
|
|
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
|
+
|
|
156
232
|
module.exports = {
|
|
157
233
|
requestMyCertifications,
|
|
158
234
|
requestTemplatesByDimension,
|
|
235
|
+
requestCreateCertificationInterview,
|
|
236
|
+
requestCertificationReport,
|
|
237
|
+
requestDimensionCase,
|
|
159
238
|
composeDimensionKey,
|
|
160
239
|
composeOfferableDimensions,
|
|
161
240
|
discoverOfferableDimensions,
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// `certify` dimension flow: discover -> pick ->
|
|
3
|
+
// `certify` dimension flow: discover -> pick -> create -> LiveKit interview -> poll verdict.
|
|
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');
|
|
10
11
|
return {
|
|
11
|
-
|
|
12
|
+
getCertificationInterviewsEndpoint: config.getCertificationInterviewsEndpoint,
|
|
13
|
+
// `{certsBase}/interviews` — shared base for LiveKit start/complete + the report poll.
|
|
14
|
+
getInterviewsBase: config.getOnboardingInterviewsEndpoint,
|
|
12
15
|
discoverOfferableDimensions: (opts) => client.discoverOfferableDimensions({}, opts),
|
|
16
|
+
requestCreateCertificationInterview: client.requestCreateCertificationInterview,
|
|
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
22
|
...overrides,
|
|
14
23
|
};
|
|
15
24
|
}
|
|
@@ -53,19 +62,51 @@ function resolveDimensionArg(arg, items) {
|
|
|
53
62
|
return items.find((d) => d.dimensionKey === raw || d.slug === raw) || undefined;
|
|
54
63
|
}
|
|
55
64
|
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
origin = new URL(profileUrl).origin;
|
|
64
|
-
} catch {
|
|
65
|
-
return null;
|
|
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);
|
|
66
72
|
}
|
|
67
|
-
|
|
68
|
-
|
|
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 ');
|
|
69
110
|
}
|
|
70
111
|
|
|
71
112
|
async function runCertifyDimension({ io, ask, stdinIsTTY, session, lang, catalog, opts = {}, deps = makeCertifyDimensionDeps() }) {
|
|
@@ -116,11 +157,52 @@ async function runCertifyDimension({ io, ask, stdinIsTTY, session, lang, catalog
|
|
|
116
157
|
}
|
|
117
158
|
}
|
|
118
159
|
|
|
119
|
-
const
|
|
120
|
-
io.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
160
|
+
const createEndpoint = deps.getCertificationInterviewsEndpoint();
|
|
161
|
+
const created = await io.withProgress(cd.creating, () =>
|
|
162
|
+
deps.requestCreateCertificationInterview(
|
|
163
|
+
{ dimensionKey: chosen.dimensionKey, languageCode: lang, accessToken },
|
|
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 };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
io.section(cd.verdictTitle);
|
|
203
|
+
io.notify(renderVerdict(polled.report, cd));
|
|
204
|
+
io.success(cd.done);
|
|
205
|
+
return { ok: true, completed: 'interview', verdict: polled.report };
|
|
124
206
|
}
|
|
125
207
|
|
|
126
208
|
module.exports = {
|
|
@@ -128,5 +210,7 @@ module.exports = {
|
|
|
128
210
|
runCertifyDimension,
|
|
129
211
|
chooseDimension,
|
|
130
212
|
resolveDimensionArg,
|
|
131
|
-
|
|
213
|
+
pollReport,
|
|
214
|
+
renderVerdict,
|
|
215
|
+
renderCase,
|
|
132
216
|
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { scrubSecrets } = require('./agent-synthesis');
|
|
6
|
+
const { loadAuthSession } = require('./auth-session-store');
|
|
7
|
+
const { getCertsBase } = require('./config');
|
|
8
|
+
const { postJsonWithTimeout } = require('./backend-request');
|
|
9
|
+
|
|
10
|
+
// Fire-and-forget: short timeout, every error swallowed, so a logging failure never blocks or fails a command.
|
|
11
|
+
const AUDIT_TIMEOUT_MS = 1500;
|
|
12
|
+
|
|
13
|
+
// Capped at 200 chars AFTER scrubbing (mirrors MAX_COMMAND_LEN in session-scan.js) so a secret straddling the cut cannot leak.
|
|
14
|
+
const MAX_ARGS_LEN = 200;
|
|
15
|
+
|
|
16
|
+
function cliVersion() {
|
|
17
|
+
try {
|
|
18
|
+
return require(path.join(__dirname, '..', 'package.json')).version || null;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Scrub THEN cap: scrubSecrets can GROW text, so the cap is measured on the scrubbed string that goes on the wire.
|
|
25
|
+
function redactArgs(argv) {
|
|
26
|
+
if (!Array.isArray(argv) || argv.length === 0) return null;
|
|
27
|
+
const scrubbed = scrubSecrets(argv.join(' ')).trim().slice(0, MAX_ARGS_LEN);
|
|
28
|
+
return scrubbed || null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Anonymous runs carry only a best-effort machine hint; authenticated runs identify via the Bearer instead.
|
|
32
|
+
function machineHint() {
|
|
33
|
+
let osUser = null;
|
|
34
|
+
try {
|
|
35
|
+
const info = os.userInfo();
|
|
36
|
+
osUser = info && typeof info.username === 'string' ? info.username : null;
|
|
37
|
+
} catch {
|
|
38
|
+
osUser = null;
|
|
39
|
+
}
|
|
40
|
+
let hostname = null;
|
|
41
|
+
try {
|
|
42
|
+
hostname = os.hostname() || null;
|
|
43
|
+
} catch {
|
|
44
|
+
hostname = null;
|
|
45
|
+
}
|
|
46
|
+
return { osUser, hostname };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Builds the record + headers; returns null when there is no endpoint to post to.
|
|
50
|
+
function buildAudit(command, argv, env) {
|
|
51
|
+
let base;
|
|
52
|
+
try {
|
|
53
|
+
base = getCertsBase(env);
|
|
54
|
+
} catch {
|
|
55
|
+
base = null;
|
|
56
|
+
}
|
|
57
|
+
if (!base) return null;
|
|
58
|
+
|
|
59
|
+
const session = loadAuthSession(env);
|
|
60
|
+
const token =
|
|
61
|
+
session && (session.accessToken || session.hubAccessToken)
|
|
62
|
+
? session.accessToken || session.hubAccessToken
|
|
63
|
+
: null;
|
|
64
|
+
|
|
65
|
+
const body = {
|
|
66
|
+
command: String(command || 'unknown').slice(0, 100),
|
|
67
|
+
args: redactArgs(argv),
|
|
68
|
+
cliVersion: cliVersion(),
|
|
69
|
+
clientTimestamp: new Date().toISOString(),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Machine hint only when anonymous; identity comes from the token otherwise.
|
|
73
|
+
if (!token) {
|
|
74
|
+
const { osUser, hostname } = machineHint();
|
|
75
|
+
body.osUser = osUser;
|
|
76
|
+
body.hostname = hostname;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const headers = token ? { Authorization: `Bearer ${token}` } : null;
|
|
80
|
+
const endpoint = `${base.replace(/\/+$/, '')}/cli/command-log`;
|
|
81
|
+
return { endpoint, body, headers };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Emits ONE fire-and-forget audit record; never throws or rejects. Returns the `.catch`-guarded promise so tests can await it (production does not).
|
|
85
|
+
function emitCommandAudit({ command, argv = [], env = process.env } = {}) {
|
|
86
|
+
try {
|
|
87
|
+
const audit = buildAudit(command, argv, env);
|
|
88
|
+
if (!audit) return Promise.resolve();
|
|
89
|
+
return postJsonWithTimeout(
|
|
90
|
+
audit.endpoint,
|
|
91
|
+
audit.body,
|
|
92
|
+
AUDIT_TIMEOUT_MS,
|
|
93
|
+
'POST',
|
|
94
|
+
null,
|
|
95
|
+
audit.headers,
|
|
96
|
+
).then(
|
|
97
|
+
() => {},
|
|
98
|
+
() => {},
|
|
99
|
+
);
|
|
100
|
+
} catch {
|
|
101
|
+
return Promise.resolve();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { emitCommandAudit, redactArgs, buildAudit };
|
package/src/config.js
CHANGED
|
@@ -318,6 +318,14 @@ function getTemplatesByDimensionEndpoint(env = process.env) {
|
|
|
318
318
|
return base ? `${base}/templates/by-dimension` : null;
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
+
// The Talent's assessments with their retry availability (certs, talent Bearer).
|
|
322
|
+
function getMyAssessmentsEndpoint(env = process.env) {
|
|
323
|
+
const explicit = readEnv(env, 'MY_ASSESSMENTS_ENDPOINT');
|
|
324
|
+
if (explicit && explicit.trim()) return explicit.trim();
|
|
325
|
+
const base = getCertsBase(env);
|
|
326
|
+
return base ? `${base}/users/me/assessments` : null;
|
|
327
|
+
}
|
|
328
|
+
|
|
321
329
|
// Find Positions list (hub `works/positions/find`, talent Bearer). Serves the
|
|
322
330
|
// All/Recommended/Saved tabs via `criteria.savedByMe`; ordered by match desc.
|
|
323
331
|
function getFindPositionsEndpoint(env = process.env) {
|
|
@@ -749,6 +757,7 @@ module.exports = {
|
|
|
749
757
|
getSetMainRoleEndpoint,
|
|
750
758
|
getAssignedClustersEndpoint,
|
|
751
759
|
getTemplatesByDimensionEndpoint,
|
|
760
|
+
getMyAssessmentsEndpoint,
|
|
752
761
|
// Find Positions list (`find-projects` command / `find_projects` MCP tool).
|
|
753
762
|
getFindPositionsEndpoint,
|
|
754
763
|
// Marketplace (show-project / save-project / unsave-project / invitations).
|