tadforce 0.3.0 → 0.4.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -5
- package/package.json +1 -1
- package/skills/tadforce/references/questions.md +8 -9
- package/skills/tadforce/references/workflow.md +13 -7
- package/skills/tadforce-review/SKILL.md +2 -0
- package/skills/tadforce-update/SKILL.md +6 -4
- package/src/build/directives.js +16 -11
- package/src/build/figures.js +1 -1
- package/src/build/render.js +8 -5
- package/src/commands/docs.js +34 -13
- package/src/commands/doctor.js +399 -135
- package/src/commands/org.js +75 -11
- package/src/commands/spec.js +19 -6
- package/src/commands/update.js +54 -23
- package/src/facts/extractors/misc.js +2 -0
- package/src/facts/heuristics/exclusions.js +13 -1
- package/src/facts/heuristics/modules.js +5 -3
- package/src/facts/index.js +20 -1
- package/src/sf/sf.js +28 -4
- package/src/util/agents.js +35 -0
- package/src/util/config.js +4 -1
- package/src/util/i18n.js +10 -0
- package/src/validate/index.js +3 -2
- package/templates/html/editor.js +25 -0
- package/templates/i18n/en.json +8 -2
- package/templates/i18n/fr.json +8 -2
- package/templates/manifest/package-tad.xml +4 -104
- package/templates/specs/sections.yml +3 -1
- package/templates/tad/en/02-purpose.md +15 -2
- package/templates/tad/fr/02-purpose.md +15 -2
package/src/commands/doctor.js
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
// `tadforce doctor`: everything before the writing, in one loop that runs until all is green —
|
|
2
2
|
// tools, project, org connection, metadata, the framing questions and the questions only a human can answer.
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import readline from 'node:readline/promises';
|
|
6
7
|
import { spawnSync } from 'node:child_process';
|
|
7
8
|
import YAML from 'yaml';
|
|
8
9
|
import { exists, readJson, readText, writeText } from '../util/fsx.js';
|
|
9
10
|
import { findWorkspace, defaultConfig, DEFAULT_BRAND, loadWorkspace, sourceDirs } from '../util/config.js';
|
|
10
|
-
import { CONFIG_FILE } from '../util/paths.js';
|
|
11
|
+
import { CONFIG_FILE, TEMPLATES } from '../util/paths.js';
|
|
11
12
|
import { LANGUAGES, normalizeLang } from '../util/i18n.js';
|
|
12
|
-
import { sfVersion, sfJson, sfInteractive, soql } from '../sf/sf.js';
|
|
13
|
+
import { sfVersion, sfJson, sfInteractive, soql, isTransient } from '../sf/sf.js';
|
|
13
14
|
import { findBrowser, closeBrowser } from '../build/browser.js';
|
|
14
15
|
import { findLogoCandidates, orgThemeColors, imageColors, paletteFrom } from '../build/brand.js';
|
|
15
16
|
import { log, c, setQuiet } from '../util/log.js';
|
|
17
|
+
import { installedAgents, insideAgent, launchAgent } from '../util/agents.js';
|
|
16
18
|
import { ensureWorkspace, installSkills } from './init.js';
|
|
17
19
|
import * as org from './org.js';
|
|
18
20
|
import * as docs from './docs.js';
|
|
@@ -36,28 +38,67 @@ export async function checks(cwd) {
|
|
|
36
38
|
|
|
37
39
|
const sfdx = path.join(cwd, 'sfdx-project.json');
|
|
38
40
|
const hasProject = exists(sfdx);
|
|
39
|
-
add('sfdx-project', hasProject, hasProject ? `SFDX project: ${readJson(sfdx).name || path.basename(cwd)}` : 'No sfdx-project.json in this folder', hasProject ? null : '
|
|
41
|
+
add('sfdx-project', hasProject, hasProject ? `SFDX project: ${readJson(sfdx).name || path.basename(cwd)}` : 'No sfdx-project.json in this folder', hasProject ? null : 'pick your Salesforce project (doctor lists the ones nearby), or --project create', false, hasProject ? 'ok' : 'warn');
|
|
40
42
|
return out;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
/** The org
|
|
44
|
-
async function
|
|
45
|
-
if (typeof args.org === 'string') return args.org;
|
|
46
|
-
if (cfg && cfg.org && cfg.org.alias) return cfg.org.alias;
|
|
45
|
+
/** The default org: the project's target-org, else the machine-wide one (`global: true`) — sf config. */
|
|
46
|
+
async function defaultOrg(project) {
|
|
47
47
|
try {
|
|
48
48
|
const r = await sfJson(['config', 'get', 'target-org'], { cwd: project, timeoutMs: 60000 });
|
|
49
|
-
const
|
|
50
|
-
return
|
|
49
|
+
const e = Array.isArray(r) && r[0];
|
|
50
|
+
return e && e.value ? { alias: e.value, global: /global/i.test(e.location || '') } : null;
|
|
51
51
|
} catch { return null; }
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// One check per org and per run: every doctor step reuses it.
|
|
55
|
+
const statusCache = new Map();
|
|
54
56
|
async function orgStatus(alias) {
|
|
55
57
|
if (!alias) return { ok: false, reason: 'none' };
|
|
58
|
+
if (!statusCache.has(alias)) statusCache.set(alias, cachedOrgStatus(alias));
|
|
59
|
+
return statusCache.get(alias);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// An org seen connected stays « connected » for 10 minutes across doctor runs (each `sf org display` costs seconds);
|
|
63
|
+
// failures are never kept, so a fresh login is seen at once.
|
|
64
|
+
const STATUS_FILE = path.join(os.tmpdir(), 'tadforce-org-status.json');
|
|
65
|
+
async function cachedOrgStatus(alias) {
|
|
66
|
+
let all = {};
|
|
67
|
+
try { all = JSON.parse(fs.readFileSync(STATUS_FILE, 'utf8')); } catch { /* none yet */ }
|
|
68
|
+
const hit = all[alias];
|
|
69
|
+
if (hit && hit.ok && Date.now() - hit.at < 600000) return hit;
|
|
70
|
+
const st = await readOrgStatus(alias);
|
|
71
|
+
if (st.ok) { all[alias] = { ...st, at: Date.now() }; try { fs.writeFileSync(STATUS_FILE, JSON.stringify(all)); } catch { /* no writable tmp */ } }
|
|
72
|
+
return st;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readOrgStatus(alias) {
|
|
56
76
|
try {
|
|
57
77
|
const d = await sfJson(['org', 'display', '--target-org', alias], { timeoutMs: 120000 });
|
|
58
|
-
if (d.connectedStatus && !/^connected$/i.test(d.connectedStatus) && !/^active$/i.test(d.status || '')) return { ok: false, reason: 'expired', detail: d.connectedStatus };
|
|
78
|
+
if (d.connectedStatus && !/^connected$/i.test(d.connectedStatus) && !/^active$/i.test(d.status || '')) return { ok: false, reason: /DomainNotFound|ENOTFOUND/i.test(d.connectedStatus) ? 'unreachable' : 'expired', detail: d.connectedStatus };
|
|
59
79
|
return { ok: true, username: d.username, instanceUrl: d.instanceUrl, sandbox: /test\.salesforce|sandbox|\.cs\d+\./i.test(d.instanceUrl || '') };
|
|
60
|
-
} catch (e) {
|
|
80
|
+
} catch (e) {
|
|
81
|
+
// an unknown alias, a network failure and an expired session call for different actions
|
|
82
|
+
if (/No authorization information|NamedOrgNotFound|No org configuration found|cannot be found/i.test(e.message) && !isTransient(e)) return { ok: false, reason: 'unknown', detail: e.message.slice(0, 160) };
|
|
83
|
+
if (isTransient(e)) return { ok: false, reason: 'unreachable', detail: e.message.slice(0, 160) };
|
|
84
|
+
return { ok: false, reason: 'expired', detail: e.message.slice(0, 160) };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Orgs authorized on this machine, to choose from instead of logging in again. Listed without pinging
|
|
90
|
+
* each org (that takes a minute and fails on one bad org); the chosen one is checked afterwards.
|
|
91
|
+
*/
|
|
92
|
+
async function connectedOrgs() {
|
|
93
|
+
try {
|
|
94
|
+
const r = await sfJson(['org', 'list', '--skip-connection-status'], { timeoutMs: 120000 });
|
|
95
|
+
const all = [...(r.nonScratchOrgs || []), ...(r.scratchOrgs || []), ...(r.sandboxes || []), ...(r.other || [])];
|
|
96
|
+
const ok = all.filter((o) => !o.isExpired && !/deleted|expired/i.test(o.status || ''));
|
|
97
|
+
return [...new Map(ok.map((o) => [o.username, { alias: o.alias || o.username, username: o.username, instanceUrl: o.instanceUrl }])).values()];
|
|
98
|
+
} catch (e) {
|
|
99
|
+
log.warn(`Could not list the orgs of this machine (${e.message.slice(0, 120)})`);
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
61
102
|
}
|
|
62
103
|
|
|
63
104
|
/** Org facts that give a recommended answer to the framing questions. */
|
|
@@ -70,6 +111,12 @@ async function proposals(alias) {
|
|
|
70
111
|
return out;
|
|
71
112
|
}
|
|
72
113
|
|
|
114
|
+
/** Short code for file names: initials of a multi-word name (Northwind Bank → NB), else the name cut to 8. */
|
|
115
|
+
export function shortCode(name) {
|
|
116
|
+
const words = name.normalize('NFD').replace(/\p{Diacritic}/gu, '').split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
117
|
+
return (words.length > 1 ? words.map((w) => w[0]).join('') : words[0] || 'CLIENT').slice(0, 8).toUpperCase();
|
|
118
|
+
}
|
|
119
|
+
|
|
73
120
|
const listArg = (v) => (typeof v !== 'string' ? undefined : v.split(',').map((x) => x.trim()).filter(Boolean));
|
|
74
121
|
|
|
75
122
|
/** Answers given as flags (the agent's way of answering) are written to the config. */
|
|
@@ -77,7 +124,7 @@ async function applyAnswers(ctx, args) {
|
|
|
77
124
|
const cfg = ctx.cfg;
|
|
78
125
|
const set = (cond, fn) => { if (cond) fn(); };
|
|
79
126
|
set(args.lang, () => { cfg.language = normalizeLang(args.lang); });
|
|
80
|
-
set(args.client, () => { cfg.client.name = String(args.client); if (!args.short && (cfg.client.short === 'CLIENT' || !cfg.client.short)) cfg.client.short = String(args.client)
|
|
127
|
+
set(args.client, () => { cfg.client.name = String(args.client); if (!args.short && (cfg.client.short === 'CLIENT' || !cfg.client.short)) cfg.client.short = shortCode(String(args.client)); });
|
|
81
128
|
set(args.short, () => { cfg.client.short = String(args.short); });
|
|
82
129
|
set(args.integrator !== undefined, () => { cfg.integrator.name = args.integrator === true || args.integrator === '' || args.integrator === 'none' ? null : String(args.integrator); });
|
|
83
130
|
set(args.author !== undefined, () => { cfg.document.author = args.author === true || args.author === '' || args.author === 'none' ? null : String(args.author); });
|
|
@@ -102,7 +149,9 @@ async function applyAnswers(ctx, args) {
|
|
|
102
149
|
}
|
|
103
150
|
if (args.specs !== undefined) cfg.setup.specsAsked = true; // --no-specs: none provided
|
|
104
151
|
if (args.confirm || args.yes) cfg.setup.confirmed = true;
|
|
105
|
-
if (args.factsReviewed) cfg.setup.factsReviewed = true;
|
|
152
|
+
if (args.factsReviewed) { cfg.setup.factsReviewed = true; cfg.setup.factsDirty = true; }
|
|
153
|
+
if (args.branding === 'keep') cfg.setup.brandingConfirmed = true;
|
|
154
|
+
if (cfg.document.reference === 'TAD-CLIENT-CRM' && cfg.client.short !== 'CLIENT') cfg.document.reference = `TAD-${cfg.client.short}-CRM`;
|
|
106
155
|
ctx.save(cfg);
|
|
107
156
|
}
|
|
108
157
|
|
|
@@ -159,34 +208,13 @@ async function brand(ctx) {
|
|
|
159
208
|
return notes;
|
|
160
209
|
}
|
|
161
210
|
|
|
162
|
-
/** Questions only a human can answer, each with a recommended answer. */
|
|
163
|
-
function framingQuestions(cfg, prop, alias, hasLocal) {
|
|
164
|
-
const q = [];
|
|
165
|
-
const ask = (id, question, recommended, extra = {}) => q.push({ id, question, recommended, ...extra });
|
|
166
|
-
if (!cfg.setup.confirmed) {
|
|
167
|
-
ask('lang', `Document language — ${LANGUAGES.join(', ')} built in, or any language code (es, de, it, pt, nl, ar, ja…): the agent then translates the headings and labels once`, prop.language || cfg.language, { options: [...LANGUAGES, 'other'] });
|
|
168
|
-
ask('client', 'Client name, as it must appear in the document', prop.client || cfg.client.name);
|
|
169
|
-
ask('short', 'Client short name (file names, references)', cfg.client.short !== 'CLIENT' ? cfg.client.short : String(prop.client || cfg.client.name).replace(/[^A-Za-z0-9]/g, '').slice(0, 8).toUpperCase());
|
|
170
|
-
ask('integrator', 'Integrator, if any (blank: the document only names the client)', cfg.integrator.name || '', { optional: true });
|
|
171
|
-
ask('author', 'Author of the document (blank: no author shown)', cfg.document.author || '', { optional: true });
|
|
172
|
-
ask('title', 'Document title', cfg.document.title);
|
|
173
|
-
ask('scope', 'Part of the org to document — name patterns such as NWB_*,Loan* (blank: the whole org)', (cfg.scope.include || []).join(','), { optional: true });
|
|
174
|
-
ask('logo', `Client logo — a file or a URL${cfg.client.logo ? ` (current: ${cfg.client.logo})` : ' (blank: searched in the org, then a badge with initials)'}`, cfg.client.logo || '', { optional: true });
|
|
175
|
-
}
|
|
176
|
-
if (!cfg.setup.specsAsked && !(cfg.inputs.specs || []).length) ask('specs', 'Functional specifications or other project documents (PDF, DOCX, MD) — comma-separated paths (blank: none)', '', { optional: true });
|
|
177
|
-
if (!cfg.source.mode) {
|
|
178
|
-
ask('source', 'Metadata to document', alias ? 'org' : 'local', { options: ['org', 'local', 'both'], help: 'org: retrieve every metadata type from the org (recommended — the org is the truth); local: the force-app sources already here' + (hasLocal ? '' : ' (none found)') + '; both: org first, local sources added' });
|
|
179
|
-
}
|
|
180
|
-
return q;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
211
|
/** Systems to name and exclusions to confirm, from facts/questions.json. */
|
|
184
212
|
function factQuestions(ctx) {
|
|
185
213
|
const p = ctx.p('facts', 'questions.json');
|
|
186
214
|
if (!exists(p) || ctx.cfg.setup.factsReviewed) return [];
|
|
187
215
|
const qs = readJson(p);
|
|
188
216
|
const out = [];
|
|
189
|
-
for (const s of qs.systems || []) out.push({ id: `system:${s.key}`, kind: 'system', question: `Which system is ${s.key}? (name as the client calls it
|
|
217
|
+
for (const s of qs.systems || []) out.push({ id: `system:${s.key}`, kind: 'system', question: `Which system is ${s.key}? (name as the client calls it)`, recommended: '', endpoints: s.endpoints, writeTo: 'knowledge/glossary.yml › systems' });
|
|
190
218
|
for (const g of (qs.exclusions && qs.exclusions.groups) || []) out.push({ id: `exclude:${g.reason}`, kind: 'exclusion', question: `${g.count} component(s): ${g.reason}. Exclude from the document? (all / none / list of names)`, recommended: 'none', items: g.items, writeTo: 'knowledge/exclusions.yml › exclude' });
|
|
191
219
|
out.push({ id: 'features', kind: 'features', question: 'Any feature decommissioned or switched off that the document must not describe? (blank: none)', recommended: '', writeTo: 'knowledge/exclusions.yml › features' });
|
|
192
220
|
return out;
|
|
@@ -203,6 +231,10 @@ function editYaml(file, key, items) {
|
|
|
203
231
|
/** Interactive answers to the facts questions, written to the knowledge files. */
|
|
204
232
|
async function askFacts(ctx, rl, questions) {
|
|
205
233
|
const systems = [], exclude = [], features = [];
|
|
234
|
+
log.step('What the metadata cannot tell:');
|
|
235
|
+
questions.forEach((q, i) => console.log(` ${i + 1}. ${q.question}`));
|
|
236
|
+
console.log(` ${c.dim('recommended: systems « to be confirmed » in the document, nothing excluded, no decommissioned feature')}`);
|
|
237
|
+
if (yes((await rl.question(' Accept the recommended answers? [Y/n] ')).trim())) return 0;
|
|
206
238
|
for (const q of questions) {
|
|
207
239
|
const a = (await rl.question(` ${q.question}${q.items ? '\n ' + c.dim(q.items.slice(0, 12).join(', ') + (q.items.length > 12 ? ` … +${q.items.length - 12}` : '')) : ''}${q.endpoints ? '\n ' + c.dim(q.endpoints.join(', ')) : ''}\n > `)).trim();
|
|
208
240
|
if (!a) continue;
|
|
@@ -219,144 +251,376 @@ async function askFacts(ctx, rl, questions) {
|
|
|
219
251
|
return systems.length + exclude.length + features.length;
|
|
220
252
|
}
|
|
221
253
|
|
|
254
|
+
/** Framing questions (5 to 6), each with a recommended answer. Title and short code are derived. */
|
|
255
|
+
function framingQuestions(cfg, prop) {
|
|
256
|
+
if (cfg.setup.confirmed) return [];
|
|
257
|
+
const q = [];
|
|
258
|
+
const ask = (id, question, recommended, extra = {}) => q.push({ id, question, recommended, ...extra });
|
|
259
|
+
ask('lang', `Document language (${LANGUAGES.join(', ')} built in, or any code: es, de, it, pt, ar…)`, prop.language || cfg.language, { options: [...LANGUAGES, 'other'] });
|
|
260
|
+
ask('client', 'Client name, as it must appear in the document', prop.client || (cfg.client.name !== 'Client' ? cfg.client.name : ''));
|
|
261
|
+
ask('integrator', 'Integrator (blank: the document only names the client)', cfg.integrator.name || '', { optional: true });
|
|
262
|
+
ask('author', 'Author of the document (blank: no author shown)', cfg.document.author || '', { optional: true });
|
|
263
|
+
ask('specs', 'Functional specifications to use, comma-separated paths (blank: none)', (cfg.inputs.specs || []).join(','), { optional: true });
|
|
264
|
+
ask('scope', 'Part of the org to document, name patterns such as NWB_* (blank: the whole org)', (cfg.scope.include || []).join(','), { optional: true });
|
|
265
|
+
return q;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Interactive batch: the list with its recommendations, then « accept all » or one by one. */
|
|
269
|
+
async function askBatch(rl, title, questions) {
|
|
270
|
+
log.step(title);
|
|
271
|
+
questions.forEach((q, i) => console.log(` ${i + 1}. ${q.question}\n ${c.dim('recommended:')} ${q.recommended === '' || q.recommended == null ? c.dim('(blank)') : c.bold(String(q.recommended))}`));
|
|
272
|
+
const all = (await rl.question(' Accept all the recommended answers? [Y/n] ')).trim();
|
|
273
|
+
const answers = {};
|
|
274
|
+
if (/^(|y|o|yes|oui)$/i.test(all)) { for (const q of questions) answers[q.id] = q.recommended ?? ''; return answers; }
|
|
275
|
+
for (const q of questions) {
|
|
276
|
+
const a = (await rl.question(` ${q.question}${q.options ? ` (${q.options.join('/')})` : ''}${q.recommended ? ` [${q.recommended}]` : ''}: `)).trim();
|
|
277
|
+
answers[q.id] = a || q.recommended || '';
|
|
278
|
+
if (q.id === 'lang' && /^(other|autre)$/i.test(a)) answers.lang = (await rl.question(' Language code (es, de, it, pt, nl, ar, ja…): ')).trim() || 'en';
|
|
279
|
+
}
|
|
280
|
+
return answers;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const yes = (a, def = true) => (a === '' ? def : /^(y|o|yes|oui)$/i.test(a));
|
|
284
|
+
|
|
285
|
+
/** Salesforce DX projects in this folder's sub-folders and next to it (two levels), for the user to pick from. */
|
|
286
|
+
function findProjects(start) {
|
|
287
|
+
const out = new Set();
|
|
288
|
+
const visit = (dir, depth) => {
|
|
289
|
+
let entries = [];
|
|
290
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
291
|
+
if (entries.some((e) => e.isFile() && e.name === 'sfdx-project.json')) out.add(dir);
|
|
292
|
+
if (depth <= 0) return;
|
|
293
|
+
for (const e of entries) if (e.isDirectory() && !/^(node_modules|\.|force-app$)/.test(e.name)) visit(path.join(dir, e.name), depth - 1);
|
|
294
|
+
};
|
|
295
|
+
visit(start, 2);
|
|
296
|
+
visit(path.dirname(start), 1);
|
|
297
|
+
out.delete(start);
|
|
298
|
+
return [...out].slice(0, 9);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const hasLocalSources = (project) => (readJson(path.join(project, 'sfdx-project.json')).packageDirectories || [])
|
|
302
|
+
.some((d) => exists(path.join(project, d.path)) && fs.readdirSync(path.join(project, d.path), { recursive: true }).some((f) => String(f).endsWith('-meta.xml')));
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Step 2 — which Salesforce project. Never creates anything without a yes. Returns the project folder,
|
|
306
|
+
* or { stop } with the question to hand back.
|
|
307
|
+
*/
|
|
308
|
+
async function resolveProject(start, args, rl) {
|
|
309
|
+
const ws = findWorkspace(start);
|
|
310
|
+
const confirmed = ws && defaultConfig(readJson(path.join(ws, CONFIG_FILE))).setup.projectConfirmed;
|
|
311
|
+
if (exists(path.join(start, 'sfdx-project.json'))) {
|
|
312
|
+
if (confirmed || args.project === 'yes' || args.yes) return start;
|
|
313
|
+
const name = readJson(path.join(start, 'sfdx-project.json')).name || path.basename(start);
|
|
314
|
+
if (!rl) return { stop: { id: 'project', question: `Generate the TAD for the Salesforce project "${name}" (${start})?`, recommended: 'yes', options: ['yes', '<path of another project>'] } };
|
|
315
|
+
if (yes((await rl.question(` Salesforce project found: ${c.bold(name)} (${start}).\n Generate the TAD for this project? [Y/n] `)).trim())) return start;
|
|
316
|
+
const other = (await rl.question(' Path of the project to document (blank: stop): ')).trim();
|
|
317
|
+
return other ? resolveProject(path.resolve(other), { ...args, project: 'yes' }, rl) : { stop: null };
|
|
318
|
+
}
|
|
319
|
+
if (args.project === 'create') return start;
|
|
320
|
+
if (args.project && args.project !== 'yes') return resolveProject(path.resolve(String(args.project)), { ...args, project: 'yes' }, rl);
|
|
321
|
+
const found = findProjects(start);
|
|
322
|
+
if (!rl) return { stop: { id: 'project', question: `No Salesforce DX project in ${start}. Which project should the TAD document? A path, or "create" to create a new project here.`, recommended: found[0] || '', options: [...found, 'create'] } };
|
|
323
|
+
console.log(` ${c.yellow('⚠')} ${start} is not a Salesforce DX project (no sfdx-project.json).`);
|
|
324
|
+
found.forEach((p, i) => console.log(` ${i + 1}. ${p}`));
|
|
325
|
+
console.log(` ${c.dim('or a path · c = create a new project here · Enter = stop')}`);
|
|
326
|
+
const a = (await rl.question(` Project to document${found.length ? ' [1]' : ''}: `)).trim();
|
|
327
|
+
if (!a && found.length) return found[0];
|
|
328
|
+
if (/^\d+$/.test(a) && found[Number(a) - 1]) return found[Number(a) - 1];
|
|
329
|
+
if (/^c$/i.test(a)) return start;
|
|
330
|
+
if (a) return resolveProject(path.resolve(a), { ...args, project: 'yes' }, rl);
|
|
331
|
+
console.log(' Open your Salesforce project folder (cd <path>), then run tadforce doctor again.');
|
|
332
|
+
return { stop: null };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Step 3 — which org. The default org is proposed, never taken silently; « no » opens the Salesforce
|
|
337
|
+
* login and asks again once an org is connected. Returns { ok } or { stop } with the question to hand back.
|
|
338
|
+
*/
|
|
339
|
+
async function confirmOrg(project, ctx, args, rl) {
|
|
340
|
+
const cfg = ctx.cfg;
|
|
341
|
+
if (cfg.source.mode === 'local') return { ok: true };
|
|
342
|
+
const explicit = typeof args.org === 'string' ? args.org : null;
|
|
343
|
+
const def = explicit || cfg.setup.orgConfirmed ? null : await defaultOrg(project);
|
|
344
|
+
let alias = explicit || (cfg.setup.orgConfirmed ? cfg.org.alias : def && def.alias);
|
|
345
|
+
let status = alias ? await orgStatus(alias) : { ok: false, reason: 'none' };
|
|
346
|
+
const keep = () => { cfg.org.alias = alias; cfg.setup.orgConfirmed = true; ctx.save(cfg); return { ok: true }; };
|
|
347
|
+
if (status.ok && (explicit || cfg.setup.orgConfirmed)) return keep();
|
|
348
|
+
// the machine-wide default may be any org (another client's): proposed, never recommended
|
|
349
|
+
const machineWide = !!(def && def.global);
|
|
350
|
+
const label = machineWide ? 'Machine-wide default org (not set for this project)' : 'Default org of this project';
|
|
351
|
+
const describe = () => `${c.bold(alias)} (${status.username}, ${status.instanceUrl})`;
|
|
352
|
+
const why = (st) => ({ unknown: 'is not authorized on this machine', unreachable: 'cannot be reached (network, or the org was deleted)', expired: 'session expired' }[st.reason] || 'is not connected');
|
|
353
|
+
// the default org is unusable or refused: the orgs already connected come first (the same user once), a new login last
|
|
354
|
+
const others = (await connectedOrgs()).filter((o) => o.alias !== alias && o.username !== alias && o.username !== status.username);
|
|
355
|
+
if (!rl) {
|
|
356
|
+
const login = 'another org: the human runs `sf org login web --alias <a> [--instance-url https://test.salesforce.com]` (opens the browser), then --org <a>';
|
|
357
|
+
return { stop: alias && status.ok
|
|
358
|
+
? { id: 'org', question: `${label}: "${alias}" (${status.username}, ${status.instanceUrl}). Generate the TAD from this org?`, recommended: machineWide ? null : alias, options: [`${alias} (${status.username}, ${status.instanceUrl})`, ...others.map((o) => `${o.alias} (${o.username}, ${o.instanceUrl})`), login, 'local: document the local sources only (--source local)'] }
|
|
359
|
+
: { id: 'org', question: `${alias ? `The default org "${alias}" ${why(status)}.` : 'No default org for this project.'} Which org is the TAD about? Answer with --org <alias>.`, recommended: null, options: [...others.map((o) => `${o.alias} (${o.username}, ${o.instanceUrl})`), login, 'local: document the local sources only (--source local)'], human: !others.length } };
|
|
360
|
+
}
|
|
361
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
362
|
+
if (alias && status.ok) {
|
|
363
|
+
const a = (await rl.question(` ${attempt ? 'Connected' : label}: ${describe()}\n Generate the TAD from this org? [Y/n/l = local sources only] `)).trim();
|
|
364
|
+
if (yes(a)) return keep();
|
|
365
|
+
if (/^l/i.test(a)) { cfg.source.mode = 'local'; ctx.save(cfg); return { ok: true }; }
|
|
366
|
+
} else if (alias) console.log(` ${c.yellow('⚠')} The default org "${alias}" ${why(status)}.`);
|
|
367
|
+
if (others.length) {
|
|
368
|
+
console.log(' Orgs already connected on this machine:');
|
|
369
|
+
others.forEach((o, i) => console.log(` ${i + 1}. ${o.alias} ${c.dim(`(${o.username}, ${o.instanceUrl})`)}`));
|
|
370
|
+
const pick = (await rl.question(' Org for the TAD (number), n = log in to another org, l = local sources only: ')).trim();
|
|
371
|
+
if (/^l/i.test(pick)) { cfg.source.mode = 'local'; ctx.save(cfg); return { ok: true }; }
|
|
372
|
+
if (others[Number(pick) - 1]) { alias = others[Number(pick) - 1].alias; status = await orgStatus(alias); if (status.ok) return keep(); }
|
|
373
|
+
}
|
|
374
|
+
// « no », or no usable org: open the Salesforce CLI login and wait for it.
|
|
375
|
+
const def = alias && status.reason === 'expired' ? alias : 'tad-org';
|
|
376
|
+
const next = (await rl.question(` Alias for the org to connect [${def}]: `)).trim() || def;
|
|
377
|
+
const sandbox = yes((await rl.question(' Is it a sandbox? [y/N] ')).trim(), false);
|
|
378
|
+
log.human('a browser window opens: log in to the org, then click « Allow ». Waiting for the login…');
|
|
379
|
+
if (!sfInteractive(['org', 'login', 'web', '--alias', next, ...(sandbox ? ['--instance-url', 'https://test.salesforce.com'] : [])])) { log.warn('Login did not complete.'); continue; }
|
|
380
|
+
alias = next; status = await orgStatus(alias);
|
|
381
|
+
}
|
|
382
|
+
return { stop: null };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Step 5 — which metadata, and the go before a long retrieve. Returns { mode } or { stop }. */
|
|
386
|
+
async function chooseSource(project, ctx, args, rl) {
|
|
387
|
+
const cfg = ctx.cfg;
|
|
388
|
+
const local = hasLocalSources(project);
|
|
389
|
+
// local sources need no go: there is no long retrieve
|
|
390
|
+
if (cfg.source.mode === 'local' || (cfg.source.mode && cfg.setup.sourceGo)) return { mode: cfg.source.mode };
|
|
391
|
+
if (typeof args.source === 'string') { cfg.source.mode = args.source === 'self' ? 'local' : args.source; cfg.setup.sourceGo = args.source !== 'self'; ctx.save(cfg); if (args.source !== 'self') return { mode: cfg.source.mode }; }
|
|
392
|
+
const org = cfg.source.mode !== 'local' && cfg.org.alias;
|
|
393
|
+
const options = [
|
|
394
|
+
...(org ? [{ id: 'org', text: `retrieve all the metadata of ${cfg.org.alias}, read-only (several minutes)` }] : []),
|
|
395
|
+
...(local ? [{ id: 'local', text: 'use only the metadata already in this project (force-app…)' }] : []),
|
|
396
|
+
{ id: 'self', text: 'you retrieve the metadata yourself first, then give the go' },
|
|
397
|
+
];
|
|
398
|
+
const recommended = org ? 'org' : local ? 'local' : 'self';
|
|
399
|
+
if (!rl) return { stop: { id: 'source', question: 'Which metadata should the TAD be written from? (--source answers it and is the go: the retrieve starts right away)', recommended, options: options.map((o) => `${o.id}: ${o.text}`) } };
|
|
400
|
+
log.step('Metadata for the document:');
|
|
401
|
+
options.forEach((o, i) => console.log(` ${i + 1}. ${o.text}${o.id === recommended ? c.dim(' (recommended)') : ''}`));
|
|
402
|
+
const a = (await rl.question(` Your choice [${options.findIndex((o) => o.id === recommended) + 1}]: `)).trim();
|
|
403
|
+
const pick = options[Number(a) - 1] || options.find((o) => o.id === recommended);
|
|
404
|
+
if (pick.id === 'self') {
|
|
405
|
+
console.log(` Retrieve the metadata into this project, for example:\n ${c.cyan('sf project retrieve start --manifest manifest/package.xml')}`);
|
|
406
|
+
const done = (await rl.question(' Press Enter when your retrieve is done (s = stop for now): ')).trim();
|
|
407
|
+
if (/^s/i.test(done)) return { stop: null };
|
|
408
|
+
cfg.source.mode = 'local'; cfg.setup.sourceGo = true; ctx.save(cfg);
|
|
409
|
+
return { mode: 'local' };
|
|
410
|
+
}
|
|
411
|
+
if (pick.id === 'org' && !yes((await rl.question(' The retrieve takes several minutes, read-only. Start now? [Y/n] ')).trim())) {
|
|
412
|
+
console.log(' Run tadforce doctor again when you are ready: your answers are kept.');
|
|
413
|
+
return { stop: null };
|
|
414
|
+
}
|
|
415
|
+
cfg.source.mode = pick.id; cfg.setup.sourceGo = true; ctx.save(cfg);
|
|
416
|
+
return { mode: pick.id };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Step 6a — logo and colours found from evidence, confirmed in one question. */
|
|
420
|
+
async function confirmBranding(ctx, args, rl, say) {
|
|
421
|
+
for (const n of await brand(ctx)) say(() => log.ok(n));
|
|
422
|
+
const cfg = loadWorkspace(ctx.project).cfg;
|
|
423
|
+
if (cfg.setup.brandingConfirmed) return null;
|
|
424
|
+
const logo = cfg.client.logo || 'none (a badge with the initials)';
|
|
425
|
+
const colours = cfg.brand.source ? `${cfg.brand.gradient[0]} (from the ${cfg.brand.source})` : 'TADForce default colours';
|
|
426
|
+
if (!rl) return { id: 'branding', question: `Branding: logo ${logo}; colours ${colours}. Keep it? (--branding keep, or --logo <file|url>)`, recommended: 'keep' };
|
|
427
|
+
const a = (await rl.question(` Logo: ${c.bold(logo)} · colours: ${c.bold(colours)}\n Keep it? [Y / path or URL of another logo] `)).trim();
|
|
428
|
+
const c2 = loadWorkspace(ctx.project);
|
|
429
|
+
if (a && !yes(a, false) && !/^n/i.test(a)) {
|
|
430
|
+
try { c2.cfg.client.logo = await importAsset(c2, a, 'client-logo'); c2.save(c2.cfg); for (const n of await brand(loadWorkspace(ctx.project))) say(() => log.ok(n)); } catch (e) { log.warn(e.message); }
|
|
431
|
+
}
|
|
432
|
+
const c3 = loadWorkspace(ctx.project); c3.cfg.setup.brandingConfirmed = true; c3.save(c3.cfg);
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
|
|
222
436
|
/** One pass: where the workspace stands, and what remains. */
|
|
223
437
|
async function evaluate(project, args) {
|
|
224
438
|
const tools = await checks(project);
|
|
225
439
|
const ws = findWorkspace(project);
|
|
226
440
|
const cfg = ws ? defaultConfig(readJson(path.join(ws, CONFIG_FILE))) : defaultConfig();
|
|
227
|
-
const alias = tools.find((t) => t.id === 'sf-cli').ok ? await resolveAlias(project, args, cfg) : null;
|
|
228
|
-
const status = alias ? await orgStatus(alias) : { ok: false, reason: 'none' };
|
|
229
441
|
const list = [...tools];
|
|
230
442
|
const add = (id, ok, message, fix = null, human = false, level = ok ? 'ok' : 'error') => list.push({ id, ok, level, message, fix, human });
|
|
231
|
-
add('workspace',
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
else if (status.reason === 'none') add('org', false, 'No org connected to this project', 'log in once in the browser (doctor opens it), or `--source local` to document the local sources only', true);
|
|
236
|
-
else add('org', false, `Org "${alias}" is not connected (${status.detail || 'session expired'})`, 'log in again in the browser (doctor opens it)', true);
|
|
443
|
+
if (ws) add('workspace', true, `Workspace: ${path.relative(project, ws) || '.'}`);
|
|
444
|
+
if (cfg.source.mode !== 'local' && cfg.setup.orgConfirmed && cfg.org.alias) {
|
|
445
|
+
const status = await orgStatus(cfg.org.alias);
|
|
446
|
+
add('org', status.ok, status.ok ? `Org: ${cfg.org.alias} (${status.instanceUrl})` : `Org "${cfg.org.alias}" is not connected`, status.ok ? null : 'log in again (doctor opens the browser)', !status.ok);
|
|
237
447
|
}
|
|
238
|
-
let facts = false
|
|
448
|
+
let facts = false;
|
|
239
449
|
if (ws) {
|
|
240
450
|
const ctx = loadWorkspace(project);
|
|
241
|
-
snapshot = exists(ctx.p(ctx.cfg.source.dir));
|
|
242
451
|
facts = exists(ctx.p('facts', 'facts.json'));
|
|
243
452
|
const lang = normalizeLang(ctx.cfg.language);
|
|
244
453
|
const own = ctx.p('i18n', `${lang}.json`);
|
|
245
|
-
if (!LANGUAGES.includes(lang)
|
|
246
|
-
|
|
454
|
+
if (!LANGUAGES.includes(lang)) {
|
|
455
|
+
// still English: the labels file not translated, or chapters whose headings match the English skeleton
|
|
456
|
+
const english = [];
|
|
457
|
+
for (const doc of ['tad', 'axes']) {
|
|
458
|
+
const dir = ctx.p('src', doc);
|
|
459
|
+
if (!exists(dir)) continue;
|
|
460
|
+
for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.md'))) {
|
|
461
|
+
const tpl = path.join(TEMPLATES, doc, 'en', f);
|
|
462
|
+
const heads = (txt) => txt.split('\n').filter((l) => /^#{1,3} /.test(l)).join('\n');
|
|
463
|
+
if (exists(tpl) && heads(readText(tpl)) === heads(readText(path.join(dir, f)))) english.push(`src/${doc}/${f}`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const labels = exists(own) && readJson(own)._translate;
|
|
467
|
+
const base = defaultConfig().document;
|
|
468
|
+
const fields = ['title', 'status', 'classification', 'environment'].filter((k) => ctx.cfg.document[k] === base[k]);
|
|
469
|
+
if (labels || english.length || fields.length) add('language', true, `Document language "${lang}": ${[labels && 'interface labels', english.length && `${english.length} chapter(s) with English headings`, fields.length && `document ${fields.join(', ')} (English defaults)`].filter(Boolean).join(' and ')} still to translate (once)`, `the agent translates, once: the headings and the body text of ${english.length ? english.slice(0, 3).join(', ') + (english.length > 3 ? '…' : '') : 'src/*/*.md'} (keep {#anchors}, {-}, <!-- tad:… --> lines and @@…@@ directives; the caption after « | » may be translated), the values of tadforce/i18n/${lang}.json (keys and {placeholders} kept, meta.htmlLang set, "_translate" line removed), and document.title / status / classification / environment in tadforce.config.json`, false, 'warn');
|
|
470
|
+
}
|
|
471
|
+
const rr = ctx.p('.snapshot', 'retrieve-report.json');
|
|
472
|
+
const lost = exists(rr) ? (readJson(rr).failed || []).filter((x) => x.core).map((x) => x.type) : [];
|
|
473
|
+
if (lost.length) add('retrieve', true, `Not retrieved, the document needs them: ${lost.join(', ')}`, 'rerun the retrieve when the org is less busy: tadforce doctor --refresh', false, 'warn');
|
|
474
|
+
if (facts) add('metadata', true, `Metadata: ${exists(ctx.p(ctx.cfg.source.dir)) && ctx.cfg.source.mode !== 'local' ? 'retrieved from the org' : 'local sources'} → facts/facts.json`);
|
|
247
475
|
}
|
|
248
|
-
return { tools, ws, cfg,
|
|
476
|
+
return { tools, ws, cfg, list, facts };
|
|
249
477
|
}
|
|
250
478
|
|
|
251
479
|
export async function run(args) {
|
|
252
|
-
const
|
|
480
|
+
const start = path.resolve(args.cwd || process.cwd());
|
|
253
481
|
const interactive = process.stdin.isTTY && !args.json && !args.yes;
|
|
254
482
|
const rl = interactive ? readline.createInterface({ input: process.stdin, output: process.stdout }) : null;
|
|
255
483
|
const say = (fn) => { if (!args.json) fn(); };
|
|
256
484
|
if (args.json) setQuiet(true);
|
|
257
485
|
say(() => log.banner('doctor'));
|
|
258
486
|
try {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
-
const blocking = st.tools.filter((t) => t.level === 'error');
|
|
268
|
-
if (blocking.length) return finish(args, st, [], [], 'install the missing tools, then run tadforce doctor again');
|
|
269
|
-
|
|
270
|
-
// 2. Workspace (with the skills for the agents of this project)
|
|
271
|
-
if (!st.ws) {
|
|
272
|
-
await quietly(args.json, () => { ensureWorkspace(project, defaultConfig({ org: { alias: st.alias || null } })); installSkills(project, null); });
|
|
273
|
-
say(() => log.ok('Workspace created: tadforce/ (skills installed for your agents)'));
|
|
274
|
-
st = await evaluate(project, args);
|
|
275
|
-
}
|
|
276
|
-
const ctx = loadWorkspace(project);
|
|
277
|
-
ctx.cfg.setup = ctx.cfg.setup || {};
|
|
278
|
-
const hasLocal = (readJson(path.join(project, 'sfdx-project.json')).packageDirectories || []).some((d) => exists(path.join(project, d.path)) && fs.readdirSync(path.join(project, d.path), { recursive: true }).some((f) => String(f).endsWith('-meta.xml')));
|
|
279
|
-
await applyAnswers(ctx, args);
|
|
280
|
-
|
|
281
|
-
// 3. Org connection: reuse the project's target-org, log in only when needed.
|
|
282
|
-
if (ctx.cfg.source.mode !== 'local' && !st.status.ok) {
|
|
283
|
-
if (!rl) return finish(args, st, [], [], 'the human logs in: run `tadforce doctor` in a terminal (it opens the browser), or `sf org login web --alias <alias> --set-default`');
|
|
284
|
-
if (hasLocal && /^l/i.test((await rl.question(' No org connected. Log in now, or document the local sources only? [Login/local] ')).trim())) { ctx.cfg.source.mode = 'local'; ctx.save(ctx.cfg); continue; }
|
|
285
|
-
const def = st.alias || 'tadforce-org';
|
|
286
|
-
const alias = (await rl.question(` Org alias to log in [${def}]: `)).trim() || def;
|
|
287
|
-
const sandbox = /^(y|o|yes|oui)$/i.test((await rl.question(' Is it a sandbox? [y/N] ')).trim());
|
|
288
|
-
log.human('a browser window opens: log in to the org, then click « Allow ».');
|
|
289
|
-
if (sfInteractive(['org', 'login', 'web', '--alias', alias, '--set-default', ...(sandbox ? ['--instance-url', 'https://test.salesforce.com'] : [])])) { ctx.cfg.org.alias = alias; ctx.save(ctx.cfg); }
|
|
290
|
-
continue;
|
|
291
|
-
}
|
|
292
|
-
if (st.alias && ctx.cfg.org.alias !== st.alias && st.status.ok) { ctx.cfg.org.alias = st.alias; ctx.save(ctx.cfg); }
|
|
293
|
-
|
|
294
|
-
// 4. Framing questions (one batch, each with a recommendation)
|
|
295
|
-
const prop = st.status.ok ? await proposals(st.alias) : {};
|
|
296
|
-
const framing = framingQuestions(ctx.cfg, prop, st.status.ok ? st.alias : null, hasLocal);
|
|
297
|
-
if (framing.length) {
|
|
298
|
-
if (!rl) return finish(args, st, framing, [], 'ask the human these questions in one message, then run `tadforce doctor --json --confirm --<id> "<answer>"…`');
|
|
299
|
-
log.step('A few facts only you can confirm (Enter keeps the recommendation):');
|
|
300
|
-
const answers = {};
|
|
301
|
-
for (const q of framing) {
|
|
302
|
-
const a = (await rl.question(` ${q.question}${q.options ? ` (${q.options.join('/')})` : ''}${q.recommended ? ` [${q.recommended}]` : ''}: `)).trim();
|
|
303
|
-
answers[q.id] = a || q.recommended || (q.optional ? '' : undefined);
|
|
304
|
-
if (q.id === 'lang' && /^(other|autre)$/i.test(a)) answers.lang = (await rl.question(' Language code (es, de, it, pt, nl, ar, ja…): ')).trim() || 'en';
|
|
305
|
-
}
|
|
306
|
-
const flags = { confirm: true, specs: answers.specs || false };
|
|
307
|
-
for (const [k, v] of Object.entries(answers)) if (v !== undefined) flags[k === 'logo' ? 'logo' : k] = v === '' && ['integrator', 'author'].includes(k) ? 'none' : v === '' ? undefined : v;
|
|
308
|
-
await applyAnswers(loadWorkspace(project), flags);
|
|
309
|
-
continue;
|
|
310
|
-
}
|
|
487
|
+
// 1. Tools: TADForce installs what it may, the rest is a human action.
|
|
488
|
+
let st = await evaluate(start, args);
|
|
489
|
+
if (st.tools.find((t) => t.id === 'sf-cli' && !t.ok) && rl && yes((await rl.question(' Salesforce CLI missing. Install it now with npm? [Y/n] ')).trim())) {
|
|
490
|
+
spawnSync('npm', ['install', '--global', '@salesforce/cli'], { stdio: 'inherit', shell: process.platform === 'win32' });
|
|
491
|
+
st = await evaluate(start, args);
|
|
492
|
+
}
|
|
493
|
+
if (st.tools.some((t) => t.level === 'error' && t.id !== 'sfdx-project')) return finish(args, st, [], 'install the missing tools, then run tadforce doctor again');
|
|
311
494
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
495
|
+
// 2. The Salesforce project the TAD is about (confirmed, never created without a yes).
|
|
496
|
+
const p = await resolveProject(start, args, rl);
|
|
497
|
+
if (typeof p !== 'string') return p.stop ? finish(args, st, [p.stop], `ask the human, then run 'npx tadforce doctor --json --cwd "${start}" --project <yes|path|create>' (after a path, always pass --cwd <that path>)`) : 1;
|
|
498
|
+
const project = p;
|
|
499
|
+
if (project !== start) say(() => log.ok(`Project: ${project}`));
|
|
500
|
+
if (!findWorkspace(project)) {
|
|
501
|
+
await quietly(args.json, () => { ensureWorkspace(project, defaultConfig()); installSkills(project, null); });
|
|
502
|
+
say(() => log.ok('Workspace created: tadforce/ (skills installed for your agents)'));
|
|
503
|
+
}
|
|
504
|
+
const again = (flags) => `npx tadforce doctor --json --cwd "${project}" ${flags}`;
|
|
505
|
+
let ctx = loadWorkspace(project);
|
|
506
|
+
ctx.cfg.setup.projectConfirmed = true; ctx.save(ctx.cfg);
|
|
507
|
+
await applyAnswers(ctx, args);
|
|
508
|
+
ctx = loadWorkspace(project);
|
|
325
509
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
510
|
+
// 3. The org: the default one is proposed; « no » opens the login and asks again.
|
|
511
|
+
const o = await confirmOrg(project, ctx, args, rl);
|
|
512
|
+
if (!o.ok) return o.stop ? finish(args, await evaluate(project, args), [o.stop], `ask the human, then run '${again('--org <alias>')}' (or --source local)`) : 1;
|
|
513
|
+
ctx = loadWorkspace(project);
|
|
514
|
+
|
|
515
|
+
// 4. Framing: 6 questions, each with a recommended answer; « accept all » in one keystroke.
|
|
516
|
+
const prop = !ctx.cfg.setup.confirmed && ctx.cfg.org.alias && ctx.cfg.source.mode !== 'local' ? await proposals(ctx.cfg.org.alias) : {};
|
|
517
|
+
const framing = framingQuestions(ctx.cfg, prop);
|
|
518
|
+
if (framing.length) {
|
|
519
|
+
if (!rl) return finish(args, await evaluate(project, args), framing, `ask the human in one message (recommended answers shown; « accept all » is fine), then run '${again('--confirm --lang … --client "…" --integrator …|none --author …|none [--specs …|--no-specs] [--scope …]')}'`);
|
|
520
|
+
const a = await askBatch(rl, 'A few facts only you can confirm:', framing);
|
|
521
|
+
await applyAnswers(loadWorkspace(project), {
|
|
522
|
+
confirm: true, lang: a.lang, client: a.client || undefined,
|
|
523
|
+
integrator: a.integrator || 'none', author: a.author || 'none',
|
|
524
|
+
specs: a.specs || false, scope: a.scope || undefined,
|
|
525
|
+
});
|
|
526
|
+
ctx = loadWorkspace(project);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// 5. Metadata: org, local or retrieved by the user — and a go before a long retrieve.
|
|
530
|
+
if (!exists(ctx.p('facts', 'facts.json')) || args.refresh) {
|
|
531
|
+
const s = await chooseSource(project, ctx, args, rl);
|
|
532
|
+
if (!s.mode) return s.stop ? finish(args, await evaluate(project, args), [s.stop], `ask the human; the answer is the go: '${again('--source <one of the options: ' + (s.stop.options || []).map((o) => o.split(':')[0]).join('|') + '>')}'`) : 1;
|
|
533
|
+
// doctor's --source (org|local|self) is not facts' --source <folder>: never pass it on
|
|
534
|
+
const { source: _source, project: _project, ...rest } = args;
|
|
535
|
+
const a = { ...rest, json: false, cwd: project, org: s.mode === 'local' ? false : ctx.cfg.org.alias };
|
|
536
|
+
if (s.mode !== 'local') {
|
|
537
|
+
say(() => log.step('Retrieving the metadata from the org (read-only, into tadforce/.snapshot)…'));
|
|
538
|
+
for (const fn of [org.manifest, org.retrieve]) { const code = await quietly(args.json, () => fn(a), true); if (code) return finish(args, await evaluate(project, args), [], 'the retrieve failed: see the messages above'); }
|
|
539
|
+
}
|
|
540
|
+
await quietly(args.json, () => docs.facts(a), true);
|
|
541
|
+
ctx = loadWorkspace(project);
|
|
542
|
+
const found = (readJson(ctx.p('facts', 'facts.json')).inventory || []).length;
|
|
543
|
+
if (!found) {
|
|
544
|
+
// an empty inventory would give an empty document: never report it as green
|
|
545
|
+
fs.rmSync(ctx.p('facts', 'facts.json'), { force: true });
|
|
546
|
+
ctx.cfg.setup.sourceGo = false; ctx.save(ctx.cfg);
|
|
547
|
+
const dirs = sourceDirs(ctx).map((d) => path.relative(project, d) || '.');
|
|
548
|
+
return finish(args, await evaluate(project, args), [], `no metadata found in ${dirs.join(', ') || 'this project'}: retrieve it (source org) or put the sources in the package directories, then run doctor again`);
|
|
338
549
|
}
|
|
550
|
+
await quietly(args.json, () => docs.scaffold(a));
|
|
551
|
+
}
|
|
339
552
|
|
|
340
|
-
|
|
341
|
-
|
|
553
|
+
// 6. Branding (logo, colours) and what the metadata cannot tell — recommended answers again.
|
|
554
|
+
const b = await confirmBranding(ctx, args, rl, say);
|
|
555
|
+
if (b) return finish(args, await evaluate(project, args), [b], `ask the human, then run '${again('--branding keep')}' (or --logo <file|url>)`);
|
|
556
|
+
const fq = factQuestions(loadWorkspace(project));
|
|
557
|
+
if (fq.length) {
|
|
558
|
+
if (!rl) return finish(args, await evaluate(project, args), fq, `ask the human in one message (recommended answers shown), write the answers to the knowledge files, then run '${again('--facts-reviewed')}' (it recomputes the facts)`);
|
|
559
|
+
const changed = await askFacts(loadWorkspace(project), rl, fq);
|
|
560
|
+
const c2 = loadWorkspace(project); c2.cfg.setup.factsReviewed = true; c2.save(c2.cfg);
|
|
561
|
+
if (changed) await quietly(args.json, () => docs.facts({ cwd: project, org: false }));
|
|
562
|
+
}
|
|
563
|
+
// answers written to the knowledge files (systems, exclusions) change the facts: recompute once
|
|
564
|
+
const c3 = loadWorkspace(project);
|
|
565
|
+
if (c3.cfg.setup.factsDirty) {
|
|
566
|
+
await quietly(args.json, () => docs.facts({ cwd: project, org: false }));
|
|
567
|
+
const c4 = loadWorkspace(project); delete c4.cfg.setup.factsDirty; c4.save(c4.cfg);
|
|
342
568
|
}
|
|
343
|
-
|
|
344
|
-
|
|
569
|
+
|
|
570
|
+
// 7. All green: say it, and offer to start the writing.
|
|
571
|
+
const last = await evaluate(project, args);
|
|
572
|
+
const translate = last.list.some((x) => x.id === 'language') ? 'first translate once (check « language »), then ' : '';
|
|
573
|
+
const code = finish(args, last, [], null, `${translate}write the sections: 'npx tadforce next --json --cwd "${project}"' (loop), then 'npx tadforce build --cwd "${project}"'`);
|
|
574
|
+
// CI, agents (--json) and agent sessions stop here; a person in a terminal is offered the writing.
|
|
575
|
+
if (code || args.json || args.launch === false || !process.stdin.isTTY || insideAgent()) return code;
|
|
576
|
+
return handOver(project, args, rl);
|
|
345
577
|
} finally {
|
|
346
578
|
if (rl) rl.close();
|
|
347
579
|
}
|
|
348
580
|
}
|
|
349
581
|
|
|
350
|
-
|
|
582
|
+
/**
|
|
583
|
+
* Everything is green: offer to start the AI agent on the writing, in this terminal. The agent is chosen
|
|
584
|
+
* once (--agent claude|codex|gemini|cursor) and remembered; --no-launch stops at the prerequisites.
|
|
585
|
+
*/
|
|
586
|
+
async function handOver(project, args, rl) {
|
|
587
|
+
const ctx = loadWorkspace(project);
|
|
588
|
+
const found = installedAgents();
|
|
589
|
+
const wanted = typeof args.agent === 'string' ? args.agent : ctx.cfg.setup.agent;
|
|
590
|
+
let agent = found.find((a) => a.id === wanted) || (wanted ? null : found[0]);
|
|
591
|
+
if (!agent) {
|
|
592
|
+
log.human(found.length ? `agent "${wanted}" not found; installed: ${found.map((a) => a.id).join(', ')} (--agent <id>).` : 'open your AI agent (Claude Code, Codex, Cursor, Copilot…) in this folder and type /tadforce to write the document.');
|
|
593
|
+
return 0;
|
|
594
|
+
}
|
|
595
|
+
if (rl) {
|
|
596
|
+
if (!yes((await rl.question(` Launch the writing of the document now with ${agent.name}? [Y/n] `)).trim())) {
|
|
597
|
+
log.info(`Later: open your agent in ${project} and type /tadforce.`);
|
|
598
|
+
rl.close(); return 0;
|
|
599
|
+
}
|
|
600
|
+
if (found.length > 1 && !wanted) {
|
|
601
|
+
const pick = (await rl.question(` Agent (${found.map((a) => a.id).join('/')}) [${agent.id}]: `)).trim();
|
|
602
|
+
agent = found.find((a) => a.id === pick) || agent;
|
|
603
|
+
}
|
|
604
|
+
rl.close();
|
|
605
|
+
}
|
|
606
|
+
ctx.cfg.setup.agent = agent.id; ctx.save(ctx.cfg);
|
|
607
|
+
log.step(`Handing over to ${agent.name}: it writes the document, then builds and validates it…`);
|
|
608
|
+
return launchAgent(agent, project);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** In --json mode stdout carries only the answer: messages are dropped, or sent to stderr as progress. */
|
|
612
|
+
async function quietly(json, fn, progress = false) {
|
|
351
613
|
if (!json) return fn();
|
|
352
|
-
const orig = console.log;
|
|
353
|
-
|
|
614
|
+
const orig = console.log;
|
|
615
|
+
console.log = progress ? (...a) => process.stderr.write(a.join(' ') + '\n') : () => {};
|
|
616
|
+
if (progress) setQuiet(false);
|
|
617
|
+
try { return await fn(); } finally { console.log = orig; if (progress) setQuiet(true); }
|
|
354
618
|
}
|
|
355
619
|
|
|
356
|
-
function finish(args, st,
|
|
620
|
+
function finish(args, st, questions, next, after = 'npx tadforce build') {
|
|
357
621
|
const ok = !next && st.list.every((x) => x.level !== 'error');
|
|
358
622
|
if (args.json) {
|
|
359
|
-
console.log(JSON.stringify({ ok, checks: st.list, questions
|
|
623
|
+
console.log(JSON.stringify({ ok, checks: st.list, questions, next: next || after }, null, 2));
|
|
360
624
|
return ok ? 0 : 1;
|
|
361
625
|
}
|
|
362
626
|
for (const x of st.list) {
|