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.
@@ -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 : 'tadforce doctor creates a minimal SFDX project here', false, hasProject ? 'ok' : 'warn');
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 to document: --org, the workspace, then the project's target-org (sf config). */
45
- async function resolveAlias(project, args, cfg) {
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 v = Array.isArray(r) && r[0] && r[0].value;
51
- return v || null;
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) { return { ok: false, reason: 'expired', detail: e.message.slice(0, 160) }; }
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(/[̀-ͯ]/g, '').split(/[^A-Za-z0-9]+/).filter(Boolean);
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
- let alias = explicit || (cfg.setup.orgConfirmed ? cfg.org.alias : await resolveAlias(project, {}, { org: {} }));
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: `Generate the TAD from the org "${alias}" (${status.username}, ${status.instanceUrl})?`, recommended: alias, options: [alias, 'another org: the human runs `sf org login web --alias <a>` (opens the browser), then --org <a>', 'local: document the local sources only (--source local)'] }
311
- : { id: 'org', question: alias ? `The org "${alias}" is not connected (session expired). The human logs in again: \`sf org login web --alias ${alias}\`, then --org ${alias}.` : 'No org connected. The human logs in: `sf org login web --alias <a>` (opens the browser), then --org <a>; or --source local.', recommended: alias || null, human: true } };
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' : 'Default org'}: ${describe()}\n Generate the TAD from this org? [Y/n/l = local sources only] `)).trim();
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}" is not connected (session expired): log in again.`);
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 && !status.ok ? alias : 'tad-org';
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) && exists(own) && readJson(own)._translate) add('language', true, `Document language "${lang}": headings and labels still to translate (once)`, `the agent translates the headings of src/*/*.md (keep the {#anchors}) and the values of tadforce/i18n/${lang}.json, then removes its "_translate" line`, false, 'warn');
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], 'ask the human, then run `tadforce doctor --json --project <yes|path|create>`') : 1;
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], 'ask the human, then run `tadforce doctor --json --org <alias>` (or --source local)') : 1;
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, 'ask the human in one message (recommended answers shown; « accept all » is fine), then run `tadforce doctor --json --confirm --lang … --client "…" --integrator …|none --author …|none [--specs …|--no-specs] [--scope …]`');
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], 'ask the human; the answer is the go: `tadforce doctor --json --source org|local|self`') : 1;
458
- const a = { ...args, cwd: project, org: s.mode === 'local' ? false : ctx.cfg.org.alias, quiet: args.json };
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], 'ask the human, then run `tadforce doctor --json --branding keep` (or --logo <file|url>)');
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, 'ask the human in one message (recommended answers shown), write the answers to the knowledge files, then run `tadforce doctor --json --facts-reviewed`');
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: c2.cfg.source.mode === 'local' ? false : c2.cfg.org.alias }));
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 code = finish(args, await evaluate(project, args), [], null);
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
- async function quietly(json, fn) {
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; console.log = () => {};
521
- try { return await fn(); } finally { console.log = orig; }
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 || 'tadforce build' }, null, 2));
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) {
@@ -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 runBatch = async (batch, label) => {
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(args.wait || 60), '--ignore-conflicts'], { timeoutMs: 3600000, cwd: ctx.project });
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
- for (let i = 0; i < types.length; i += size) {
136
- const batch = types.slice(i, i + size);
137
- const names = batch.map((b) => /<name>([^<]+)<\/name>/.exec(b)[1]);
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(batch, `${names.join(', ')}`);
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
- log.warn(`Batch failed (${e.message.slice(0, 160)}) — retrying type by type`);
142
- for (const b of batch) {
143
- const n = /<name>([^<]+)<\/name>/.exec(b)[1];
144
- try { await runBatch([b], n); } catch (e2) { failed.push({ type: n, error: e2.message.slice(0, 300) }); log.warn(`${n}: skipped (${e2.message.slice(0, 120)})`); }
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
  }
@@ -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) ? fs.readdirSync(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
- /** First section still to write (in reading order), among the sections active for this org. */
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')).sort()) {
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)) {