shakers-cli 0.0.0-stage → 0.14.1
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/LICENSE +59 -0
- package/README.md +1233 -2
- package/bin/add-agent.js +88 -0
- package/bin/add-project.js +88 -0
- package/bin/add-role.js +61 -0
- package/bin/add-skill.js +88 -0
- package/bin/ai-usage.js +99 -0
- package/bin/alma.js +99 -0
- package/bin/applications.js +69 -0
- package/bin/availability.js +70 -0
- package/bin/certifications.js +69 -0
- package/bin/certify.js +73 -0
- package/bin/change-role.js +61 -0
- package/bin/config.js +124 -0
- package/bin/experiences.js +69 -0
- package/bin/find-projects.js +89 -0
- package/bin/invitations.js +69 -0
- package/bin/lang.js +70 -0
- package/bin/login.js +216 -0
- package/bin/mcp.js +124 -0
- package/bin/mcpb.js +5 -0
- package/bin/me.js +69 -0
- package/bin/onboarding.js +134 -0
- package/bin/portfolios.js +69 -0
- package/bin/rate.js +70 -0
- package/bin/register.js +337 -0
- package/bin/report-html.js +134 -0
- package/bin/save.js +77 -0
- package/bin/shakers.js +363 -0
- package/bin/share.js +72 -0
- package/bin/show-project.js +72 -0
- package/bin/socials.js +70 -0
- package/bin/superadmin.js +351 -0
- package/bin/update-check-worker.js +70 -0
- package/bin/update.js +111 -0
- package/package.json +40 -4
- package/src/agent-category.js +13 -0
- package/src/agent-evaluation.js +177 -0
- package/src/agent-org-chart.js +355 -0
- package/src/agent-synthesis.js +132 -0
- package/src/agent-usage.js +118 -0
- package/src/agents-client.js +165 -0
- package/src/ai-fluency-cell.js +72 -0
- package/src/ai-profile-client.js +45 -0
- package/src/ai-profile-preview.js +154 -0
- package/src/ai.js +66 -0
- package/src/alma-client.js +123 -0
- package/src/alma-flow.js +32 -0
- package/src/alma-repo-context.js +47 -0
- package/src/alma-shell.js +161 -0
- package/src/ansi.js +62 -0
- package/src/applications-client.js +79 -0
- package/src/applications-flow.js +58 -0
- package/src/auth-client.js +144 -0
- package/src/auth-device-client.js +118 -0
- package/src/auth-method-picker.js +44 -0
- package/src/auth-methods.js +35 -0
- package/src/auth-session-store.js +215 -0
- package/src/authorship-mismatch.js +62 -0
- package/src/authorship.js +204 -0
- package/src/automations-detector.js +170 -0
- package/src/availability-client.js +93 -0
- package/src/availability-flow.js +124 -0
- package/src/backend-request.js +191 -0
- package/src/blind-approval-extract.js +48 -0
- package/src/brand-ansi.js +14 -0
- package/src/brand-svg.js +9 -0
- package/src/browser-tools-detector.js +21 -0
- package/src/build-next-level.js +56 -0
- package/src/certifications-client.js +89 -0
- package/src/certifications-flow.js +75 -0
- package/src/certify-dimension-client.js +163 -0
- package/src/certify-dimension-flow.js +135 -0
- package/src/certify-disclaimer.js +51 -0
- package/src/certify-remediation-prompt.js +23 -0
- package/src/cli-args.js +83 -0
- package/src/command-audit.js +105 -0
- package/src/config-dir.js +120 -0
- package/src/config.js +877 -0
- package/src/consent-flow.js +115 -0
- package/src/consent-skip.js +27 -0
- package/src/cv-file.js +62 -0
- package/src/cv-text.js +92 -0
- package/src/decisions-extract.js +43 -0
- package/src/detect-repos.js +145 -0
- package/src/detected-tools.js +15 -0
- package/src/detection-evidence.js +105 -0
- package/src/detection-noise.js +132 -0
- package/src/detectors.js +221 -0
- package/src/device-auth-store.js +100 -0
- package/src/device-login.js +120 -0
- package/src/email-verification.js +152 -0
- package/src/env-paths.js +30 -0
- package/src/find-projects-cache.js +89 -0
- package/src/find-projects-client.js +148 -0
- package/src/find-projects-flow.js +164 -0
- package/src/git-activity.js +124 -0
- package/src/git-log-prefetch.js +53 -0
- package/src/graph-certs.js +58 -0
- package/src/graph-generator.js +230 -0
- package/src/graph-scan.js +239 -0
- package/src/i18n.js +3341 -0
- package/src/inter-fonts.js +6 -0
- package/src/interactive-select.js +183 -0
- package/src/interview-client.js +226 -0
- package/src/interview-transcript.js +56 -0
- package/src/inventory-client.js +90 -0
- package/src/invitations-client.js +66 -0
- package/src/invitations-flow.js +50 -0
- package/src/lang-client.js +147 -0
- package/src/lang-codes.js +14 -0
- package/src/lang-flow.js +85 -0
- package/src/legal-notice.js +53 -0
- package/src/locale.js +72 -0
- package/src/markdown-ansi.js +50 -0
- package/src/maturity.js +111 -0
- package/src/mcp-ai-usage-result-tool.js +111 -0
- package/src/mcp-ai-usage-tool.js +401 -0
- package/src/mcp-alma-tools.js +95 -0
- package/src/mcp-applications-tools.js +44 -0
- package/src/mcp-auth-form.js +480 -0
- package/src/mcp-availability-tools.js +45 -0
- package/src/mcp-certifications-tools.js +44 -0
- package/src/mcp-certify-dimension-tools.js +66 -0
- package/src/mcp-choice.js +95 -0
- package/src/mcp-detector.js +223 -0
- package/src/mcp-device-login-tools.js +177 -0
- package/src/mcp-find-projects-tools.js +69 -0
- package/src/mcp-install.js +275 -0
- package/src/mcp-instructions.js +27 -0
- package/src/mcp-invitations-tools.js +44 -0
- package/src/mcp-lang-tools.js +41 -0
- package/src/mcp-onboarding-tools.js +379 -0
- package/src/mcp-portfolios-tools.js +50 -0
- package/src/mcp-profile-tools.js +48 -0
- package/src/mcp-prompts.js +73 -0
- package/src/mcp-rate-tools.js +44 -0
- package/src/mcp-register-tools.js +441 -0
- package/src/mcp-report-tools.js +105 -0
- package/src/mcp-role-tools.js +372 -0
- package/src/mcp-saved-positions-tools.js +59 -0
- package/src/mcp-server.js +290 -0
- package/src/mcp-show-project-tools.js +52 -0
- package/src/mcp-signup-client.js +118 -0
- package/src/mcp-signup-tools.js +1171 -0
- package/src/mcp-socials-tools.js +41 -0
- package/src/memory-structure-detector.js +108 -0
- package/src/model-call-record.js +198 -0
- package/src/onboarding-availability.js +26 -0
- package/src/onboarding-client.js +374 -0
- package/src/onboarding-flow.js +1162 -0
- package/src/onboarding-funnel.js +272 -0
- package/src/onboarding-livekit-client.js +102 -0
- package/src/onboarding-status-client.js +57 -0
- package/src/open-file.js +32 -0
- package/src/osc-link.js +16 -0
- package/src/portfolio-client.js +164 -0
- package/src/portfolio-git-info.js +61 -0
- package/src/portfolios-client.js +85 -0
- package/src/portfolios-flow.js +79 -0
- package/src/profile-client.js +133 -0
- package/src/profile-flow.js +115 -0
- package/src/profile-switch.js +46 -0
- package/src/prompt-select.js +66 -0
- package/src/rate-client.js +81 -0
- package/src/rate-flow.js +137 -0
- package/src/register-context.js +145 -0
- package/src/render-ai-fluency.js +266 -0
- package/src/render-certification.js +177 -0
- package/src/render-html.js +140 -0
- package/src/render-report-text.js +27 -0
- package/src/render-sheet.js +261 -0
- package/src/render-terminal.js +109 -0
- package/src/render-usage-report-text.js +155 -0
- package/src/repo-context.js +215 -0
- package/src/report-gating.js +49 -0
- package/src/report-store.js +226 -0
- package/src/report-theme.js +177 -0
- package/src/report-view-client.js +50 -0
- package/src/reveal-output.js +29 -0
- package/src/roadmap-content.js +453 -0
- package/src/roadmap-personalization.js +132 -0
- package/src/roadmap-prompt.js +154 -0
- package/src/roles-client.js +112 -0
- package/src/roles-flow.js +192 -0
- package/src/sanitize-network-text.js +36 -0
- package/src/saved-positions-client.js +71 -0
- package/src/saved-positions-flow.js +50 -0
- package/src/scan-exclusions.js +50 -0
- package/src/scanner.js +624 -0
- package/src/scope-signals.js +148 -0
- package/src/secret-prompt.js +72 -0
- package/src/session-gate.js +30 -0
- package/src/session-normalizers.js +174 -0
- package/src/session-refresh.js +51 -0
- package/src/session-scan.js +475 -0
- package/src/session-signals.js +36 -0
- package/src/shakers-wordmark.js +17 -0
- package/src/share-card.js +362 -0
- package/src/share-certification.js +111 -0
- package/src/share-consent-state.js +157 -0
- package/src/share-payload.js +300 -0
- package/src/share.js +185 -0
- package/src/show-project-client.js +58 -0
- package/src/show-project-flow.js +119 -0
- package/src/signup-client.js +103 -0
- package/src/signup-copy.js +450 -0
- package/src/signup-language.js +21 -0
- package/src/skills-client.js +102 -0
- package/src/socials-client.js +91 -0
- package/src/socials-flow.js +83 -0
- package/src/specificity-extract.js +41 -0
- package/src/start-add-agents.js +306 -0
- package/src/start-add-portfolio.js +388 -0
- package/src/start-add-skills.js +260 -0
- package/src/stdin-ask.js +129 -0
- package/src/steering-extract.js +43 -0
- package/src/strip-internal-comments.js +45 -0
- package/src/tech-detector.js +295 -0
- package/src/templates/report-sheet.html +572 -0
- package/src/terminal-agents.js +207 -0
- package/src/terminal-format.js +85 -0
- package/src/terminal-progress.js +106 -0
- package/src/terminal-sections.js +219 -0
- package/src/tier-analysis.js +126 -0
- package/src/tier-engine.js +114 -0
- package/src/tier-projection.js +117 -0
- package/src/transcript-turns.js +49 -0
- package/src/translations-client.js +84 -0
- package/src/typewriter.js +154 -0
- package/src/update-flow.js +223 -0
- package/src/update-notifier.js +113 -0
- package/src/usage-model-calls.js +119 -0
- package/src/usage-oneshot-commands.js +180 -0
- package/src/usage-repo-scope.js +162 -0
- package/src/usage-run-steps.js +354 -0
- package/src/work-streams.js +79 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Dimension-certification discovery + lifecycle client (hub + certs endpoints).
|
|
4
|
+
|
|
5
|
+
const { postJsonWithTimeout } = require('./backend-request');
|
|
6
|
+
const { reasonForError } = require('./onboarding-client');
|
|
7
|
+
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 20000;
|
|
9
|
+
|
|
10
|
+
function parseJson(raw) {
|
|
11
|
+
try {
|
|
12
|
+
return raw ? JSON.parse(raw) : null;
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function payloadOf(raw) {
|
|
19
|
+
const json = parseJson(raw);
|
|
20
|
+
if (!json || typeof json !== 'object') return undefined;
|
|
21
|
+
if (json.status === 'OK') return json.data;
|
|
22
|
+
return json;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function bearerHeaders(accessToken) {
|
|
26
|
+
const headers = {};
|
|
27
|
+
if (typeof accessToken === 'string' && accessToken) headers.Authorization = `Bearer ${accessToken}`;
|
|
28
|
+
return headers;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function pick(obj, keys) {
|
|
32
|
+
for (const k of keys) {
|
|
33
|
+
const v = obj && obj[k];
|
|
34
|
+
if (v !== undefined && v !== null && v !== '') return v;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// FLAG: hub gives `clusterId`, certs keys templates by `clusterRef`; assumed equal here.
|
|
40
|
+
function composeDimensionKey(clusterRef, slug) {
|
|
41
|
+
if (!clusterRef || !slug) return null;
|
|
42
|
+
return `${clusterRef}/${slug}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function requestMyCertifications({ hubAccessToken } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
46
|
+
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
47
|
+
if (!hubAccessToken) return { ok: false, reason: 'no-hub-token' };
|
|
48
|
+
let res;
|
|
49
|
+
try {
|
|
50
|
+
res = await postJsonWithTimeout(endpoint, null, timeoutMs, 'GET', null, bearerHeaders(hubAccessToken));
|
|
51
|
+
} catch (e) {
|
|
52
|
+
return { ok: false, reason: (e && e.kind) || 'network-error' };
|
|
53
|
+
}
|
|
54
|
+
if (res.status < 200 || res.status >= 300) return { ok: false, reason: reasonForError(res.status, res.raw) };
|
|
55
|
+
|
|
56
|
+
const data = payloadOf(res.raw);
|
|
57
|
+
if (data === undefined || !data || typeof data !== 'object') return { ok: false, reason: 'bad-response' };
|
|
58
|
+
const rawDims = Array.isArray(data) ? data
|
|
59
|
+
: (Array.isArray(data.dimensions) ? data.dimensions
|
|
60
|
+
: (Array.isArray(data.items) ? data.items : []));
|
|
61
|
+
const dimensions = rawDims
|
|
62
|
+
.map((d) => ({
|
|
63
|
+
slug: pick(d, ['slug', 'dimensionSlug', 'key']),
|
|
64
|
+
clusterId: pick(d, ['clusterRef', 'clusterId', 'clusterKey', 'cluster']),
|
|
65
|
+
state: pick(d, ['state', 'status']),
|
|
66
|
+
band: pick(d, ['band', 'level']) || null,
|
|
67
|
+
}))
|
|
68
|
+
.filter((d) => d.slug && d.clusterId);
|
|
69
|
+
return { ok: true, mainRole: pick(data, ['mainRole', 'role']) || null, dimensions };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// @Public: no auth header. Returns a Map dimensionKey -> deliveryFormat.
|
|
73
|
+
async function requestTemplatesByDimension({ dimensionKeys } = {}, { endpoint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
74
|
+
if (!endpoint) return { ok: false, reason: 'no-endpoint' };
|
|
75
|
+
const keys = (Array.isArray(dimensionKeys) ? dimensionKeys : []).filter(Boolean);
|
|
76
|
+
if (keys.length === 0) return { ok: true, formats: new Map() };
|
|
77
|
+
|
|
78
|
+
const url = `${endpoint}${endpoint.includes('?') ? '&' : '?'}dimensionKeys=${keys.map(encodeURIComponent).join(',')}`;
|
|
79
|
+
let res;
|
|
80
|
+
try {
|
|
81
|
+
res = await postJsonWithTimeout(url, null, timeoutMs, 'GET', null, {});
|
|
82
|
+
} catch (e) {
|
|
83
|
+
return { ok: false, reason: (e && e.kind) || 'network-error' };
|
|
84
|
+
}
|
|
85
|
+
if (res.status < 200 || res.status >= 300) return { ok: false, reason: reasonForError(res.status, res.raw) };
|
|
86
|
+
|
|
87
|
+
const data = payloadOf(res.raw);
|
|
88
|
+
if (data === undefined || data === null) return { ok: false, reason: 'bad-response' };
|
|
89
|
+
|
|
90
|
+
// The real certs route returns rows under `data.items`; templates/bare-array are tolerant fallbacks.
|
|
91
|
+
const formats = new Map();
|
|
92
|
+
const rows = Array.isArray(data)
|
|
93
|
+
? data
|
|
94
|
+
: (Array.isArray(data.items) ? data.items
|
|
95
|
+
: (Array.isArray(data.templates) ? data.templates : null));
|
|
96
|
+
if (rows) {
|
|
97
|
+
for (const t of rows) {
|
|
98
|
+
const key = pick(t, ['dimensionKey', 'key', 'dimension']);
|
|
99
|
+
const fmt = pick(t, ['deliveryFormat', 'delivery_format', 'format']);
|
|
100
|
+
if (key && fmt) formats.set(String(key), String(fmt));
|
|
101
|
+
}
|
|
102
|
+
} else if (typeof data === 'object') {
|
|
103
|
+
for (const [key, value] of Object.entries(data)) {
|
|
104
|
+
const fmt = value && typeof value === 'object' ? pick(value, ['deliveryFormat', 'delivery_format', 'format']) : value;
|
|
105
|
+
if (fmt) formats.set(String(key), String(fmt));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { ok: true, formats };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// What the terminal can run: the templates marked for it, and the spoken ones, which are
|
|
112
|
+
// conversation only. Whiteboard and screen share need the web.
|
|
113
|
+
const TEXT_DELIVERY_FORMATS = new Set(['terminal', 'spoken_case']);
|
|
114
|
+
|
|
115
|
+
function composeOfferableDimensions({ dimensions, formats } = {}) {
|
|
116
|
+
const fmt = formats instanceof Map ? formats : new Map(Object.entries(formats || {}));
|
|
117
|
+
const candidates = (Array.isArray(dimensions) ? dimensions : [])
|
|
118
|
+
.filter((d) => d && d.state !== 'CERTIFIED')
|
|
119
|
+
.map((d) => ({ ...d, dimensionKey: composeDimensionKey(d.clusterId, d.slug) }))
|
|
120
|
+
.filter((d) => d.dimensionKey);
|
|
121
|
+
const offerable = candidates.filter((d) => TEXT_DELIVERY_FORMATS.has(fmt.get(d.dimensionKey)));
|
|
122
|
+
const unmatched = candidates
|
|
123
|
+
.filter((d) => !fmt.has(d.dimensionKey))
|
|
124
|
+
.map((d) => d.dimensionKey);
|
|
125
|
+
return { offerable, unmatched };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function discoverOfferableDimensions(deps = {}, { accessToken, hubAccessToken, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
129
|
+
const {
|
|
130
|
+
getMyCertificationsEndpoint = require('./config').getMyCertificationsEndpoint,
|
|
131
|
+
getTemplatesByDimensionEndpoint = require('./config').getTemplatesByDimensionEndpoint,
|
|
132
|
+
requestMyCertifications: myCerts = requestMyCertifications,
|
|
133
|
+
requestTemplatesByDimension: templates = requestTemplatesByDimension,
|
|
134
|
+
} = deps;
|
|
135
|
+
|
|
136
|
+
const mineEndpoint = getMyCertificationsEndpoint();
|
|
137
|
+
if (!mineEndpoint) return { ok: false, reason: 'no-endpoint' };
|
|
138
|
+
const mine = await myCerts({ hubAccessToken }, { endpoint: mineEndpoint, timeoutMs });
|
|
139
|
+
if (!mine.ok) return { ok: false, reason: mine.reason };
|
|
140
|
+
|
|
141
|
+
const candidateDims = mine.dimensions.filter((d) => d.state !== 'CERTIFIED');
|
|
142
|
+
if (candidateDims.length === 0) return { ok: true, mainRole: mine.mainRole, offerable: [], unmatched: [] };
|
|
143
|
+
|
|
144
|
+
const templatesEndpoint = getTemplatesByDimensionEndpoint();
|
|
145
|
+
if (!templatesEndpoint) return { ok: false, reason: 'no-endpoint' };
|
|
146
|
+
const keys = candidateDims
|
|
147
|
+
.map((d) => composeDimensionKey(d.clusterId, d.slug))
|
|
148
|
+
.filter(Boolean);
|
|
149
|
+
const tmpl = await templates({ dimensionKeys: keys }, { endpoint: templatesEndpoint, timeoutMs });
|
|
150
|
+
if (!tmpl.ok) return { ok: false, reason: tmpl.reason };
|
|
151
|
+
|
|
152
|
+
const { offerable, unmatched } = composeOfferableDimensions({ dimensions: candidateDims, formats: tmpl.formats });
|
|
153
|
+
return { ok: true, mainRole: mine.mainRole, offerable, unmatched };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
requestMyCertifications,
|
|
158
|
+
requestTemplatesByDimension,
|
|
159
|
+
composeDimensionKey,
|
|
160
|
+
composeOfferableDimensions,
|
|
161
|
+
discoverOfferableDimensions,
|
|
162
|
+
TEXT_DELIVERY_FORMATS,
|
|
163
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// `certify` dimension flow: discover -> pick -> hand over the web deep link. The interview runs on the web.
|
|
4
|
+
|
|
5
|
+
const { runInteractiveMultiSelect } = require('./interactive-select');
|
|
6
|
+
|
|
7
|
+
function makeCertifyDimensionDeps(overrides = {}) {
|
|
8
|
+
const config = require('./config');
|
|
9
|
+
const client = require('./certify-dimension-client');
|
|
10
|
+
return {
|
|
11
|
+
getTalentProfileUrl: config.getTalentProfileUrl,
|
|
12
|
+
discoverOfferableDimensions: (opts) => client.discoverOfferableDimensions({}, opts),
|
|
13
|
+
buildWebLink,
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
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
|
+
|
|
31
|
+
// Numbered fallback when `ask` can't release stdin for raw mode.
|
|
32
|
+
async function askNumberedChoice(ask, promptText, count) {
|
|
33
|
+
const raw = (await ask(promptText)).trim();
|
|
34
|
+
if (!raw) return null;
|
|
35
|
+
const n = Number.parseInt(raw, 10);
|
|
36
|
+
if (!Number.isInteger(n) || n < 1 || n > count) return null;
|
|
37
|
+
return n - 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function chooseDimension(ask, stdinIsTTY, items, cd, out, { input, output } = {}) {
|
|
41
|
+
const labelFor = (d) => `${d.slug}${d.state === 'EXPIRED' ? ` (${cd.stateExpired})` : ''}`;
|
|
42
|
+
if (stdinIsTTY && typeof ask.suspend === 'function') {
|
|
43
|
+
ask.suspend();
|
|
44
|
+
const picked = await runInteractiveMultiSelect({
|
|
45
|
+
items,
|
|
46
|
+
labelFor,
|
|
47
|
+
header: cd.selectHeading,
|
|
48
|
+
hint: cd.selectHint,
|
|
49
|
+
single: true,
|
|
50
|
+
...(input ? { input } : {}),
|
|
51
|
+
...(output ? { output } : {}),
|
|
52
|
+
});
|
|
53
|
+
ask.resume();
|
|
54
|
+
return picked && picked.length ? picked[0] : null;
|
|
55
|
+
}
|
|
56
|
+
out(`\n ${cd.selectHeading}\n`);
|
|
57
|
+
items.forEach((d, i) => out(` ${i + 1}) ${labelFor(d)}\n`));
|
|
58
|
+
const idx = await askNumberedChoice(ask, ` ${cd.selectPrompt(items.length)}`, items.length);
|
|
59
|
+
return idx === null ? null : items[idx];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveDimensionArg(arg, items) {
|
|
63
|
+
if (arg == null) return null;
|
|
64
|
+
const raw = String(arg).trim();
|
|
65
|
+
const n = Number.parseInt(raw, 10);
|
|
66
|
+
if (Number.isInteger(n) && String(n) === raw && n >= 1 && n <= items.length) return items[n - 1];
|
|
67
|
+
return items.find((d) => d.dimensionKey === raw || d.slug === raw) || undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function runCertifyDimension({ io, ask, stdinIsTTY, session, lang, catalog, opts = {}, deps = makeCertifyDimensionDeps() }) {
|
|
71
|
+
const cd = catalog.certifyDimension;
|
|
72
|
+
const accessToken = session ? session.accessToken : null;
|
|
73
|
+
const hubAccessToken = session ? session.hubAccessToken : null;
|
|
74
|
+
|
|
75
|
+
io.section(cd.title);
|
|
76
|
+
|
|
77
|
+
const discovered = await io.withProgress(cd.discovering, () =>
|
|
78
|
+
deps.discoverOfferableDimensions({ accessToken, hubAccessToken }));
|
|
79
|
+
if (!discovered.ok) {
|
|
80
|
+
io.error(cd.discoverFailed(discovered.reason));
|
|
81
|
+
return { ok: false, reason: discovered.reason, step: 'discover' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (Array.isArray(discovered.unmatched) && discovered.unmatched.length > 0) {
|
|
85
|
+
io.warn(cd.unmatchedNote(discovered.unmatched.join(', ')));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const offerable = Array.isArray(discovered.offerable) ? discovered.offerable : [];
|
|
89
|
+
if (offerable.length === 0) {
|
|
90
|
+
io.notify(cd.noneOfferable);
|
|
91
|
+
return { ok: true, none: true };
|
|
92
|
+
}
|
|
93
|
+
const mainRole = discovered.mainRole;
|
|
94
|
+
// The hub sends the main role as { clusterId, name, ... }.
|
|
95
|
+
const mainRoleName = mainRole && typeof mainRole === 'object' ? mainRole.name || mainRole.clusterId : mainRole;
|
|
96
|
+
if (mainRoleName) io.notify(cd.mainRole(mainRoleName));
|
|
97
|
+
|
|
98
|
+
let chosen;
|
|
99
|
+
if (opts.dimension != null) {
|
|
100
|
+
const resolved = resolveDimensionArg(opts.dimension, offerable);
|
|
101
|
+
if (!resolved) {
|
|
102
|
+
io.error(cd.dimensionInvalid);
|
|
103
|
+
return { ok: false, reason: 'bad-dimension', step: 'select' };
|
|
104
|
+
}
|
|
105
|
+
chosen = resolved;
|
|
106
|
+
io.notify(cd.dimensionUsing(chosen.slug));
|
|
107
|
+
} else if (!stdinIsTTY) {
|
|
108
|
+
io.error(cd.selectNonInteractive);
|
|
109
|
+
return { ok: false, reason: 'non-interactive', step: 'select' };
|
|
110
|
+
} else {
|
|
111
|
+
chosen = await chooseDimension(ask, stdinIsTTY, offerable, cd, (s) => io.notify(s.replace(/\n$/, '')), opts);
|
|
112
|
+
if (!chosen) {
|
|
113
|
+
io.notify(cd.selectNoneChosen);
|
|
114
|
+
return { ok: true, cancelled: true };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
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 };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
io.notify(cd.webHandoff);
|
|
125
|
+
io.notify(cd.webHandoffLink(link));
|
|
126
|
+
return { ok: true, handoff: link, dimension: chosen.dimensionKey };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = {
|
|
130
|
+
makeCertifyDimensionDeps,
|
|
131
|
+
runCertifyDimension,
|
|
132
|
+
buildWebLink,
|
|
133
|
+
chooseDimension,
|
|
134
|
+
resolveDimensionArg,
|
|
135
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Legal disclaimer + EXPLICIT acceptance gate (ADR-001), shown before egress. Skill-code certify was retired (certify is now a dimension LiveKit interview); consumers today are the add-skill/add-agent/add-project consent gates.
|
|
4
|
+
|
|
5
|
+
const { renderLegalNotice } = require('./legal-notice');
|
|
6
|
+
const { styleQuestion } = require('./ansi');
|
|
7
|
+
|
|
8
|
+
const MAX_ATTEMPTS = 5;
|
|
9
|
+
const YES_RE = /^(y|yes|s|si|sí)$/i;
|
|
10
|
+
const NO_RE = /^(n|no)$/i;
|
|
11
|
+
|
|
12
|
+
function isAffirmative(raw) {
|
|
13
|
+
return YES_RE.test(String(raw).trim());
|
|
14
|
+
}
|
|
15
|
+
function isNegative(raw) {
|
|
16
|
+
return NO_RE.test(String(raw).trim());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function confirmDisclaimerAcceptance({ ask, catalog, preAccepted = false, stdinIsTTY = true, text = null, drainBeforeAsk = false }) {
|
|
20
|
+
const d = catalog.certify;
|
|
21
|
+
const shown = text || d.disclaimer;
|
|
22
|
+
|
|
23
|
+
// The disclaimer TEXT is always shown, even when pre-accepting via flag — the talent (or the script author who chose the flag) still sees what they're accepting.
|
|
24
|
+
process.stdout.write(`\n ${renderLegalNotice(shown, catalog)}\n`);
|
|
25
|
+
|
|
26
|
+
if (preAccepted) {
|
|
27
|
+
process.stdout.write(` ${d.disclaimerAcceptedFlag}\n\n`);
|
|
28
|
+
return { accepted: true, reason: 'flag' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!stdinIsTTY) {
|
|
32
|
+
process.stdout.write(` ${d.disclaimerNonInteractive}\n\n`);
|
|
33
|
+
return { accepted: false, reason: 'non-interactive' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (drainBeforeAsk && typeof ask.drain === 'function') ask.drain();
|
|
37
|
+
|
|
38
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
39
|
+
const raw = String(await ask(styleQuestion(d.disclaimerQuestion))).trim();
|
|
40
|
+
if (isAffirmative(raw)) return { accepted: true, reason: 'interactive' };
|
|
41
|
+
if (isNegative(raw)) {
|
|
42
|
+
process.stdout.write(` ${d.disclaimerDeclined}\n\n`);
|
|
43
|
+
return { accepted: false, reason: 'declined' };
|
|
44
|
+
}
|
|
45
|
+
process.stdout.write(` ${d.disclaimerInvalidAnswer}\n`);
|
|
46
|
+
}
|
|
47
|
+
process.stdout.write(` ${d.disclaimerNoAnswer}\n\n`);
|
|
48
|
+
return { accepted: false, reason: 'no-answer' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { confirmDisclaimerAcceptance, isAffirmative, isNegative };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { getCatalog } = require('./i18n');
|
|
4
|
+
|
|
5
|
+
// Client-side "remediation prompt" for the certify report (skill-code- certification, issue 011).
|
|
6
|
+
function buildRemediationPrompt(item, lang) {
|
|
7
|
+
const r = getCatalog(lang).certify.report;
|
|
8
|
+
const improvements =
|
|
9
|
+
item && item.result && Array.isArray(item.result.improvements)
|
|
10
|
+
? item.result.improvements.filter((x) => typeof x === 'string' && x)
|
|
11
|
+
: [];
|
|
12
|
+
if (improvements.length === 0) return null;
|
|
13
|
+
|
|
14
|
+
const lines = [];
|
|
15
|
+
lines.push(r.remediationIntro(item.skillName, item.technology));
|
|
16
|
+
lines.push('');
|
|
17
|
+
improvements.forEach((imp, i) => lines.push(`${i + 1}. ${imp}`));
|
|
18
|
+
lines.push('');
|
|
19
|
+
lines.push(r.remediationClosing);
|
|
20
|
+
return lines.join('\n');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { buildRemediationPrompt };
|
package/src/cli-args.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const VALID_LANGS = new Set(['es', 'en']);
|
|
4
|
+
const VALID_SCOPES = new Set(['machine', 'repo']);
|
|
5
|
+
|
|
6
|
+
function parseScope(value) {
|
|
7
|
+
return VALID_SCOPES.has(value) ? value : null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function parseRepoList(value) {
|
|
11
|
+
if (typeof value !== 'string') return null;
|
|
12
|
+
const items = value
|
|
13
|
+
.split(',')
|
|
14
|
+
.map((s) => s.trim())
|
|
15
|
+
.filter(Boolean);
|
|
16
|
+
return items.length > 0 ? items : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseArgs(argv) {
|
|
20
|
+
const opts = {
|
|
21
|
+
json: false,
|
|
22
|
+
save: true,
|
|
23
|
+
root: null,
|
|
24
|
+
help: false,
|
|
25
|
+
consentStatus: false,
|
|
26
|
+
consentRevoke: false,
|
|
27
|
+
consentReset: false,
|
|
28
|
+
consentEmail: null,
|
|
29
|
+
setEndpoint: null,
|
|
30
|
+
showEndpoint: false,
|
|
31
|
+
buildNextLevel: false,
|
|
32
|
+
force: false,
|
|
33
|
+
lang: null,
|
|
34
|
+
roadmap: false,
|
|
35
|
+
repos: null,
|
|
36
|
+
allRepos: false,
|
|
37
|
+
// Dynamic scope selector for the DEDICATED `ai-usage` command (a symptom fix: machine-wide runs were silently collapsing to the current repo's agents).
|
|
38
|
+
machine: false,
|
|
39
|
+
repo: false,
|
|
40
|
+
scope: null,
|
|
41
|
+
noAi: false,
|
|
42
|
+
// Issue 103: turns the section reveal off. Same name in both parsers, and the
|
|
43
|
+
// env var `NO_ANIMATION` does the same thing (the `NO_COLOR` pattern of 079).
|
|
44
|
+
noAnimation: false,
|
|
45
|
+
};
|
|
46
|
+
for (let i = 0; i < argv.length; i++) {
|
|
47
|
+
const a = argv[i];
|
|
48
|
+
if (a === '--no-animation') opts.noAnimation = true;
|
|
49
|
+
else if (a === '--json') opts.json = true;
|
|
50
|
+
else if (a === '--no-save') opts.save = false;
|
|
51
|
+
else if (a === '--root') opts.root = argv[++i];
|
|
52
|
+
else if (a === '--consent-status') opts.consentStatus = true;
|
|
53
|
+
else if (a === '--consent-revoke') opts.consentRevoke = true;
|
|
54
|
+
else if (a === '--consent-reset') opts.consentReset = true;
|
|
55
|
+
else if (a === '--consent-email') opts.consentEmail = argv[++i];
|
|
56
|
+
else if (a.startsWith('--consent-email=')) opts.consentEmail = a.slice('--consent-email='.length);
|
|
57
|
+
else if (a === '--set-endpoint') opts.setEndpoint = argv[++i];
|
|
58
|
+
else if (a.startsWith('--set-endpoint=')) opts.setEndpoint = a.slice('--set-endpoint='.length);
|
|
59
|
+
else if (a === '--show-endpoint') opts.showEndpoint = true;
|
|
60
|
+
else if (a === '--build-next-level') opts.buildNextLevel = true;
|
|
61
|
+
else if (a === '--force') opts.force = true;
|
|
62
|
+
// ADR-016: the tier roadmap / "next steps" block is removed from the
|
|
63
|
+
// default footprint output; `--roadmap` (alias `--next`) shows it again.
|
|
64
|
+
else if (a === '--roadmap' || a === '--next') opts.roadmap = true;
|
|
65
|
+
else if (a === '--lang') opts.lang = VALID_LANGS.has(argv[++i]) ? argv[i] : null;
|
|
66
|
+
else if (a.startsWith('--lang=')) {
|
|
67
|
+
const value = a.slice('--lang='.length);
|
|
68
|
+
opts.lang = VALID_LANGS.has(value) ? value : null;
|
|
69
|
+
}
|
|
70
|
+
else if (a === '--no-ai') opts.noAi = true;
|
|
71
|
+
else if (a === '--all-repos') opts.allRepos = true;
|
|
72
|
+
else if (a === '--machine') opts.machine = true;
|
|
73
|
+
else if (a === '--repo') opts.repo = true;
|
|
74
|
+
else if (a === '--scope') opts.scope = parseScope(argv[++i]);
|
|
75
|
+
else if (a.startsWith('--scope=')) opts.scope = parseScope(a.slice('--scope='.length));
|
|
76
|
+
else if (a === '--repos') opts.repos = parseRepoList(argv[++i]);
|
|
77
|
+
else if (a.startsWith('--repos=')) opts.repos = parseRepoList(a.slice('--repos='.length));
|
|
78
|
+
else if (a === '--help' || a === '-h') opts.help = true;
|
|
79
|
+
}
|
|
80
|
+
return opts;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { parseArgs };
|
|
@@ -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 };
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const { getHomeDir, readEnv } = require('./env-paths');
|
|
6
|
+
|
|
7
|
+
// Single source of truth for the CLI's config directory (ADR-045).
|
|
8
|
+
|
|
9
|
+
const DIR_NAME = 'shakers';
|
|
10
|
+
const LEGACY_DIR_NAME = 'ai-footprint';
|
|
11
|
+
|
|
12
|
+
// Only config.json holds persisted endpoint URLs that must follow the endpoint
|
|
13
|
+
// rename; every other file is copied byte-for-byte.
|
|
14
|
+
const CONFIG_FILE = 'config.json';
|
|
15
|
+
|
|
16
|
+
// One-time marker dropped in the NEW dir once migration has run.
|
|
17
|
+
const MIGRATION_MARKER = '.migrated-from-ai-footprint';
|
|
18
|
+
|
|
19
|
+
function overrideDir(env) {
|
|
20
|
+
return readEnv(env, 'CONFIG_DIR') || null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function defaultConfigDir(env) {
|
|
24
|
+
return path.join(getHomeDir(env), '.config', DIR_NAME);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function legacyConfigDir(env) {
|
|
28
|
+
return path.join(getHomeDir(env), '.config', LEGACY_DIR_NAME);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getConfigDir(env = process.env) {
|
|
32
|
+
return overrideDir(env) || defaultConfigDir(env);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Rewrites the endpoint rename inside any persisted string value.
|
|
36
|
+
function rewriteConfigEndpoints(text) {
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(text);
|
|
40
|
+
} catch {
|
|
41
|
+
return text;
|
|
42
|
+
}
|
|
43
|
+
const walk = (v) => {
|
|
44
|
+
if (typeof v === 'string') return v.includes('ai-footprint') ? v.split('ai-footprint').join('usage') : v;
|
|
45
|
+
if (Array.isArray(v)) return v.map(walk);
|
|
46
|
+
if (v && typeof v === 'object') {
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const k of Object.keys(v)) out[k] = walk(v[k]);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
return v;
|
|
52
|
+
};
|
|
53
|
+
return `${JSON.stringify(walk(parsed), null, 2)}\n`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Atomic write preserving `mode`: temp-in-same-dir + fsync + rename.
|
|
57
|
+
function writeFilePreservingMode(dest, buf, mode, overwrite) {
|
|
58
|
+
if (!overwrite && fs.existsSync(dest)) return false;
|
|
59
|
+
const dir = path.dirname(dest);
|
|
60
|
+
const tmp = path.join(dir, `.${path.basename(dest)}.${process.pid}.${Date.now()}.tmp`);
|
|
61
|
+
const fd = fs.openSync(tmp, 'wx', mode);
|
|
62
|
+
try {
|
|
63
|
+
fs.writeSync(fd, buf);
|
|
64
|
+
fs.fsyncSync(fd);
|
|
65
|
+
} finally {
|
|
66
|
+
fs.closeSync(fd);
|
|
67
|
+
}
|
|
68
|
+
fs.renameSync(tmp, dest); // atomic replace within the same directory
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Migrates the legacy config dir to the new one exactly once (guarded by MIGRATION_MARKER).
|
|
73
|
+
function migrateLegacyConfigDir(env = process.env) {
|
|
74
|
+
// Explicit override: the caller chose the location, nothing to migrate.
|
|
75
|
+
if (overrideDir(env)) return { migrated: false, reason: 'override' };
|
|
76
|
+
|
|
77
|
+
const to = defaultConfigDir(env);
|
|
78
|
+
const from = legacyConfigDir(env);
|
|
79
|
+
const marker = path.join(to, MIGRATION_MARKER);
|
|
80
|
+
try {
|
|
81
|
+
if (fs.existsSync(marker)) return { migrated: false, reason: 'already-migrated', to };
|
|
82
|
+
if (!fs.existsSync(from) || !fs.statSync(from).isDirectory()) {
|
|
83
|
+
return { migrated: false, reason: 'no-legacy', from, to };
|
|
84
|
+
}
|
|
85
|
+
fs.mkdirSync(to, { recursive: true });
|
|
86
|
+
const brought = [];
|
|
87
|
+
for (const name of fs.readdirSync(from)) {
|
|
88
|
+
if (name === MIGRATION_MARKER) continue;
|
|
89
|
+
const src = path.join(from, name);
|
|
90
|
+
let st;
|
|
91
|
+
try {
|
|
92
|
+
st = fs.statSync(src);
|
|
93
|
+
} catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (!st.isFile()) continue; // the config dir is flat; skip anything odd
|
|
97
|
+
const dest = path.join(to, name);
|
|
98
|
+
const isConfig = name === CONFIG_FILE;
|
|
99
|
+
const buf = isConfig
|
|
100
|
+
? Buffer.from(rewriteConfigEndpoints(fs.readFileSync(src, 'utf8')), 'utf8')
|
|
101
|
+
: fs.readFileSync(src);
|
|
102
|
+
// config.json overwrites install's default; everything else is copy-if-missing.
|
|
103
|
+
if (writeFilePreservingMode(dest, buf, st.mode & 0o777, isConfig)) brought.push(name);
|
|
104
|
+
}
|
|
105
|
+
// Strictly once: drop the marker so a later boot never re-runs (and never
|
|
106
|
+
// re-overwrites a config the Talent has since edited in the new dir).
|
|
107
|
+
fs.writeFileSync(marker, `${new Date().toISOString()}\n`);
|
|
108
|
+
return { migrated: true, from, to, brought };
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return { migrated: false, reason: 'error', error: err && err.message, from, to };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
getConfigDir,
|
|
116
|
+
legacyConfigDir,
|
|
117
|
+
defaultConfigDir,
|
|
118
|
+
migrateLegacyConfigDir,
|
|
119
|
+
rewriteConfigEndpoints,
|
|
120
|
+
};
|