tadforce 0.4.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 +1 -1
- package/package.json +1 -1
- package/skills/tadforce/references/workflow.md +6 -5
- 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 +134 -38
- 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/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,15 +1,16 @@
|
|
|
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';
|
|
@@ -37,28 +38,67 @@ export async function checks(cwd) {
|
|
|
37
38
|
|
|
38
39
|
const sfdx = path.join(cwd, 'sfdx-project.json');
|
|
39
40
|
const hasProject = exists(sfdx);
|
|
40
|
-
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');
|
|
41
42
|
return out;
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
/** The org
|
|
45
|
-
async function
|
|
46
|
-
if (typeof args.org === 'string') return args.org;
|
|
47
|
-
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) {
|
|
48
47
|
try {
|
|
49
48
|
const r = await sfJson(['config', 'get', 'target-org'], { cwd: project, timeoutMs: 60000 });
|
|
50
|
-
const
|
|
51
|
-
return
|
|
49
|
+
const e = Array.isArray(r) && r[0];
|
|
50
|
+
return e && e.value ? { alias: e.value, global: /global/i.test(e.location || '') } : null;
|
|
52
51
|
} catch { return null; }
|
|
53
52
|
}
|
|
54
53
|
|
|
54
|
+
// One check per org and per run: every doctor step reuses it.
|
|
55
|
+
const statusCache = new Map();
|
|
55
56
|
async function orgStatus(alias) {
|
|
56
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) {
|
|
57
76
|
try {
|
|
58
77
|
const d = await sfJson(['org', 'display', '--target-org', alias], { timeoutMs: 120000 });
|
|
59
|
-
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 };
|
|
60
79
|
return { ok: true, username: d.username, instanceUrl: d.instanceUrl, sandbox: /test\.salesforce|sandbox|\.cs\d+\./i.test(d.instanceUrl || '') };
|
|
61
|
-
} 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
|
+
}
|
|
62
102
|
}
|
|
63
103
|
|
|
64
104
|
/** Org facts that give a recommended answer to the framing questions. */
|
|
@@ -73,7 +113,7 @@ async function proposals(alias) {
|
|
|
73
113
|
|
|
74
114
|
/** Short code for file names: initials of a multi-word name (Northwind Bank → NB), else the name cut to 8. */
|
|
75
115
|
export function shortCode(name) {
|
|
76
|
-
const words = name.normalize('NFD').replace(/
|
|
116
|
+
const words = name.normalize('NFD').replace(/\p{Diacritic}/gu, '').split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
77
117
|
return (words.length > 1 ? words.map((w) => w[0]).join('') : words[0] || 'CLIENT').slice(0, 8).toUpperCase();
|
|
78
118
|
}
|
|
79
119
|
|
|
@@ -109,7 +149,7 @@ async function applyAnswers(ctx, args) {
|
|
|
109
149
|
}
|
|
110
150
|
if (args.specs !== undefined) cfg.setup.specsAsked = true; // --no-specs: none provided
|
|
111
151
|
if (args.confirm || args.yes) cfg.setup.confirmed = true;
|
|
112
|
-
if (args.factsReviewed) cfg.setup.factsReviewed = true;
|
|
152
|
+
if (args.factsReviewed) { cfg.setup.factsReviewed = true; cfg.setup.factsDirty = true; }
|
|
113
153
|
if (args.branding === 'keep') cfg.setup.brandingConfirmed = true;
|
|
114
154
|
if (cfg.document.reference === 'TAD-CLIENT-CRM' && cfg.client.short !== 'CLIENT') cfg.document.reference = `TAD-${cfg.client.short}-CRM`;
|
|
115
155
|
ctx.save(cfg);
|
|
@@ -300,24 +340,39 @@ async function confirmOrg(project, ctx, args, rl) {
|
|
|
300
340
|
const cfg = ctx.cfg;
|
|
301
341
|
if (cfg.source.mode === 'local') return { ok: true };
|
|
302
342
|
const explicit = typeof args.org === 'string' ? args.org : null;
|
|
303
|
-
|
|
343
|
+
const def = explicit || cfg.setup.orgConfirmed ? null : await defaultOrg(project);
|
|
344
|
+
let alias = explicit || (cfg.setup.orgConfirmed ? cfg.org.alias : def && def.alias);
|
|
304
345
|
let status = alias ? await orgStatus(alias) : { ok: false, reason: 'none' };
|
|
305
346
|
const keep = () => { cfg.org.alias = alias; cfg.setup.orgConfirmed = true; ctx.save(cfg); return { ok: true }; };
|
|
306
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';
|
|
307
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);
|
|
308
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>';
|
|
309
357
|
return { stop: alias && status.ok
|
|
310
|
-
? { id: 'org', question:
|
|
311
|
-
: { id: 'org', question: alias ? `The org "${alias}"
|
|
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 } };
|
|
312
360
|
}
|
|
313
361
|
for (let attempt = 0; attempt < 4; attempt++) {
|
|
314
362
|
if (alias && status.ok) {
|
|
315
|
-
const a = (await rl.question(` ${attempt ? 'Connected' :
|
|
363
|
+
const a = (await rl.question(` ${attempt ? 'Connected' : label}: ${describe()}\n Generate the TAD from this org? [Y/n/l = local sources only] `)).trim();
|
|
316
364
|
if (yes(a)) return keep();
|
|
317
365
|
if (/^l/i.test(a)) { cfg.source.mode = 'local'; ctx.save(cfg); return { ok: true }; }
|
|
318
|
-
} else if (alias) console.log(` ${c.yellow('⚠')} The org "${alias}"
|
|
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
|
+
}
|
|
319
374
|
// « no », or no usable org: open the Salesforce CLI login and wait for it.
|
|
320
|
-
const def = alias &&
|
|
375
|
+
const def = alias && status.reason === 'expired' ? alias : 'tad-org';
|
|
321
376
|
const next = (await rl.question(` Alias for the org to connect [${def}]: `)).trim() || def;
|
|
322
377
|
const sandbox = yes((await rl.question(' Is it a sandbox? [y/N] ')).trim(), false);
|
|
323
378
|
log.human('a browser window opens: log in to the org, then click « Allow ». Waiting for the login…');
|
|
@@ -396,7 +451,26 @@ async function evaluate(project, args) {
|
|
|
396
451
|
facts = exists(ctx.p('facts', 'facts.json'));
|
|
397
452
|
const lang = normalizeLang(ctx.cfg.language);
|
|
398
453
|
const own = ctx.p('i18n', `${lang}.json`);
|
|
399
|
-
if (!LANGUAGES.includes(lang)
|
|
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');
|
|
400
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`);
|
|
401
475
|
}
|
|
402
476
|
return { tools, ws, cfg, list, facts };
|
|
@@ -420,13 +494,14 @@ export async function run(args) {
|
|
|
420
494
|
|
|
421
495
|
// 2. The Salesforce project the TAD is about (confirmed, never created without a yes).
|
|
422
496
|
const p = await resolveProject(start, args, rl);
|
|
423
|
-
if (typeof p !== 'string') return p.stop ? finish(args, st, [p.stop],
|
|
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;
|
|
424
498
|
const project = p;
|
|
425
499
|
if (project !== start) say(() => log.ok(`Project: ${project}`));
|
|
426
500
|
if (!findWorkspace(project)) {
|
|
427
501
|
await quietly(args.json, () => { ensureWorkspace(project, defaultConfig()); installSkills(project, null); });
|
|
428
502
|
say(() => log.ok('Workspace created: tadforce/ (skills installed for your agents)'));
|
|
429
503
|
}
|
|
504
|
+
const again = (flags) => `npx tadforce doctor --json --cwd "${project}" ${flags}`;
|
|
430
505
|
let ctx = loadWorkspace(project);
|
|
431
506
|
ctx.cfg.setup.projectConfirmed = true; ctx.save(ctx.cfg);
|
|
432
507
|
await applyAnswers(ctx, args);
|
|
@@ -434,14 +509,14 @@ export async function run(args) {
|
|
|
434
509
|
|
|
435
510
|
// 3. The org: the default one is proposed; « no » opens the login and asks again.
|
|
436
511
|
const o = await confirmOrg(project, ctx, args, rl);
|
|
437
|
-
if (!o.ok) return o.stop ? finish(args, await evaluate(project, args), [o.stop],
|
|
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;
|
|
438
513
|
ctx = loadWorkspace(project);
|
|
439
514
|
|
|
440
515
|
// 4. Framing: 6 questions, each with a recommended answer; « accept all » in one keystroke.
|
|
441
|
-
const prop = ctx.cfg.org.alias && ctx.cfg.source.mode !== 'local' ? await proposals(ctx.cfg.org.alias) : {};
|
|
516
|
+
const prop = !ctx.cfg.setup.confirmed && ctx.cfg.org.alias && ctx.cfg.source.mode !== 'local' ? await proposals(ctx.cfg.org.alias) : {};
|
|
442
517
|
const framing = framingQuestions(ctx.cfg, prop);
|
|
443
518
|
if (framing.length) {
|
|
444
|
-
if (!rl) return finish(args, await evaluate(project, args), framing,
|
|
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 …]')}'`);
|
|
445
520
|
const a = await askBatch(rl, 'A few facts only you can confirm:', framing);
|
|
446
521
|
await applyAnswers(loadWorkspace(project), {
|
|
447
522
|
confirm: true, lang: a.lang, client: a.client || undefined,
|
|
@@ -454,30 +529,48 @@ export async function run(args) {
|
|
|
454
529
|
// 5. Metadata: org, local or retrieved by the user — and a go before a long retrieve.
|
|
455
530
|
if (!exists(ctx.p('facts', 'facts.json')) || args.refresh) {
|
|
456
531
|
const s = await chooseSource(project, ctx, args, rl);
|
|
457
|
-
if (!s.mode) return s.stop ? finish(args, await evaluate(project, args), [s.stop],
|
|
458
|
-
|
|
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 };
|
|
459
536
|
if (s.mode !== 'local') {
|
|
460
537
|
say(() => log.step('Retrieving the metadata from the org (read-only, into tadforce/.snapshot)…'));
|
|
461
|
-
for (const fn of [org.manifest, org.retrieve]) { const code = await quietly(args.json, () => fn(a)); if (code) return finish(args, await evaluate(project, args), [], 'the retrieve failed: see the messages above'); }
|
|
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'); }
|
|
462
539
|
}
|
|
463
|
-
await quietly(args.json, () => docs.facts(a));
|
|
464
|
-
await quietly(args.json, () => docs.scaffold(a));
|
|
540
|
+
await quietly(args.json, () => docs.facts(a), true);
|
|
465
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`);
|
|
549
|
+
}
|
|
550
|
+
await quietly(args.json, () => docs.scaffold(a));
|
|
466
551
|
}
|
|
467
552
|
|
|
468
553
|
// 6. Branding (logo, colours) and what the metadata cannot tell — recommended answers again.
|
|
469
554
|
const b = await confirmBranding(ctx, args, rl, say);
|
|
470
|
-
if (b) return finish(args, await evaluate(project, args), [b],
|
|
555
|
+
if (b) return finish(args, await evaluate(project, args), [b], `ask the human, then run '${again('--branding keep')}' (or --logo <file|url>)`);
|
|
471
556
|
const fq = factQuestions(loadWorkspace(project));
|
|
472
557
|
if (fq.length) {
|
|
473
|
-
if (!rl) return finish(args, await evaluate(project, args), fq,
|
|
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)`);
|
|
474
559
|
const changed = await askFacts(loadWorkspace(project), rl, fq);
|
|
475
560
|
const c2 = loadWorkspace(project); c2.cfg.setup.factsReviewed = true; c2.save(c2.cfg);
|
|
476
|
-
if (changed) await quietly(args.json, () => docs.facts({ cwd: project, org:
|
|
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);
|
|
477
568
|
}
|
|
478
569
|
|
|
479
570
|
// 7. All green: say it, and offer to start the writing.
|
|
480
|
-
const
|
|
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}"'`);
|
|
481
574
|
// CI, agents (--json) and agent sessions stop here; a person in a terminal is offered the writing.
|
|
482
575
|
if (code || args.json || args.launch === false || !process.stdin.isTTY || insideAgent()) return code;
|
|
483
576
|
return handOver(project, args, rl);
|
|
@@ -515,16 +608,19 @@ async function handOver(project, args, rl) {
|
|
|
515
608
|
return launchAgent(agent, project);
|
|
516
609
|
}
|
|
517
610
|
|
|
518
|
-
|
|
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) {
|
|
519
613
|
if (!json) return fn();
|
|
520
|
-
const orig = console.log;
|
|
521
|
-
|
|
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); }
|
|
522
618
|
}
|
|
523
619
|
|
|
524
|
-
function finish(args, st, questions, next) {
|
|
620
|
+
function finish(args, st, questions, next, after = 'npx tadforce build') {
|
|
525
621
|
const ok = !next && st.list.every((x) => x.level !== 'error');
|
|
526
622
|
if (args.json) {
|
|
527
|
-
console.log(JSON.stringify({ ok, checks: st.list, questions, next: next ||
|
|
623
|
+
console.log(JSON.stringify({ ok, checks: st.list, questions, next: next || after }, null, 2));
|
|
528
624
|
return ok ? 0 : 1;
|
|
529
625
|
}
|
|
530
626
|
for (const x of st.list) {
|
package/src/commands/org.js
CHANGED
|
@@ -124,28 +124,92 @@ export async function retrieve(args) {
|
|
|
124
124
|
const size = Number(args.batchSize || 12);
|
|
125
125
|
const outDir = ctx.p(ctx.cfg.source.dir);
|
|
126
126
|
fs.mkdirSync(outDir, { recursive: true });
|
|
127
|
+
// the CLI refuses to retrieve when a package directory of the project is missing
|
|
128
|
+
for (const d of (exists(path.join(ctx.project, 'sfdx-project.json')) ? JSON.parse(readText(path.join(ctx.project, 'sfdx-project.json'))).packageDirectories || [] : [])) fs.mkdirSync(path.join(ctx.project, d.path), { recursive: true });
|
|
127
129
|
log.banner('retrieve');
|
|
128
130
|
const failed = [];
|
|
129
|
-
const
|
|
131
|
+
const nameOf = (b) => /<name>([^<]+)<\/name>/.exec(b)[1];
|
|
132
|
+
const typeXml = (name, members) => ` <types>\n${members.map((m) => ` <members>${m}</members>`).join('\n')}\n <name>${name}</name>\n </types>`;
|
|
133
|
+
const runBatch = async (batch, label, minutes) => {
|
|
130
134
|
const p = ctx.p('.snapshot', 'batch.xml');
|
|
131
135
|
writeText(p, `<?xml version="1.0" encoding="UTF-8"?>\n<Package xmlns="http://soap.sforce.com/2006/04/metadata">\n${batch.join('\n')}\n <version>${version}</version>\n</Package>\n`);
|
|
132
|
-
await sfJson(['project', 'retrieve', 'start', '--manifest', p, '--target-org', alias, '--output-dir', outDir, '--wait', String(
|
|
133
|
-
log.ok(label);
|
|
136
|
+
await sfJson(['project', 'retrieve', 'start', '--manifest', p, '--target-org', alias, '--output-dir', outDir, '--wait', String(minutes), '--ignore-conflicts'], { timeoutMs: (minutes + 2) * 60000, cwd: ctx.project });
|
|
137
|
+
if (label) log.ok(label);
|
|
134
138
|
};
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
139
|
+
// the CLI stopped waiting (« The client has timed out »): the job was too big for one retrieve
|
|
140
|
+
const timedOut = (e) => /timed out|MetadataTransferError/i.test(`${e.message} ${e.sf && e.sf.name}`);
|
|
141
|
+
// an error about the project itself fails every type the same way: stop at once, do not retry type by type
|
|
142
|
+
const projectError = (e) => /MissingPackageDirectory|InvalidProjectWorkspace|specified in sfdx-project\.json, does not exist|sfdx-project\.json/i.test(`${e.message} ${e.sf && e.sf.name}`);
|
|
143
|
+
// types the local Salesforce CLI does not know fail at once and never succeed: dropped, not retried
|
|
144
|
+
const unknownType = (e) => (/registry for id '([^']+)'|Missing metadata type definition[^']*'([^']+)'/.exec(e.message) || []).slice(1).find(Boolean);
|
|
145
|
+
// The types the document is built on come first, one by one, with up to 60 minutes each (--wait can only
|
|
146
|
+
// lengthen it). Too big for one retrieve (huge org, 39 MB / 10,000 files limit): its components are listed
|
|
147
|
+
// and retrieved by chunks of 100. The other types go by batches of 12 (30 minutes), then one by one (10 minutes).
|
|
148
|
+
const CORE = ['ApexClass', 'ApexTrigger', 'CustomObject', 'Flow', 'LightningComponentBundle', 'AuraDefinitionBundle', 'Profile', 'PermissionSet', 'PermissionSetGroup', 'FlexiPage', 'CustomApplication', 'CustomMetadata', 'OmniScript', 'OmniIntegrationProcedure', 'OmniDataTransform', 'OmniUiCard'];
|
|
149
|
+
// --core-wait shortens the first try (tests, impatient runs); the chunks always get the full 60 minutes
|
|
150
|
+
if (args.coreWait !== undefined && !/^\d+$/.test(String(args.coreWait))) throw new Error('--core-wait takes a number of minutes (0 or more).');
|
|
151
|
+
const coreWait = args.coreWait !== undefined ? Number(args.coreWait) : 60;
|
|
152
|
+
const chunkWait = 60;
|
|
153
|
+
const restWait = Math.min(Number(args.wait || 30), 30);
|
|
154
|
+
const byChunks = async (b, step) => {
|
|
155
|
+
const name = nameOf(b);
|
|
156
|
+
let listed;
|
|
157
|
+
try { listed = await sfJson(['org', 'list', 'metadata', '--metadata-type', name, '--target-org', alias], { timeoutMs: 600000 }); } catch (e) {
|
|
158
|
+
// a product this org does not have (OmniStudio, …): nothing to retrieve, as `*` would say
|
|
159
|
+
if (/INVALID_TYPE|Cannot use/i.test(e.message)) { log.info(`${step} ${name}: not used in this org`); return; }
|
|
160
|
+
throw e;
|
|
161
|
+
}
|
|
162
|
+
const explicit = [...b.matchAll(/<members>([^<]+)<\/members>/g)].map((m) => m[1]).filter((m) => m !== '*');
|
|
163
|
+
// the same components as `*`: everything but what installed packages bring (standard__ apps, force__ groups stay)
|
|
164
|
+
const own = (Array.isArray(listed) ? listed : []).filter((x) => !/^(installed|deprecated)/i.test(x.manageableState || ''));
|
|
165
|
+
const members = [...new Set([...own.map((x) => x.fullName), ...explicit])].sort();
|
|
166
|
+
if (!members.length) {
|
|
167
|
+
log.info(`${step} ${name}: nothing listed — one more try with the full wait`);
|
|
168
|
+
return runBatch([b], `${step} ${name}`, chunkWait);
|
|
169
|
+
}
|
|
170
|
+
log.info(`${step} ${name}: did not finish in ${coreWait} min — ${members.length} component(s), retrieved by chunks of 100`);
|
|
171
|
+
for (let i = 0; i < members.length; i += 100) await runBatch([typeXml(name, members.slice(i, i + 100))], `${step} ${name} ${Math.min(i + 100, members.length)}/${members.length}`, chunkWait);
|
|
172
|
+
};
|
|
173
|
+
const core = types.filter((b) => CORE.includes(nameOf(b)));
|
|
174
|
+
const rest = types.filter((b) => !CORE.includes(nameOf(b)));
|
|
175
|
+
const total = core.length + Math.ceil(rest.length / size);
|
|
176
|
+
let n = 0;
|
|
177
|
+
for (const b of core) {
|
|
178
|
+
const step = `[${++n}/${total}]`;
|
|
138
179
|
try {
|
|
139
|
-
await runBatch(
|
|
180
|
+
try { await runBatch([b], `${step} ${nameOf(b)}`, coreWait); } catch (e) { if (!timedOut(e)) throw e; await byChunks(b, step); }
|
|
140
181
|
} catch (e) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
182
|
+
if (projectError(e)) throw new Error(`The Salesforce DX project cannot be used (${e.message.slice(0, 200)}). Fix sfdx-project.json or its package directories, then retrieve again.`);
|
|
183
|
+
const why = unknownType(e) ? 'not supported by this Salesforce CLI version' : e.message.slice(0, 300);
|
|
184
|
+
failed.push({ type: nameOf(b), error: why, core: true });
|
|
185
|
+
log.warn(`${step} ${nameOf(b)}: NOT retrieved (${why.slice(0, 120)}) — the document needs it`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (let i = 0; i < rest.length; i += size) {
|
|
189
|
+
let batch = rest.slice(i, i + size);
|
|
190
|
+
const step = `[${++n}/${total}]`;
|
|
191
|
+
for (let tries = 0; batch.length && tries < 3; tries++) {
|
|
192
|
+
try { await runBatch(batch, `${step} ${batch.map(nameOf).join(', ')}`, restWait); batch = []; } catch (e) {
|
|
193
|
+
if (projectError(e)) throw new Error(`The Salesforce DX project cannot be used (${e.message.slice(0, 200)}). Fix sfdx-project.json or its package directories, then retrieve again.`);
|
|
194
|
+
const bad = unknownType(e);
|
|
195
|
+
if (bad && batch.some((b) => nameOf(b) === bad)) {
|
|
196
|
+
failed.push({ type: bad, error: 'not supported by this Salesforce CLI version' });
|
|
197
|
+
log.warn(`${bad}: skipped (not supported by this Salesforce CLI version)`);
|
|
198
|
+
batch = batch.filter((b) => nameOf(b) !== bad);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
log.warn(`${step} batch failed (${e.message.slice(0, 160)}) — retrying type by type`);
|
|
202
|
+
for (const b of batch) {
|
|
203
|
+
try { await runBatch([b], `${step} ${nameOf(b)}`, Math.min(10, restWait)); } catch (e2) { failed.push({ type: nameOf(b), error: e2.message.slice(0, 300) }); log.warn(`${nameOf(b)}: skipped (${e2.message.slice(0, 120)})`); }
|
|
204
|
+
}
|
|
205
|
+
batch = [];
|
|
145
206
|
}
|
|
146
207
|
}
|
|
147
208
|
}
|
|
148
209
|
writeJson(ctx.p('.snapshot', 'retrieve-report.json'), { failed });
|
|
210
|
+
// a type the document is built on is missing: not a ready snapshot
|
|
211
|
+
const lost = failed.filter((x) => x.core).map((x) => x.type);
|
|
212
|
+
if (lost.length) { log.fail(`Not retrieved, and the document needs them: ${lost.join(', ')}. Run the retrieve again (tadforce doctor --refresh).`); return 1; }
|
|
149
213
|
log.ok(`Snapshot ready in ${path.relative(ctx.project, outDir)}${failed.length ? ` — ${failed.length} type(s) could not be retrieved (see .snapshot/retrieve-report.json)` : ''}.`);
|
|
150
214
|
return 0;
|
|
151
215
|
}
|
package/src/commands/spec.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import YAML from 'yaml';
|
|
5
|
-
import { exists, readText, readYaml } from '../util/fsx.js';
|
|
6
|
-
import { loadWorkspace } from '../util/config.js';
|
|
5
|
+
import { exists, readText, readYaml, walk, toPosix } from '../util/fsx.js';
|
|
6
|
+
import { loadWorkspace, sourceFlags } from '../util/config.js';
|
|
7
7
|
import { TEMPLATES } from '../util/paths.js';
|
|
8
8
|
import { loadFacts } from '../facts/index.js';
|
|
9
9
|
import { resolveConditionals, get } from '../build/directives.js';
|
|
@@ -33,13 +33,19 @@ export function specWithFacts(ctx, id) {
|
|
|
33
33
|
const out = { id, ...s, factsExcerpt: {} };
|
|
34
34
|
for (const p of s.facts || []) {
|
|
35
35
|
if (p.startsWith('knowledge/')) {
|
|
36
|
+
// every file, sub-folders included (knowledge/inputs/specs/SFD.pdf), README excepted
|
|
36
37
|
const dir = ctx.p(p);
|
|
37
|
-
out.factsExcerpt[p] = exists(dir) ?
|
|
38
|
+
out.factsExcerpt[p] = exists(dir) ? walk(dir).map((x) => toPosix(path.relative(dir, x))).filter((x) => x !== 'README.md') : [];
|
|
38
39
|
continue;
|
|
39
40
|
}
|
|
40
41
|
const v = get(facts, p) ?? get(facts.counts || {}, p);
|
|
41
42
|
if (v !== undefined) out.factsExcerpt[p] = excerpt(v);
|
|
42
43
|
}
|
|
44
|
+
// Functional specifications given by the project: evidence P2/P3 for the chapters that explain the why.
|
|
45
|
+
const projectSpecs = (ctx.cfg.inputs && ctx.cfg.inputs.specs) || [];
|
|
46
|
+
if (projectSpecs.length && /^(summary|purpose|context|functional|general)\./.test(id)) {
|
|
47
|
+
out.specifications = { read: projectSpecs.map((p) => toPosix(path.join('tadforce', p))), how: 'Read them before writing: they explain the purpose, the processes and the vocabulary. Cite them (« according to the specifications (DR-nn) ») — the metadata still decides what is actually built.' };
|
|
48
|
+
}
|
|
43
49
|
if (s.modules) {
|
|
44
50
|
const refs = s.modules.includes('all detected') ? (facts.modules && facts.modules.references) || [] : s.modules.filter((m) => (facts.modules && facts.modules.references || []).includes(m) || s.modules.length === 1);
|
|
45
51
|
out.readModules = refs.map((r) => `skills/tadforce/references/modules/${r}.md`);
|
|
@@ -57,18 +63,25 @@ export async function spec(args) {
|
|
|
57
63
|
return 0;
|
|
58
64
|
}
|
|
59
65
|
|
|
60
|
-
|
|
66
|
+
// The order that works: the facts-heavy chapters first, the summary last (it is written from all the others).
|
|
67
|
+
const ORDER = { tad: ['00', '06', '07', '08', '09', '10', '11', '12', '13', '05', '04', '03', '14', '15', '99', '02', '01'], axes: ['00', '03', '04', '05', '02', '99', '01'] };
|
|
68
|
+
export function writingOrder(doc, files) {
|
|
69
|
+
const rank = (f) => { const i = (ORDER[doc] || []).indexOf(f.slice(0, 2)); return i < 0 ? 50 : i; };
|
|
70
|
+
return [...files].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** First section still to write (in the recommended order), among the sections active for this org. */
|
|
61
74
|
export async function next(args) {
|
|
62
75
|
const ctx = loadWorkspace(args.cwd || process.cwd());
|
|
63
76
|
const facts = loadFacts(ctx.ws);
|
|
64
77
|
if (!facts) { log.fail('Run `tadforce facts` first.'); return 1; }
|
|
65
|
-
const scope = { modules: facts.modules, counts: facts.counts, org: facts.org, cfg: ctx.cfg, facts, glossary: ctx.glossary() };
|
|
78
|
+
const scope = { modules: facts.modules, counts: facts.counts, org: facts.org, cfg: ctx.cfg, facts, glossary: ctx.glossary(), source: sourceFlags(ctx.cfg) };
|
|
66
79
|
const docs = !args.doc || args.doc === 'all' ? DOCS : [args.doc];
|
|
67
80
|
let remaining = 0, first = null;
|
|
68
81
|
for (const doc of docs) {
|
|
69
82
|
const dir = ctx.p('src', doc);
|
|
70
83
|
if (!exists(dir)) continue;
|
|
71
|
-
for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.md'))
|
|
84
|
+
for (const f of writingOrder(doc, fs.readdirSync(dir).filter((x) => x.endsWith('.md')))) {
|
|
72
85
|
const raw = readText(path.join(dir, f));
|
|
73
86
|
const active = resolveConditionals(raw, scope);
|
|
74
87
|
for (const m of active.matchAll(/<!--\s*tad:pending\s+([\w.-]+)\s*-->/g)) {
|