tadforce 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/plugin.json +13 -0
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +266 -2
- package/assets/logo-mark.svg +24 -0
- package/assets/logo.svg +31 -0
- package/bin/tadforce.js +4 -0
- package/commands/tadforce-review.md +8 -0
- package/commands/tadforce-update.md +6 -0
- package/commands/tadforce.md +10 -0
- package/package.json +60 -4
- package/skills/tadforce/SKILL.md +73 -0
- package/skills/tadforce/references/axes-rules.md +29 -0
- package/skills/tadforce/references/evidence-rules.md +40 -0
- package/skills/tadforce/references/exclusion-heuristics.md +40 -0
- package/skills/tadforce/references/figure-rules.md +42 -0
- package/skills/tadforce/references/markdown-conventions.md +67 -0
- package/skills/tadforce/references/modules/agentforce-ai.md +24 -0
- package/skills/tadforce/references/modules/analytics.md +19 -0
- package/skills/tadforce/references/modules/commerce.md +25 -0
- package/skills/tadforce/references/modules/data-cloud.md +23 -0
- package/skills/tadforce/references/modules/experience-cloud.md +24 -0
- package/skills/tadforce/references/modules/field-service.md +25 -0
- package/skills/tadforce/references/modules/industries.md +21 -0
- package/skills/tadforce/references/modules/integration-platforms.md +19 -0
- package/skills/tadforce/references/modules/marketing.md +24 -0
- package/skills/tadforce/references/modules/omnistudio.md +24 -0
- package/skills/tadforce/references/modules/platform-events.md +19 -0
- package/skills/tadforce/references/modules/revenue-cpq.md +25 -0
- package/skills/tadforce/references/modules/sales-cloud.md +26 -0
- package/skills/tadforce/references/modules/security-shield.md +18 -0
- package/skills/tadforce/references/modules/service-cloud.md +27 -0
- package/skills/tadforce/references/naming-external-systems.md +32 -0
- package/skills/tadforce/references/questions.md +59 -0
- package/skills/tadforce/references/salesforce-skills.md +27 -0
- package/skills/tadforce/references/workflow.md +95 -0
- package/skills/tadforce/references/writing-rules.md +47 -0
- package/skills/tadforce-review/SKILL.md +53 -0
- package/skills/tadforce-update/SKILL.md +48 -0
- package/src/build/brand.js +107 -0
- package/src/build/browser.js +70 -0
- package/src/build/directives.js +293 -0
- package/src/build/export.js +60 -0
- package/src/build/figures.js +119 -0
- package/src/build/mermaid.js +107 -0
- package/src/build/render.js +299 -0
- package/src/build/serve.js +59 -0
- package/src/cli.js +68 -0
- package/src/commands/docs.js +204 -0
- package/src/commands/doctor.js +370 -0
- package/src/commands/init.js +132 -0
- package/src/commands/org.js +151 -0
- package/src/commands/review.js +35 -0
- package/src/commands/spec.js +91 -0
- package/src/commands/update.js +154 -0
- package/src/facts/extractors/apex.js +111 -0
- package/src/facts/extractors/automation.js +85 -0
- package/src/facts/extractors/data.js +94 -0
- package/src/facts/extractors/integration.js +76 -0
- package/src/facts/extractors/misc.js +100 -0
- package/src/facts/extractors/security.js +92 -0
- package/src/facts/extractors/ui.js +120 -0
- package/src/facts/fingerprints.js +32 -0
- package/src/facts/heuristics/exclusions.js +143 -0
- package/src/facts/heuristics/modules.js +85 -0
- package/src/facts/heuristics/naming.js +31 -0
- package/src/facts/heuristics/signals.js +76 -0
- package/src/facts/index.js +197 -0
- package/src/facts/org.js +87 -0
- package/src/facts/xml.js +85 -0
- package/src/sf/sf.js +120 -0
- package/src/util/args.js +21 -0
- package/src/util/config.js +104 -0
- package/src/util/fsx.js +58 -0
- package/src/util/i18n.js +49 -0
- package/src/util/log.js +31 -0
- package/src/util/paths.js +11 -0
- package/src/validate/index.js +126 -0
- package/templates/axes/en/00-document-control.md +3 -0
- package/templates/axes/en/01-summary.md +7 -0
- package/templates/axes/en/02-method.md +9 -0
- package/templates/axes/en/03-findings.md +19 -0
- package/templates/axes/en/04-register.md +3 -0
- package/templates/axes/en/05-roadmap.md +3 -0
- package/templates/axes/en/99-appendix.md +5 -0
- package/templates/axes/fr/00-document-control.md +3 -0
- package/templates/axes/fr/01-summary.md +7 -0
- package/templates/axes/fr/02-method.md +9 -0
- package/templates/axes/fr/03-findings.md +19 -0
- package/templates/axes/fr/04-register.md +3 -0
- package/templates/axes/fr/05-roadmap.md +3 -0
- package/templates/axes/fr/99-appendix.md +5 -0
- package/templates/html/cover.html +12 -0
- package/templates/html/editor.js +502 -0
- package/templates/html/style.css +255 -0
- package/templates/html/template.html +88 -0
- package/templates/i18n/en.json +366 -0
- package/templates/i18n/fr.json +366 -0
- package/templates/manifest/package-tad.xml +543 -0
- package/templates/specs/sections.yml +494 -0
- package/templates/tad/en/00-document-control.md +7 -0
- package/templates/tad/en/01-summary.md +17 -0
- package/templates/tad/en/02-purpose.md +35 -0
- package/templates/tad/en/03-context.md +17 -0
- package/templates/tad/en/04-general.md +29 -0
- package/templates/tad/en/05-functional.md +7 -0
- package/templates/tad/en/06-data.md +71 -0
- package/templates/tad/en/07-application.md +49 -0
- package/templates/tad/en/08-ui.md +41 -0
- package/templates/tad/en/09-integrations.md +53 -0
- package/templates/tad/en/10-security.md +59 -0
- package/templates/tad/en/11-reporting.md +13 -0
- package/templates/tad/en/12-operations.md +25 -0
- package/templates/tad/en/13-nfr.md +9 -0
- package/templates/tad/en/14-decisions.md +3 -0
- package/templates/tad/en/15-outlook.md +3 -0
- package/templates/tad/en/99-appendices.md +119 -0
- package/templates/tad/fr/00-document-control.md +7 -0
- package/templates/tad/fr/01-summary.md +17 -0
- package/templates/tad/fr/02-purpose.md +35 -0
- package/templates/tad/fr/03-context.md +17 -0
- package/templates/tad/fr/04-general.md +29 -0
- package/templates/tad/fr/05-functional.md +7 -0
- package/templates/tad/fr/06-data.md +71 -0
- package/templates/tad/fr/07-application.md +49 -0
- package/templates/tad/fr/08-ui.md +41 -0
- package/templates/tad/fr/09-integrations.md +53 -0
- package/templates/tad/fr/10-security.md +59 -0
- package/templates/tad/fr/11-reporting.md +13 -0
- package/templates/tad/fr/12-operations.md +25 -0
- package/templates/tad/fr/13-nfr.md +9 -0
- package/templates/tad/fr/14-decisions.md +3 -0
- package/templates/tad/fr/15-outlook.md +3 -0
- package/templates/tad/fr/99-appendices.md +119 -0
- package/templates/vendor/LICENSE-html-docx-js.txt +21 -0
- package/templates/vendor/LICENSE-html2canvas.txt +22 -0
- package/templates/vendor/LICENSE-mermaid.txt +21 -0
- package/templates/vendor/html-docx.js +13216 -0
- package/templates/vendor/html2canvas.min.js +20 -0
- package/templates/vendor/mermaid.min.js +2314 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readJson, exists, toPosix, safeReadJson } from '../util/fsx.js';
|
|
4
|
+
import { findMetadataRoots, files, safeRead, clearReadCache } from './xml.js';
|
|
5
|
+
import { extractApex, tokens } from './extractors/apex.js';
|
|
6
|
+
import { extractData } from './extractors/data.js';
|
|
7
|
+
import { extractAutomation } from './extractors/automation.js';
|
|
8
|
+
import { extractUi, placements } from './extractors/ui.js';
|
|
9
|
+
import { extractSecurity } from './extractors/security.js';
|
|
10
|
+
import { extractIntegration } from './extractors/integration.js';
|
|
11
|
+
import { extractMisc, detectNamespaces } from './extractors/misc.js';
|
|
12
|
+
import { applyExclusions, applyScope, exclusionCandidates } from './heuristics/exclusions.js';
|
|
13
|
+
import { detectModules } from './heuristics/modules.js';
|
|
14
|
+
import { detectNaming } from './heuristics/naming.js';
|
|
15
|
+
import { computeSignals } from './heuristics/signals.js';
|
|
16
|
+
|
|
17
|
+
export const FACTS_SCHEMA = 1;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build facts.json from source-format metadata. Pure and deterministic: same input, same bytes.
|
|
21
|
+
* @param {string[]} dirs source directories (snapshot or package directories)
|
|
22
|
+
* @param {object} knowledge { exclusions, glossary, scope }
|
|
23
|
+
* @param {object} [opts] { base: path used to relativise roots, org: org facts, codeAnalyzer }
|
|
24
|
+
*/
|
|
25
|
+
export function extractFacts(dirs, knowledge = {}, opts = {}) {
|
|
26
|
+
const roots = findMetadataRoots(dirs);
|
|
27
|
+
const apex = extractApex(roots);
|
|
28
|
+
const data = extractData(roots);
|
|
29
|
+
const automation = extractAutomation(roots);
|
|
30
|
+
const ui = extractUi(roots);
|
|
31
|
+
const security = extractSecurity(roots);
|
|
32
|
+
const integration = extractIntegration(roots);
|
|
33
|
+
const misc = extractMisc(roots);
|
|
34
|
+
const namespaces = detectNamespaces(roots, misc.installedPackages);
|
|
35
|
+
|
|
36
|
+
const refs = references(roots, apex, ui, automation, integration);
|
|
37
|
+
const facts = { schema: FACTS_SCHEMA, apex, data, automation, ui, security, integration, misc, namespaces };
|
|
38
|
+
|
|
39
|
+
const outOfScope = applyScope(facts, knowledge.scope || {});
|
|
40
|
+
const applied = applyExclusions(facts, knowledge.exclusions || {});
|
|
41
|
+
// placements are recomputed so that excluded containers no longer count as usages
|
|
42
|
+
const placementsAfter = {};
|
|
43
|
+
const live = new Set([...facts.ui.flexipages.map((p) => 'flexipage:' + p.name), ...facts.ui.lwc.map((l) => 'lwc:' + l.name), ...facts.ui.aura.map((a) => 'aura:' + a.name)]);
|
|
44
|
+
for (const [comp, uses] of Object.entries(refs.placements)) {
|
|
45
|
+
const kept = uses.filter((u) => !['flexipage', 'lwc', 'aura'].includes(u.kind) || live.has(u.kind + ':' + u.name));
|
|
46
|
+
if (kept.length) placementsAfter[comp] = kept;
|
|
47
|
+
}
|
|
48
|
+
refs.placements = placementsAfter;
|
|
49
|
+
for (const l of facts.ui.lwc) l.placedIn = placementsAfter[l.name] || [];
|
|
50
|
+
for (const a of facts.ui.aura) a.placedIn = placementsAfter[a.name] || [];
|
|
51
|
+
|
|
52
|
+
facts.exclusions = { applied, candidates: exclusionCandidates(facts, refs) };
|
|
53
|
+
if ((knowledge.scope?.include || []).length) facts.scope = { include: [...knowledge.scope.include], outOfScope: outOfScope.length };
|
|
54
|
+
facts.modules = detectModules(facts);
|
|
55
|
+
// Products used without metadata traces are declared by a human in glossary.yml › products.
|
|
56
|
+
for (const p of (knowledge.glossary && knowledge.glossary.products) || []) {
|
|
57
|
+
if (!p || !p.name || facts.modules.clouds.some((c) => c.name === p.name)) continue;
|
|
58
|
+
facts.modules.clouds.push({ id: p.id || p.name, name: p.name, reference: p.reference || null, evidence: ['declared'] });
|
|
59
|
+
if (p.id) facts.modules[p.id] = true;
|
|
60
|
+
if (p.reference && !facts.modules.references.includes(p.reference)) facts.modules.references.push(p.reference);
|
|
61
|
+
if (!facts.modules.products.includes(p.name)) facts.modules.products.push(p.name);
|
|
62
|
+
}
|
|
63
|
+
facts.naming = detectNaming(facts);
|
|
64
|
+
facts.systems = externalSystems(facts, knowledge.glossary || {});
|
|
65
|
+
facts.counts = counts(facts);
|
|
66
|
+
facts.inventory = inventory(facts);
|
|
67
|
+
facts.signals = computeSignals(facts, opts.org || null, opts.codeAnalyzer || null);
|
|
68
|
+
facts.sources = roots.map((r) => (opts.base ? toPosix(path.relative(opts.base, r)) || '.' : toPosix(r)));
|
|
69
|
+
|
|
70
|
+
// internal token sets are not serialised
|
|
71
|
+
for (const c of facts.apex.classes) delete c._tokens;
|
|
72
|
+
for (const t of facts.apex.triggers) delete t._tokens;
|
|
73
|
+
clearReadCache();
|
|
74
|
+
return facts;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Cross-references: where Apex classes, LWC/Aura and FlexiPages are used. */
|
|
78
|
+
function references(roots, apex, ui, automation, integration) {
|
|
79
|
+
const nonTest = apex.classes.filter((c) => !c.test);
|
|
80
|
+
const testTokens = new Set();
|
|
81
|
+
for (const c of apex.classes.filter((c) => c.test)) for (const t of c._tokens) testTokens.add(t);
|
|
82
|
+
const prodTokens = new Map(); // token -> count of producing files
|
|
83
|
+
const bump = (set, self) => { for (const t of set) if (t !== self) prodTokens.set(t, (prodTokens.get(t) || 0) + 1); };
|
|
84
|
+
for (const c of nonTest) bump(c._tokens, c.name);
|
|
85
|
+
for (const t of apex.triggers) bump(t._tokens, null);
|
|
86
|
+
const textFolders = ['flows', 'omniScripts', 'omniIntegrationProcedures', 'omniDataTransforms', 'omniUiCard', 'aura', 'lwc', 'pages', 'components', 'authproviders', 'quickActions', 'customMetadata', 'experiences', 'digitalExperiences', 'emailservices', 'objects', 'permissionsets', 'profiles', 'flexipages'];
|
|
87
|
+
for (const folder of textFolders) {
|
|
88
|
+
for (const f of files(roots, folder, '', { recursive: true })) {
|
|
89
|
+
if (!/\.(xml|js|html|cmp|app|page|component|json|design|evt)$/.test(f)) continue;
|
|
90
|
+
if (folder === 'permissionsets' || folder === 'profiles') continue; // class access grants are not usages
|
|
91
|
+
bump(tokens(safeRead(f)), null);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const apexReferenced = new Set(nonTest.filter((c) => prodTokens.has(c.name)).map((c) => c.name));
|
|
95
|
+
for (const l of ui.lwc) for (const a of l.apex) apexReferenced.add(a);
|
|
96
|
+
const apexReferencedByTests = new Set(nonTest.filter((c) => testTokens.has(c.name)).map((c) => c.name));
|
|
97
|
+
|
|
98
|
+
const assignTokens = new Set();
|
|
99
|
+
for (const folder of ['applications', 'objects', 'tabs', 'customApplications']) {
|
|
100
|
+
for (const f of files(roots, folder, '', { recursive: true })) if (/\.(app-meta|object-meta|tab-meta)\.xml$/.test(f)) for (const t of tokens(safeRead(f))) assignTokens.add(t);
|
|
101
|
+
}
|
|
102
|
+
const flexipageAssigned = new Set(ui.flexipages.filter((p) => assignTokens.has(p.name) || p.type === 'UtilityBar' && ui.apps.some((a) => a.utilityBar === p.name)).map((p) => p.name));
|
|
103
|
+
return { placements: placements(roots, ui), apexReferenced, apexReferencedByTests, flexipageAssigned };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** External systems seen from Salesforce, grouped by host, and named from the glossary when possible. */
|
|
107
|
+
function externalSystems(f, glossary) {
|
|
108
|
+
const endpoints = [];
|
|
109
|
+
for (const n of f.integration.namedCredentials) endpoints.push({ kind: 'namedCredential', name: n.name, host: n.host });
|
|
110
|
+
for (const r of f.integration.remoteSites) endpoints.push({ kind: 'remoteSite', name: r.name, host: r.host });
|
|
111
|
+
for (const c of f.integration.cspTrustedSites) endpoints.push({ kind: 'cspTrustedSite', name: c.name, host: c.host });
|
|
112
|
+
for (const a of f.integration.authProviders) endpoints.push({ kind: 'authProvider', name: a.name, host: null });
|
|
113
|
+
for (const a of f.integration.connectedApps) endpoints.push({ kind: 'connectedApp', name: a.name, host: null });
|
|
114
|
+
for (const c of f.integration.callCenters) endpoints.push({ kind: 'callCenter', name: c.name, host: c.adapterHost });
|
|
115
|
+
const systems = (glossary.systems || []).map((s) => ({ id: s.id || s.name, name: s.name, description: s.description || null, match: (s.match || []).map(String), endpoints: [] }));
|
|
116
|
+
const unnamed = [];
|
|
117
|
+
for (const e of endpoints) {
|
|
118
|
+
const sys = systems.find((s) => s.match.some((m) => m.toLowerCase() === String(e.name).toLowerCase() || (e.host && (e.host === m.toLowerCase() || e.host.endsWith('.' + m.toLowerCase())))));
|
|
119
|
+
if (sys) sys.endpoints.push(e); else unnamed.push(e);
|
|
120
|
+
}
|
|
121
|
+
for (const s of systems) { s.endpoints.sort((a, b) => (a.kind + a.name).localeCompare(b.kind + b.name)); delete s.match; }
|
|
122
|
+
return { named: systems, unnamed: unnamed.sort((a, b) => (a.kind + a.name).localeCompare(b.kind + b.name)) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function counts(f) {
|
|
126
|
+
const app = f.apex.classes.filter((c) => !c.test);
|
|
127
|
+
const kind = (k) => app.filter((c) => c.kinds.includes(k)).length;
|
|
128
|
+
const local = f.data.objects.filter((o) => !o.namespace);
|
|
129
|
+
const o = f.automation.omnistudio;
|
|
130
|
+
return {
|
|
131
|
+
apex: { classes: app.length, tests: f.apex.classes.filter((c) => c.test).length, triggers: f.apex.triggers.length, batch: kind('batch'), queueable: kind('queueable'), schedulable: kind('schedulable'), future: kind('future'), rest: kind('rest'), soap: kind('soap'), invocable: kind('invocable'), auraEnabled: kind('auraEnabled'), callout: kind('callout'), lines: app.reduce((n, c) => n + c.lines, 0), withSharing: app.filter((c) => c.sharing === 'with').length, withoutSharing: app.filter((c) => c.sharing === 'without').length, inheritedSharing: app.filter((c) => c.sharing === 'inherited').length },
|
|
132
|
+
objects: { custom: local.filter((x) => x.kind === 'custom').length, standard: local.filter((x) => x.kind === 'standard').length, managed: f.data.objects.filter((x) => x.namespace).length, platformEvents: local.filter((x) => x.kind === 'platformEvent').length, customSettings: local.filter((x) => x.kind === 'customSetting').length, cmdtTypes: f.data.cmdtTypes.length, bigObjects: local.filter((x) => x.kind === 'bigObject').length, externalObjects: local.filter((x) => x.kind === 'externalObject').length },
|
|
133
|
+
fields: { custom: f.data.objects.reduce((n, x) => n + x.customFields, 0), externalIds: f.data.objects.reduce((n, x) => n + x.externalIds.length, 0), relations: f.data.objects.reduce((n, x) => n + x.relations.length, 0), formulas: f.data.objects.reduce((n, x) => n + x.formulaFields, 0) },
|
|
134
|
+
recordTypes: f.data.objects.reduce((n, x) => n + x.recordTypes.length, 0),
|
|
135
|
+
validationRules: { total: f.data.objects.reduce((n, x) => n + x.validationRules.length, 0), active: f.data.objects.reduce((n, x) => n + x.validationRules.filter((v) => v.active).length, 0) },
|
|
136
|
+
cmdt: { types: f.data.cmdtTypes.length, records: f.data.cmdtTypes.reduce((n, t) => n + t.records, 0) },
|
|
137
|
+
flows: { total: f.automation.flows.length, active: f.automation.flows.filter((x) => x.active).length, recordTriggered: f.automation.flows.filter((x) => x.active && /Record/.test(x.triggerType || '')).length, scheduled: f.automation.flows.filter((x) => x.active && x.triggerType === 'Scheduled').length, screen: f.automation.flows.filter((x) => x.active && x.processType === 'Flow').length, autolaunched: f.automation.flows.filter((x) => x.active && x.processType === 'AutoLaunchedFlow' && !x.triggerType).length, platformEvent: f.automation.flows.filter((x) => x.active && x.triggerType === 'PlatformEvent').length },
|
|
138
|
+
omni: { scripts: o.omniScripts.length, scriptsActive: o.omniScripts.filter((x) => x.active).length, ips: o.integrationProcedures.length, ipsActive: o.integrationProcedures.filter((x) => x.active).length, dataMappers: o.dataMappers.length, dataMappersActive: o.dataMappers.filter((x) => x.active).length, cards: o.flexCards.length, cardsActive: o.flexCards.filter((x) => x.active).length },
|
|
139
|
+
ui: { apps: f.ui.apps.length, consoleApps: f.ui.apps.filter((a) => a.console).length, flexipages: f.ui.flexipages.length, recordPages: f.ui.flexipages.filter((p) => p.type === 'RecordPage').length, lwc: f.ui.lwc.length, aura: f.ui.aura.length, vfPages: f.ui.vfPages.length, tabs: f.ui.tabs.length, quickActions: f.ui.quickActions.length, messageChannels: f.ui.messageChannels.length },
|
|
140
|
+
security: { profiles: f.security.profiles.length, permissionSets: f.security.permissionSets.length, permissionSetGroups: f.security.permissionSetGroups.length, roles: f.security.roles.length, queues: f.security.queues.length, groups: f.security.groups.length, sharingRules: f.security.sharingRules.reduce((n, s) => n + s.rules.length, 0), customPermissions: f.security.customPermissions.length },
|
|
141
|
+
integration: { namedCredentials: f.integration.namedCredentials.length, externalCredentials: f.integration.externalCredentials.length, remoteSites: f.integration.remoteSites.length, connectedApps: f.integration.connectedApps.length, authProviders: f.integration.authProviders.length, cspTrustedSites: f.integration.cspTrustedSites.length, corsOrigins: f.integration.corsOrigins.length, restResources: kind('rest'), callCenters: f.integration.callCenters.length, externalServices: f.integration.externalServices.length, systems: f.systems.named.length },
|
|
142
|
+
misc: { labels: f.misc.labels.length, staticResources: f.misc.staticResources.length, reports: f.misc.reports.names.length, dashboards: f.misc.dashboards.names.length, reportTypes: f.misc.reportTypes.length, emailTemplates: f.misc.emailTemplates.length, namespaces: f.namespaces.length },
|
|
143
|
+
exclusions: { applied: f.exclusions.applied.length, candidates: f.exclusions.candidates.length },
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Every documented component name, used by `tadforce validate` to check identifiers in the text. */
|
|
148
|
+
function inventory(f) {
|
|
149
|
+
const names = new Set();
|
|
150
|
+
const addAll = (xs, k = 'name') => xs.forEach((x) => names.add(typeof x === 'string' ? x : x[k]));
|
|
151
|
+
addAll(f.apex.classes); addAll(f.apex.triggers); addAll(f.ui.lwc); addAll(f.ui.aura); addAll(f.ui.vfPages); addAll(f.ui.flexipages); addAll(f.ui.apps); addAll(f.ui.tabs); addAll(f.ui.quickActions);
|
|
152
|
+
addAll(f.automation.flows);
|
|
153
|
+
for (const k of Object.keys(f.automation.omnistudio)) {
|
|
154
|
+
addAll(f.automation.omnistudio[k]);
|
|
155
|
+
f.automation.omnistudio[k].forEach((x) => { [x.type, x.subType, x.label].forEach((v) => v && names.add(v)); if (x.type && x.subType) { names.add(`${x.type}_${x.subType}`); names.add(`${x.type}/${x.subType}`); } });
|
|
156
|
+
}
|
|
157
|
+
for (const list of Object.values(f.misc.named || {})) addAll(list);
|
|
158
|
+
for (const o of f.data.objects) { names.add(o.name); (o.customFieldNames || []).forEach((x) => { names.add(x); names.add(`${o.name}.${x}`); }); o.externalIds.forEach((x) => { names.add(x); names.add(`${o.name}.${x}`); }); o.relations.forEach((r) => { names.add(r.field); names.add(`${o.name}.${r.field}`); r.to.forEach((x) => names.add(x)); }); o.recordTypes.forEach((r) => names.add(r.name)); o.validationRules.forEach((v) => names.add(v.name)); }
|
|
159
|
+
for (const t of f.data.cmdtTypes) { names.add(t.type); t.names.forEach((n) => names.add(n)); }
|
|
160
|
+
addAll(f.security.profiles); addAll(f.security.permissionSets); addAll(f.security.permissionSetGroups); addAll(f.security.roles); addAll(f.security.queues); addAll(f.security.groups); addAll(f.security.customPermissions);
|
|
161
|
+
f.security.sharingRules.forEach((s) => s.rules.forEach((r) => names.add(r.name)));
|
|
162
|
+
addAll(f.integration.namedCredentials); addAll(f.integration.externalCredentials); addAll(f.integration.remoteSites); addAll(f.integration.connectedApps); addAll(f.integration.authProviders); addAll(f.integration.cspTrustedSites); addAll(f.integration.callCenters);
|
|
163
|
+
f.apex.classes.forEach((c) => c.restUrl && names.add(c.restUrl));
|
|
164
|
+
addAll(f.ui.messageChannels); addAll(f.misc.reports.folders);
|
|
165
|
+
f.integration.connectedApps.forEach((a) => a.scopes.forEach((s) => names.add(s)));
|
|
166
|
+
[...f.security.profiles, ...f.security.permissionSets].forEach((p) => p.powerful.forEach((x) => names.add(x)));
|
|
167
|
+
addAll(f.misc.labels); addAll(f.misc.staticResources); addAll(f.misc.reports.names); addAll(f.misc.dashboards.names); addAll(f.misc.reportTypes); addAll(f.misc.emailTemplates); addAll(f.data.globalValueSets);
|
|
168
|
+
return [...names].filter(Boolean).sort((a, b) => a.localeCompare(b));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function loadFacts(ws) {
|
|
172
|
+
const p = path.join(ws, 'facts', 'facts.json');
|
|
173
|
+
if (!exists(p)) return null;
|
|
174
|
+
const facts = readJson(p);
|
|
175
|
+
const orgP = path.join(ws, 'facts', 'org.json');
|
|
176
|
+
facts.org = exists(orgP) ? readJson(orgP) : null;
|
|
177
|
+
return facts;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Summarise a Code Analyzer JSON report (sf code-analyzer run --output-file x.json) if the user provided one. */
|
|
181
|
+
export function readCodeAnalyzer(ws) {
|
|
182
|
+
const dir = path.join(ws, 'knowledge', 'inputs');
|
|
183
|
+
if (!exists(dir)) return null;
|
|
184
|
+
const f = fs.readdirSync(dir).find((n) => /code-analyzer.*\.json$/i.test(n));
|
|
185
|
+
if (!f) return null;
|
|
186
|
+
const j = safeReadJson(path.join(dir, f));
|
|
187
|
+
const violations = j?.violations || j?.results?.flatMap?.((r) => r.violations || []) || [];
|
|
188
|
+
if (!violations.length) return null;
|
|
189
|
+
const bySeverity = {}, byRule = {};
|
|
190
|
+
for (const v of violations) {
|
|
191
|
+
const sev = String(v.severity ?? v.sev ?? 3);
|
|
192
|
+
bySeverity[sev] = (bySeverity[sev] || 0) + 1;
|
|
193
|
+
const rule = v.rule || v.ruleName || 'rule';
|
|
194
|
+
byRule[rule] = (byRule[rule] || 0) + 1;
|
|
195
|
+
}
|
|
196
|
+
return { file: f, total: violations.length, bySeverity, topRules: Object.entries(byRule).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([rule, count]) => ({ rule, count })) };
|
|
197
|
+
}
|
package/src/facts/org.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Live org facts: configuration and aggregates only (COUNT, MAX(CreatedDate), GROUP BY).
|
|
2
|
+
// Stored apart from facts.json (facts/org.json) because they change over time.
|
|
3
|
+
import { soql, sfJson } from '../sf/sf.js';
|
|
4
|
+
|
|
5
|
+
async function safe(fn, fallback = null) {
|
|
6
|
+
try { return await fn(); } catch (e) { return { error: e.message.slice(0, 300), value: fallback }; }
|
|
7
|
+
}
|
|
8
|
+
const rows = (r) => (r && Array.isArray(r.records) ? r.records : []);
|
|
9
|
+
const unwrap = (v) => (v && v.error !== undefined ? v.value : v);
|
|
10
|
+
|
|
11
|
+
async function pool(items, n, fn) {
|
|
12
|
+
const out = new Array(items.length);
|
|
13
|
+
let i = 0;
|
|
14
|
+
await Promise.all(Array.from({ length: Math.min(n, items.length) }, async () => {
|
|
15
|
+
while (i < items.length) { const k = i++; out[k] = await fn(items[k], k); }
|
|
16
|
+
}));
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function collectOrgFacts(alias, facts, { onProgress = () => {} } = {}) {
|
|
21
|
+
const errors = [];
|
|
22
|
+
const run = async (label, fn, fallback) => { const v = await safe(fn, fallback); if (v && v.error !== undefined) errors.push({ step: label, error: v.error }); return unwrap(v); };
|
|
23
|
+
|
|
24
|
+
onProgress('organization');
|
|
25
|
+
const orgRow = rows(await run('organization', () => soql('SELECT Name, OrganizationType, IsSandbox, InstanceName, LanguageLocaleKey, DefaultLocaleSidKey, TimeZoneSidKey, NamespacePrefix FROM Organization', alias)))[0] || null;
|
|
26
|
+
const organization = orgRow && {
|
|
27
|
+
name: orgRow.Name, edition: orgRow.OrganizationType, sandbox: orgRow.IsSandbox, instance: orgRow.InstanceName,
|
|
28
|
+
language: orgRow.LanguageLocaleKey, locale: orgRow.DefaultLocaleSidKey, timeZone: orgRow.TimeZoneSidKey,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
onProgress('users');
|
|
32
|
+
const usersByProfile = rows(await run('users', () => soql('SELECT Profile.Name p, COUNT(Id) c FROM User WHERE IsActive = true GROUP BY Profile.Name', alias)))
|
|
33
|
+
.map((r) => ({ profile: r.p || r.Name, users: r.c ?? r.expr0 })).sort((a, b) => String(a.profile).localeCompare(String(b.profile)));
|
|
34
|
+
const usersByLicense = rows(await run('licenses', () => soql('SELECT Name, TotalLicenses, UsedLicenses FROM UserLicense', alias)))
|
|
35
|
+
.filter((r) => r.UsedLicenses > 0).map((r) => ({ license: r.Name, used: r.UsedLicenses, total: r.TotalLicenses })).sort((a, b) => a.license.localeCompare(b.license));
|
|
36
|
+
const psgAssignments = rows(await run('psg', () => soql('SELECT PermissionSetGroup.DeveloperName g, COUNT(Id) c FROM PermissionSetAssignment WHERE PermissionSetGroupId != null AND Assignee.IsActive = true GROUP BY PermissionSetGroup.DeveloperName', alias)))
|
|
37
|
+
.map((r) => ({ group: r.g || r.DeveloperName, users: r.c ?? r.expr0 })).sort((a, b) => String(a.group).localeCompare(String(b.group)));
|
|
38
|
+
|
|
39
|
+
onProgress('records');
|
|
40
|
+
const objects = facts.data.objects.filter((o) => ['custom', 'standard'].includes(o.kind) && !o.namespace && (o.kind === 'custom' || o.customFields > 0));
|
|
41
|
+
const volumes = (await pool(objects, 4, async (o) => {
|
|
42
|
+
const count = await run(`count ${o.name}`, async () => (await soql(`SELECT COUNT() FROM ${o.name}`, alias)).totalSize);
|
|
43
|
+
if (count == null) return null;
|
|
44
|
+
let last = null;
|
|
45
|
+
if (count > 0) {
|
|
46
|
+
const r = rows(await run(`last ${o.name}`, () => soql(`SELECT MAX(CreatedDate) d FROM ${o.name}`, alias)))[0];
|
|
47
|
+
last = r ? (r.d || r.expr0 || null) : null;
|
|
48
|
+
}
|
|
49
|
+
return { object: o.name, records: count, lastCreated: last ? String(last).slice(0, 10) : null };
|
|
50
|
+
})).filter(Boolean);
|
|
51
|
+
|
|
52
|
+
onProgress('jobs');
|
|
53
|
+
const scheduledJobs = rows(await run('cron', () => soql("SELECT CronJobDetail.Name, CronJobDetail.JobType, State, CronExpression, NextFireTime FROM CronTrigger WHERE State NOT IN ('DELETED','COMPLETE')", alias)))
|
|
54
|
+
.map((r) => ({ name: r.CronJobDetail?.Name, type: r.CronJobDetail?.JobType, state: r.State, cron: r.CronExpression, next: r.NextFireTime ? String(r.NextFireTime).slice(0, 16) : null }))
|
|
55
|
+
.sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
56
|
+
const activeFlows = rows(await run('flows', () => soql('SELECT ApiName, IsActive, ProcessType FROM FlowDefinitionView WHERE IsActive = true', alias))).map((r) => r.ApiName).sort();
|
|
57
|
+
|
|
58
|
+
onProgress('coverage');
|
|
59
|
+
const cov = rows(await run('coverage', () => soql('SELECT PercentCovered FROM ApexOrgWideCoverage', alias, { tooling: true })))[0];
|
|
60
|
+
const coverage = cov ? cov.PercentCovered : null;
|
|
61
|
+
|
|
62
|
+
onProgress('packages');
|
|
63
|
+
const packages = (await run('packages', () => sfJson(['package', 'installed', 'list', '--target-org', alias]), []) || [])
|
|
64
|
+
.map((p) => ({ name: p.SubscriberPackageName, namespace: p.SubscriberPackageNamespace || null, version: p.SubscriberPackageVersionNumber || p.SubscriberPackageVersionName || null }))
|
|
65
|
+
.sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
66
|
+
|
|
67
|
+
onProgress('limits');
|
|
68
|
+
const limits = (await run('limits', () => sfJson(['org', 'list', 'limits', '--target-org', alias]), []) || [])
|
|
69
|
+
.filter((l) => ['DataStorageMB', 'FileStorageMB', 'DailyApiRequests', 'DailyAsyncApexExecutions', 'DailyBulkV2QueryJobs'].includes(l.name))
|
|
70
|
+
.map((l) => ({ name: l.name, max: l.max, remaining: l.remaining })).sort((a, b) => a.name.localeCompare(b.name));
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
collectedAt: new Date().toISOString().slice(0, 19) + 'Z',
|
|
74
|
+
alias,
|
|
75
|
+
organization,
|
|
76
|
+
usersByProfile,
|
|
77
|
+
usersByLicense,
|
|
78
|
+
psgAssignments,
|
|
79
|
+
volumes,
|
|
80
|
+
scheduledJobs,
|
|
81
|
+
activeFlows,
|
|
82
|
+
coverage,
|
|
83
|
+
packages,
|
|
84
|
+
limits,
|
|
85
|
+
errors,
|
|
86
|
+
};
|
|
87
|
+
}
|
package/src/facts/xml.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { XMLParser } from 'fast-xml-parser';
|
|
4
|
+
import { readText, walk } from '../util/fsx.js';
|
|
5
|
+
|
|
6
|
+
const parser = new XMLParser({ ignoreAttributes: true, removeNSPrefix: true, parseTagValue: false, trimValues: true });
|
|
7
|
+
|
|
8
|
+
/** Parse a metadata XML file and return its root element (attributes ignored, values as strings). */
|
|
9
|
+
export function readXml(file) {
|
|
10
|
+
try {
|
|
11
|
+
const obj = parser.parse(safeRead(file));
|
|
12
|
+
const root = Object.keys(obj).find((k) => k !== '?xml');
|
|
13
|
+
return (root && obj[root]) || {};
|
|
14
|
+
} catch {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const arr = (x) => (x == null ? [] : Array.isArray(x) ? x : [x]);
|
|
20
|
+
export const bool = (v) => v === true || v === 'true';
|
|
21
|
+
export const str = (v) => (v == null ? null : typeof v === 'object' ? null : String(v));
|
|
22
|
+
export const byName = (a, b) => String(a.name).localeCompare(String(b.name));
|
|
23
|
+
export const sortNames = (list) => [...new Set(list)].sort((a, b) => a.localeCompare(b));
|
|
24
|
+
|
|
25
|
+
export function host(url) {
|
|
26
|
+
if (!url || typeof url !== 'string') return null;
|
|
27
|
+
const m = /^[a-z][a-z0-9+.-]*:\/\/([^/:?#]+)/i.exec(url.trim());
|
|
28
|
+
return m ? m[1].toLowerCase() : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Folders that identify a Salesforce source-format metadata root (e.g. force-app/main/default). */
|
|
32
|
+
const MARKERS = new Set(['classes', 'objects', 'lwc', 'aura', 'flows', 'triggers', 'permissionsets', 'layouts', 'flexipages', 'applications', 'customMetadata', 'profiles', 'tabs', 'labels']);
|
|
33
|
+
|
|
34
|
+
export function findMetadataRoots(dirs) {
|
|
35
|
+
const roots = new Set();
|
|
36
|
+
const scan = (d, depth) => {
|
|
37
|
+
let entries;
|
|
38
|
+
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
39
|
+
const sub = entries.filter((e) => e.isDirectory());
|
|
40
|
+
if (sub.some((e) => MARKERS.has(e.name))) { roots.add(path.resolve(d)); return; }
|
|
41
|
+
if (depth <= 0) return;
|
|
42
|
+
for (const e of sub) if (!e.name.startsWith('.') && e.name !== 'node_modules') scan(path.join(d, e.name), depth - 1);
|
|
43
|
+
};
|
|
44
|
+
for (const d of dirs) scan(d, 5);
|
|
45
|
+
return [...roots].sort();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Files of a metadata folder across roots. `suffix` filters by file ending. */
|
|
49
|
+
export function files(roots, folder, suffix, { recursive = false } = {}) {
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const r of roots) {
|
|
52
|
+
const dir = path.join(r, folder);
|
|
53
|
+
if (!fs.existsSync(dir)) continue;
|
|
54
|
+
const list = recursive ? walk(dir) : fs.readdirSync(dir).sort().map((f) => path.join(dir, f)).filter((f) => fs.statSync(f).isFile());
|
|
55
|
+
out.push(...list.filter((f) => !suffix || f.endsWith(suffix)));
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Sub-directories of a metadata folder across roots (LWC, Aura, objects…). */
|
|
61
|
+
export function bundles(roots, folder) {
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const r of roots) {
|
|
64
|
+
const dir = path.join(r, folder);
|
|
65
|
+
if (!fs.existsSync(dir)) continue;
|
|
66
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
67
|
+
if (e.isDirectory()) out.push({ name: e.name, dir: path.join(dir, e.name) });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const baseName = (file, suffix) => path.basename(file).slice(0, suffix ? -suffix.length : undefined);
|
|
74
|
+
// Files are read several times (parsing, references, namespaces): keep their text for one extraction run.
|
|
75
|
+
const cache = new Map();
|
|
76
|
+
export const safeRead = (f) => {
|
|
77
|
+
if (cache.has(f)) return cache.get(f);
|
|
78
|
+
let s = '';
|
|
79
|
+
try { s = readText(f); } catch { /* unreadable file: treated as empty */ }
|
|
80
|
+
cache.set(f, s);
|
|
81
|
+
return s;
|
|
82
|
+
};
|
|
83
|
+
export const clearReadCache = () => cache.clear();
|
|
84
|
+
export const hasFolder = (roots, folder) => roots.some((r) => fs.existsSync(path.join(r, folder)));
|
|
85
|
+
export const countFiles = (roots, folder, suffix) => files(roots, folder, suffix, { recursive: true }).length;
|
package/src/sf/sf.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Read-only wrapper around the Salesforce CLI. Every call goes through an allow-list:
|
|
2
|
+
// TADForce never deploys, never deletes, never reads record-level data.
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
const ALLOWED = [
|
|
6
|
+
['--version'],
|
|
7
|
+
['org', 'list'],
|
|
8
|
+
['org', 'display'],
|
|
9
|
+
['org', 'login', 'web'],
|
|
10
|
+
['org', 'list', 'metadata-types'],
|
|
11
|
+
['org', 'list', 'metadata'],
|
|
12
|
+
['org', 'list', 'limits'],
|
|
13
|
+
['project', 'generate', 'manifest'],
|
|
14
|
+
['project', 'retrieve', 'start'],
|
|
15
|
+
['data', 'query'],
|
|
16
|
+
['package', 'installed', 'list'],
|
|
17
|
+
['config', 'get'],
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// sObjects whose rows describe the org itself (configuration, not business data).
|
|
21
|
+
const METADATA_SOBJECTS = new Set([
|
|
22
|
+
'organization', 'crontrigger', 'flowdefinitionview', 'apexorgwidecoverage', 'apexcodecoverageaggregate',
|
|
23
|
+
'asyncapexjob', 'userlicense', 'permissionsetlicense', 'entitydefinition',
|
|
24
|
+
]);
|
|
25
|
+
// sObjects allowed only through aggregated queries (GROUP BY + COUNT).
|
|
26
|
+
const AGGREGATE_ONLY = new Set(['user', 'permissionsetassignment']);
|
|
27
|
+
|
|
28
|
+
/** The subcommand path (leading words before the first flag) must match an allowed path exactly. */
|
|
29
|
+
export function isAllowed(args) {
|
|
30
|
+
const cmd = [];
|
|
31
|
+
for (const a of args) { if (a.startsWith('-')) break; cmd.push(a); }
|
|
32
|
+
if (!cmd.length) return args[0] === '--version';
|
|
33
|
+
return ALLOWED.some((p) => p.length === cmd.length && p.every((w, i) => w === cmd[i]));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const AGG = /^(count\(\s*(id)?\s*\)|max\(\s*(createddate|lastmodifieddate)\s*\)|min\(\s*createddate\s*\))(\s+\w+)?$/i;
|
|
37
|
+
|
|
38
|
+
/** Throws unless the SOQL only reads org configuration or aggregates — never business records. */
|
|
39
|
+
export function assertSafeQuery(q) {
|
|
40
|
+
const s = q.replace(/\s+/g, ' ').trim();
|
|
41
|
+
const refuse = (why) => { throw new Error(`Refused query (${why}): ${s}`); };
|
|
42
|
+
if (!/^select\s/i.test(s)) refuse('not a SELECT');
|
|
43
|
+
const from = /\sfrom\s+([A-Za-z0-9_]+)/i.exec(s);
|
|
44
|
+
if (!from) refuse('no FROM');
|
|
45
|
+
const obj = from[1].toLowerCase();
|
|
46
|
+
if (METADATA_SOBJECTS.has(obj)) return true;
|
|
47
|
+
const fields = s.slice(6, from.index).split(',').map((f) => f.trim());
|
|
48
|
+
const grouped = /\bgroup by\b/i.test(s);
|
|
49
|
+
if (AGGREGATE_ONLY.has(obj)) {
|
|
50
|
+
const ok = grouped && fields.some((f) => AGG.test(f)) && fields.every((f) => AGG.test(f) || /^[A-Za-z0-9_.]+(\s+\w+)?$/.test(f));
|
|
51
|
+
if (!ok) refuse(`aggregates only on ${from[1]}`);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
if (grouped || !fields.every((f) => AGG.test(f))) refuse('record data is never read');
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolveSf() {
|
|
59
|
+
return process.env.TADFORCE_SF_BIN || 'sf';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// On Windows `sf` is a .cmd shim and needs a shell: build one command line with quoted arguments.
|
|
63
|
+
const quote = (a) => (/^[\w@+=:,./\\-]+$/.test(a) ? a : '"' + String(a).replace(/"/g, '""') + '"');
|
|
64
|
+
function launch(args, opts) {
|
|
65
|
+
if (process.platform !== 'win32') return [resolveSf(), args, { ...opts, shell: false }];
|
|
66
|
+
return [[resolveSf(), ...args].map(quote).join(' '), [], { ...opts, shell: true }];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Run `sf ... --json` and return the parsed result (throws with the CLI message on failure). */
|
|
70
|
+
export function sfJson(args, { timeoutMs = 600000, cwd } = {}) {
|
|
71
|
+
if (!isAllowed(args)) throw new Error(`TADForce refuses to run: sf ${args.join(' ')}`);
|
|
72
|
+
if (args[0] === 'data' && args[1] === 'query') {
|
|
73
|
+
const qi = args.findIndex((a) => a === '--query' || a === '-q');
|
|
74
|
+
assertSafeQuery(args[qi + 1] || '');
|
|
75
|
+
}
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const [cmd, argv, opts] = launch([...args, '--json'], { cwd, env: { ...process.env, SF_DISABLE_TELEMETRY: 'true', SF_AUTOUPDATE_DISABLE: 'true' } });
|
|
78
|
+
const child = spawn(cmd, argv, opts);
|
|
79
|
+
let out = '', err = '';
|
|
80
|
+
const timer = setTimeout(() => { child.kill(); reject(new Error(`sf ${args.slice(0, 3).join(' ')} timed out`)); }, timeoutMs);
|
|
81
|
+
child.stdout.on('data', (d) => (out += d));
|
|
82
|
+
child.stderr.on('data', (d) => (err += d));
|
|
83
|
+
child.on('error', (e) => { clearTimeout(timer); reject(e); });
|
|
84
|
+
child.on('close', () => {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
const start = out.indexOf('{');
|
|
87
|
+
let parsed = null;
|
|
88
|
+
try { parsed = JSON.parse(out.slice(start)); } catch { /* not JSON */ }
|
|
89
|
+
if (!parsed) return reject(new Error((err || out || 'sf returned no JSON').trim().slice(0, 800)));
|
|
90
|
+
if (parsed.status !== 0 && parsed.status !== undefined) {
|
|
91
|
+
const e = new Error(parsed.message || parsed.name || 'sf command failed');
|
|
92
|
+
e.sf = parsed;
|
|
93
|
+
return reject(e);
|
|
94
|
+
}
|
|
95
|
+
resolve(parsed.result);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Interactive `sf` call (inherits the terminal) — used for the browser login only. */
|
|
101
|
+
export function sfInteractive(args) {
|
|
102
|
+
if (!isAllowed(args)) throw new Error(`TADForce refuses to run: sf ${args.join(' ')}`);
|
|
103
|
+
const [cmd, argv, opts] = launch(args, { stdio: 'inherit' });
|
|
104
|
+
const r = spawnSync(cmd, argv, opts);
|
|
105
|
+
return r.status === 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function sfVersion() {
|
|
109
|
+
const [cmd, argv, opts] = launch(['--version'], { encoding: 'utf8' });
|
|
110
|
+
const r = spawnSync(cmd, argv, opts);
|
|
111
|
+
if (r.status !== 0) return null;
|
|
112
|
+
const m = /@salesforce\/cli\/(\d+\.\d+\.\d+)/.exec(r.stdout || '');
|
|
113
|
+
return m ? m[1] : (r.stdout || '').trim() || null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function soql(query, alias, { tooling = false } = {}) {
|
|
117
|
+
const args = ['data', 'query', '--query', query, '--target-org', alias];
|
|
118
|
+
if (tooling) args.push('--use-tooling-api');
|
|
119
|
+
return sfJson(args, { timeoutMs: 180000 });
|
|
120
|
+
}
|
package/src/util/args.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Minimal argv parser: `cmd pos --flag value --bool --no-x -k v`. */
|
|
2
|
+
export function parseArgs(argv) {
|
|
3
|
+
const args = { _: [] };
|
|
4
|
+
for (let i = 0; i < argv.length; i++) {
|
|
5
|
+
const a = argv[i];
|
|
6
|
+
if (a === '--') { args._.push(...argv.slice(i + 1)); break; }
|
|
7
|
+
if (a.startsWith('--')) {
|
|
8
|
+
const [k, inline] = a.slice(2).split(/=(.*)/s);
|
|
9
|
+
const key = k.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase());
|
|
10
|
+
if (inline !== undefined) args[key] = inline;
|
|
11
|
+
else if (key.startsWith('no') && key.length > 2 && /[A-Z]/.test(key[2])) args[key[2].toLowerCase() + key.slice(3)] = false;
|
|
12
|
+
else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-')) args[key] = argv[++i];
|
|
13
|
+
else args[key] = true;
|
|
14
|
+
} else if (a.startsWith('-') && a.length === 2) {
|
|
15
|
+
const key = a.slice(1);
|
|
16
|
+
if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-')) args[key] = argv[++i];
|
|
17
|
+
else args[key] = true;
|
|
18
|
+
} else args._.push(a);
|
|
19
|
+
}
|
|
20
|
+
return args;
|
|
21
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { exists, readJson, readYaml, writeJson } from './fsx.js';
|
|
3
|
+
import { CONFIG_FILE, WORKSPACE_DIR } from './paths.js';
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_BRAND = {
|
|
6
|
+
primary: '#0B2545',
|
|
7
|
+
accent: '#3D5AFE',
|
|
8
|
+
accent2: '#9B1FE8',
|
|
9
|
+
gradient: ['#1E88FF', '#9B1FE8'],
|
|
10
|
+
tableHeader: '#0f2a4d',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function defaultConfig(overrides = {}) {
|
|
14
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
15
|
+
const cfg = {
|
|
16
|
+
language: 'en',
|
|
17
|
+
client: { name: 'Client', short: 'CLIENT', logo: null },
|
|
18
|
+
// integrator and author are optional: when empty they are left out of the document.
|
|
19
|
+
integrator: { name: null, logo: null },
|
|
20
|
+
document: {
|
|
21
|
+
author: null,
|
|
22
|
+
title: 'Salesforce CRM Platform',
|
|
23
|
+
subtitle: '',
|
|
24
|
+
reference: 'TAD-CLIENT-CRM',
|
|
25
|
+
version: '1.0',
|
|
26
|
+
status: 'For review',
|
|
27
|
+
date: today,
|
|
28
|
+
classification: 'Confidential',
|
|
29
|
+
environment: 'Production',
|
|
30
|
+
history: [],
|
|
31
|
+
},
|
|
32
|
+
org: { alias: null },
|
|
33
|
+
// Functional specifications and other documents provided by the project (evidence level P2), relative to tadforce/.
|
|
34
|
+
inputs: { specs: [] },
|
|
35
|
+
// Part of the org to document: name patterns ("NWB_*") and package directories. Empty: the whole org.
|
|
36
|
+
scope: { include: [], packageDirectories: [] },
|
|
37
|
+
brand: { ...DEFAULT_BRAND },
|
|
38
|
+
// mode: org (retrieved snapshot), local (the project's package directories) or both. null: not chosen yet.
|
|
39
|
+
source: { dir: '.snapshot/source', mode: null },
|
|
40
|
+
// What tadforce doctor already asked or searched, so that it never asks twice.
|
|
41
|
+
setup: {},
|
|
42
|
+
manifest: { mode: 'curated', includePackages: false },
|
|
43
|
+
outputs: { dir: 'out', pdf: true, docx: true },
|
|
44
|
+
review: { tad: { commentsFrom: [], resolutions: {}, dropImages: [] }, axes: { commentsFrom: [], resolutions: {}, dropImages: [] } },
|
|
45
|
+
};
|
|
46
|
+
return deepMerge(cfg, overrides);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function deepMerge(a, b) {
|
|
50
|
+
if (!b || typeof b !== 'object' || Array.isArray(b)) return b === undefined ? a : b;
|
|
51
|
+
const out = { ...(a || {}) };
|
|
52
|
+
for (const [k, v] of Object.entries(b)) {
|
|
53
|
+
out[k] = v && typeof v === 'object' && !Array.isArray(v) && a && typeof a[k] === 'object' && !Array.isArray(a[k]) ? deepMerge(a[k], v) : v;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Locate the workspace: --cwd/<tadforce>, or ./tadforce, or the current dir if it holds the config. */
|
|
59
|
+
export function findWorkspace(cwd = process.cwd()) {
|
|
60
|
+
const candidates = [path.join(cwd, WORKSPACE_DIR), cwd];
|
|
61
|
+
for (const dir of candidates) if (exists(path.join(dir, CONFIG_FILE))) return dir;
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function loadWorkspace(cwd) {
|
|
66
|
+
const ws = findWorkspace(cwd);
|
|
67
|
+
if (!ws) {
|
|
68
|
+
const err = new Error(`No TADForce workspace found under ${cwd}. Run \`npx tadforce init\` first.`);
|
|
69
|
+
err.code = 'NO_WORKSPACE';
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
const cfg = defaultConfig(readJson(path.join(ws, CONFIG_FILE)));
|
|
73
|
+
const project = path.dirname(ws);
|
|
74
|
+
return {
|
|
75
|
+
ws,
|
|
76
|
+
project,
|
|
77
|
+
cfg,
|
|
78
|
+
p: (...parts) => path.resolve(ws, ...parts),
|
|
79
|
+
glossary: () => readYaml(path.join(ws, 'knowledge', 'glossary.yml'), {}),
|
|
80
|
+
exclusions: () => readYaml(path.join(ws, 'knowledge', 'exclusions.yml'), {}),
|
|
81
|
+
save: (next) => writeJson(path.join(ws, CONFIG_FILE), next),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Metadata source directories: the retrieved snapshot and/or the project's package dirs (source.mode). */
|
|
86
|
+
export function sourceDirs(wsCtx) {
|
|
87
|
+
const mode = wsCtx.cfg.source.mode;
|
|
88
|
+
const snap = wsCtx.p(wsCtx.cfg.source.dir);
|
|
89
|
+
const local = localDirs(wsCtx);
|
|
90
|
+
if (mode === 'local') return local;
|
|
91
|
+
if (mode === 'both') return [...(exists(snap) ? [snap] : []), ...local];
|
|
92
|
+
if (exists(snap)) return [snap];
|
|
93
|
+
return local;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function localDirs(wsCtx) {
|
|
97
|
+
const sfdx = path.join(wsCtx.project, 'sfdx-project.json');
|
|
98
|
+
if (exists(sfdx)) {
|
|
99
|
+
const only = (wsCtx.cfg.scope && wsCtx.cfg.scope.packageDirectories) || [];
|
|
100
|
+
const dirs = (readJson(sfdx).packageDirectories || []).filter((d) => !only.length || only.includes(d.path)).map((d) => path.join(wsCtx.project, d.path)).filter(exists);
|
|
101
|
+
if (dirs.length) return dirs;
|
|
102
|
+
}
|
|
103
|
+
return [];
|
|
104
|
+
}
|