fullstackgtm 0.10.1 → 0.11.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/CHANGELOG.md +90 -0
- package/INSTALL_FOR_AGENTS.md +14 -0
- package/README.md +33 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +244 -13
- package/dist/connectors/hubspot.js +101 -4
- package/dist/connectors/hubspotAuth.js +6 -2
- package/dist/connectors/salesforce.js +68 -2
- package/dist/connectors/salesforceAuth.js +17 -3
- package/dist/credentials.d.ts +19 -0
- package/dist/credentials.js +69 -8
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/mcp.js +16 -0
- package/dist/merge.d.ts +1 -0
- package/dist/merge.js +1 -1
- package/dist/report.d.ts +61 -0
- package/dist/report.js +331 -0
- package/dist/rules.d.ts +1 -0
- package/dist/rules.js +49 -1
- package/dist/suggest.d.ts +31 -0
- package/dist/suggest.js +148 -0
- package/docs/api.md +13 -1
- package/docs/crm-health-lifecycle.md +135 -0
- package/llms.txt +1 -0
- package/package.json +1 -1
- package/src/cli.ts +264 -11
- package/src/connectors/hubspot.ts +101 -4
- package/src/connectors/hubspotAuth.ts +6 -2
- package/src/connectors/salesforce.ts +70 -2
- package/src/connectors/salesforceAuth.ts +19 -6
- package/src/credentials.ts +71 -6
- package/src/index.ts +7 -0
- package/src/mcp.ts +22 -0
- package/src/merge.ts +1 -1
- package/src/report.ts +502 -0
- package/src/rules.ts +52 -1
- package/src/suggest.ts +202 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# The CRM-health CRUD lifecycle: no new dupes
|
|
2
|
+
|
|
3
|
+
How fullstackgtm keeps a CRM healthy over time — not just episodically
|
|
4
|
+
audited. Grounded in dogfooding against a real portal (an outreach sync
|
|
5
|
+
tripled five open deals; our own `create:` path nearly minted a duplicate
|
|
6
|
+
company) and in what the platforms actually support today (verified 2026-06).
|
|
7
|
+
|
|
8
|
+
## The model: Prevent → Detect → Remediate → Verify/Attribute
|
|
9
|
+
|
|
10
|
+
Every mature dedupe stack (RingLead/ZoomInfo Ops, Insycle, Openprise,
|
|
11
|
+
Dedupely) converged on three layers, because each one leaks:
|
|
12
|
+
|
|
13
|
+
1. **Prevent** at write time — unique keys, upserts, point-of-entry gates.
|
|
14
|
+
Leaks because: HubSpot does not dedupe companies created via API at all,
|
|
15
|
+
deals have no native dedupe on any platform, and Salesforce duplicate
|
|
16
|
+
rules skip standard lead conversion.
|
|
17
|
+
2. **Detect** continuously — scheduled scans, because prevention leaked.
|
|
18
|
+
3. **Remediate** via survivor-rule-driven merge — irreversible on both
|
|
19
|
+
platforms, which is why preview/dry-run is table stakes.
|
|
20
|
+
|
|
21
|
+
fullstackgtm adds the layer the industry mostly lacks: **Verify/Attribute** —
|
|
22
|
+
typed, approved operations with compare-and-set, readback, drift diffs, and
|
|
23
|
+
record-source provenance that names *which writer* created the mess, so you
|
|
24
|
+
fix the faucet instead of mopping the puddle.
|
|
25
|
+
|
|
26
|
+
## C — Create: gate the faucet
|
|
27
|
+
|
|
28
|
+
**Platform facts to exploit before building anything:**
|
|
29
|
+
|
|
30
|
+
| | HubSpot | Salesforce |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| Contacts | API create with existing email → **409 with existing ID** (a free find-or-create) | Duplicate Rules fire on API writes (`DUPLICATES_DETECTED`); Alert-action rules bypassable via `DuplicateRuleHeader`, Block never |
|
|
33
|
+
| Companies/Accounts | **No API dedupe** (UI/import domain-dedupe does not apply to API) | Same duplicate-rule machinery |
|
|
34
|
+
| Deals/Opps | **No native dedupe anywhere** | No standard duplicate rule shipped |
|
|
35
|
+
| Idempotent writes | `batch/upsert` keyed on unique-value custom properties (≤10/object) | Upsert by External ID field — explicitly idempotent |
|
|
36
|
+
| Leak paths | API company creates; anything without email | Standard lead conversion, Quick Create, undelete |
|
|
37
|
+
|
|
38
|
+
**Our primitives and their duties:**
|
|
39
|
+
- `create:<Name>` link values must be **resolve-first**: search the live CRM
|
|
40
|
+
(and the current plan run) for an existing record before creating; link to
|
|
41
|
+
a unique match, refuse on ambiguity, create only on a confirmed miss.
|
|
42
|
+
HubSpot's search API is eventually consistent (~5–10s), so same-run
|
|
43
|
+
creations are deduped in memory, not via search.
|
|
44
|
+
- A standalone **`resolve` gate** (planned, 0.13): given a candidate
|
|
45
|
+
record, return existing match(es) or "safe to create" — for the CLI, the
|
|
46
|
+
library, MCP, and any external writer (sync jobs, agents, webhook
|
|
47
|
+
handlers). Identity keys are the ones the package already uses:
|
|
48
|
+
contact email, normalized account domain, and the open-deal key
|
|
49
|
+
(account + normalized name).
|
|
50
|
+
- **Stamp provenance on our own creates** (HubSpot allows integrations to
|
|
51
|
+
set `hs_object_source_detail_2/3` at create time).
|
|
52
|
+
- Recommend native config in `doctor`/audit: Salesforce duplicate rules
|
|
53
|
+
active? HubSpot unique-value properties defined? Prevention posture is
|
|
54
|
+
auditable configuration, not just record state.
|
|
55
|
+
|
|
56
|
+
## R — Read: watch continuously, attribute the source
|
|
57
|
+
|
|
58
|
+
- The regression primitive exists: `fullstackgtm diff --before a.json
|
|
59
|
+
--after b.json --fail-on-new-findings` exits 2 when a (rule, record) pair
|
|
60
|
+
fires that didn't before. "New" is a stable finding id — the hash of
|
|
61
|
+
(ruleId, objectId).
|
|
62
|
+
- **The nightly watch recipe** ("CRM CI"): scheduled
|
|
63
|
+
`snapshot → audit → diff` against yesterday's snapshot, alert on exit 2.
|
|
64
|
+
- **Attribution** (planned, 0.13): capture HubSpot's read-only
|
|
65
|
+
`hs_object_source`, `hs_object_source_label`, `hs_object_source_id` into
|
|
66
|
+
the canonical model so duplicate findings can say *"all five created by
|
|
67
|
+
integration X"*. The fix for recurring dupes is upstream, in the writer.
|
|
68
|
+
- Incremental reads (`snapshot --since`) exist for all three connectors;
|
|
69
|
+
caveats: HubSpot deltas carry no associations and cap at 10k per object,
|
|
70
|
+
Stripe deltas catch creations only.
|
|
71
|
+
|
|
72
|
+
## U — Update: governed merge
|
|
73
|
+
|
|
74
|
+
**Platform facts:** HubSpot's v3 merge endpoint
|
|
75
|
+
(`POST /crm/v3/objects/{type}/merge`) supports contacts, companies,
|
|
76
|
+
**deals**, and tickets today (the 2019 "no deal merge API" changelog is
|
|
77
|
+
obsolete). Merges are pairwise, the loser is auto-archived, primary's
|
|
78
|
+
values win, **merges cannot be undone**, and a record stops merging after
|
|
79
|
+
250 cumulative merges. Salesforce merge is SOAP/Apex only (no REST), only
|
|
80
|
+
Lead/Contact/Account/Case, max 3 records per call.
|
|
81
|
+
|
|
82
|
+
**The gap:** our three duplicate rules (`duplicate-account-domain`,
|
|
83
|
+
`duplicate-contact-email`, `duplicate-open-deal`) detect groups but emit
|
|
84
|
+
only merge-review *tasks* — detection without remediation.
|
|
85
|
+
|
|
86
|
+
**The plan (0.12):** a `merge_records` operation type —
|
|
87
|
+
`requires_human_survivor_selection` placeholder, survivor heuristics in
|
|
88
|
+
`suggest` (ordered, evidence-based: most engagements → oldest → most
|
|
89
|
+
complete, each with a written reason), high risk, approval required, with
|
|
90
|
+
the irreversibility called out in the plan text. The dry-run plan is the
|
|
91
|
+
preview every commercial tool charges for; the pre-apply snapshot is the
|
|
92
|
+
loser-record archive. HubSpot first; Salesforce merge documented as
|
|
93
|
+
unsupported until an Apex path justifies itself.
|
|
94
|
+
|
|
95
|
+
## D — Delete/Archive: the exit ramp
|
|
96
|
+
|
|
97
|
+
- `archive_record` exists in both connectors (HubSpot DELETE = archive,
|
|
98
|
+
restorable ~90 days; Salesforce DELETE = recycle bin) but no built-in
|
|
99
|
+
rule emits it. It is the endpoint for reviewed orphans; merge losers are
|
|
100
|
+
archived by the merge APIs themselves.
|
|
101
|
+
- All destructive operations stay high-risk, approval-required, and behind
|
|
102
|
+
compare-and-set where the platform exposes a readable before-value.
|
|
103
|
+
|
|
104
|
+
## Write-path integrity rules (our own faucet)
|
|
105
|
+
|
|
106
|
+
Lessons from auditing our own apply path:
|
|
107
|
+
|
|
108
|
+
1. Field writes (`set_field`/`clear_field`/`link_record`) are protected by
|
|
109
|
+
compare-and-set: the live value is read back and a drifted value returns
|
|
110
|
+
`conflict` without writing.
|
|
111
|
+
2. CAS is only as good as `readField` — for HubSpot deals, `accountId` is
|
|
112
|
+
an association, not a property, and must be read via the associations
|
|
113
|
+
API or CAS silently passes on replay (fixed in 0.11.1).
|
|
114
|
+
3. `create:` values are resolve-first and deduped within a plan run
|
|
115
|
+
(0.11.1); `create_task` operations carry an idempotency token in the
|
|
116
|
+
task body and pre-check for it (0.11.1, fail-open on search errors).
|
|
117
|
+
4. Plan replays are blocked at the store (`--plan-id` of an applied plan
|
|
118
|
+
refuses); the `--plan <file>` path relies on CAS — prefer the store.
|
|
119
|
+
|
|
120
|
+
## The operating cadence
|
|
121
|
+
|
|
122
|
+
**Gate** creates (resolve-first, upserts, native rules on) →
|
|
123
|
+
**Watch** nightly (snapshot, diff, exit-2 alert) →
|
|
124
|
+
**Fix** governed (audit → suggest → approve → apply, incl. merge) →
|
|
125
|
+
**Verify** (readback, re-audit, drift report) →
|
|
126
|
+
**Attribute** (provenance names the writer; fix the faucet).
|
|
127
|
+
|
|
128
|
+
## Build order
|
|
129
|
+
|
|
130
|
+
| Release | Scope |
|
|
131
|
+
| --- | --- |
|
|
132
|
+
| 0.11.1 | Fix our own faucet: resolve-first `create:` + plan-scoped dedup, HubSpot association-aware CAS for `link_record`, domain normalization in `duplicate-account-domain`, `create_task` idempotency token |
|
|
133
|
+
| 0.12 | `merge_records` (HubSpot contacts/companies/deals) + survivor suggestions; rules emit governed merges for dupe groups |
|
|
134
|
+
| 0.13 | `resolve` gate (CLI/lib/MCP), provenance capture + attribution in findings, prevention-posture checks |
|
|
135
|
+
| docs | The nightly watch recipe (existing flags, documented as CRM CI) |
|
package/llms.txt
CHANGED
|
@@ -15,6 +15,7 @@ at/above `--fail-on`.
|
|
|
15
15
|
- [README](https://github.com/fullstackgtm/core/blob/main/README.md): install, five-minute loop, auth ladder, MCP setup, programmatic use
|
|
16
16
|
- [INSTALL_FOR_AGENTS](https://github.com/fullstackgtm/core/blob/main/INSTALL_FOR_AGENTS.md): deterministic install-and-verify steps with expected outputs
|
|
17
17
|
- [API reference](https://github.com/fullstackgtm/core/blob/main/docs/api.md): semver-covered surfaces — canonical model, rule interface, plan/apply contract, connector contract, config, CLI, MCP tools
|
|
18
|
+
- [CRM-health lifecycle](https://github.com/fullstackgtm/core/blob/main/docs/crm-health-lifecycle.md): the Prevent → Detect → Remediate → Verify/Attribute model; no-new-dupes design
|
|
18
19
|
- [CHANGELOG](https://github.com/fullstackgtm/core/blob/main/CHANGELOG.md): release history
|
|
19
20
|
|
|
20
21
|
## Key invariants
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Full Stack GTM",
|
package/src/cli.ts
CHANGED
|
@@ -19,11 +19,15 @@ import {
|
|
|
19
19
|
validateSalesforceToken,
|
|
20
20
|
} from "./connectors/salesforceAuth.ts";
|
|
21
21
|
import {
|
|
22
|
+
activeProfile,
|
|
22
23
|
credentialsPath,
|
|
24
|
+
DEFAULT_PROFILE,
|
|
23
25
|
deleteCredential,
|
|
24
26
|
getCredential,
|
|
27
|
+
listProfiles,
|
|
25
28
|
resolveHubspotConnection,
|
|
26
29
|
resolveSalesforceConnection,
|
|
30
|
+
setActiveProfile,
|
|
27
31
|
storeCredential,
|
|
28
32
|
type StoredCredential,
|
|
29
33
|
} from "./credentials.ts";
|
|
@@ -31,8 +35,10 @@ import { generateDemoSnapshot } from "./demo.ts";
|
|
|
31
35
|
import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
32
36
|
import { mergeSnapshots } from "./merge.ts";
|
|
33
37
|
import { createFilePlanStore } from "./planStore.ts";
|
|
38
|
+
import { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
|
|
34
39
|
import { builtinAuditRules } from "./rules.ts";
|
|
35
40
|
import { sampleSnapshot } from "./sampleData.ts";
|
|
41
|
+
import { suggestValues, type ValueSuggestion } from "./suggest.ts";
|
|
36
42
|
import type { FieldMappings } from "./mappings.ts";
|
|
37
43
|
import type {
|
|
38
44
|
AuditFindingSeverity,
|
|
@@ -60,14 +66,28 @@ Usage:
|
|
|
60
66
|
echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot
|
|
61
67
|
fullstackgtm snapshot [source options] [--since <iso>] [--out <path> | --archive <dir>]
|
|
62
68
|
fullstackgtm audit [source options] [audit options] [--save]
|
|
69
|
+
fullstackgtm report [source options] [audit options] [report options]
|
|
63
70
|
fullstackgtm diff --before <a.json> --after <b.json> [--json] [--fail-on-new-findings]
|
|
64
71
|
fullstackgtm merge --input <a.json> --input <b.json> [...] --out <merged.json> [--json]
|
|
65
|
-
fullstackgtm
|
|
72
|
+
fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
|
|
73
|
+
derive values for requires_human_* placeholders
|
|
74
|
+
from snapshot evidence, with confidence + reasons
|
|
75
|
+
fullstackgtm plans list [--status <s>] | show <id> | reject <id>
|
|
76
|
+
fullstackgtm plans approve <id> --operations <ids|all> [--value <opId>=<v>]
|
|
77
|
+
fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low] [--include-creates]
|
|
66
78
|
fullstackgtm apply --plan-id <id> --provider <name>
|
|
67
79
|
fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [options]
|
|
68
80
|
fullstackgtm rules [--json]
|
|
81
|
+
fullstackgtm profiles [--json] list credential profiles
|
|
69
82
|
fullstackgtm doctor [--json] check install, credentials, and next step
|
|
70
83
|
|
|
84
|
+
Profiles (multi-organization use):
|
|
85
|
+
--profile <name> Scope credentials AND stored plans to a named profile
|
|
86
|
+
(also: FULLSTACKGTM_PROFILE). One profile per client
|
|
87
|
+
org keeps logins isolated and prevents a plan proposed
|
|
88
|
+
against one CRM from being applied through another's
|
|
89
|
+
credentials. Omitted = the default profile.
|
|
90
|
+
|
|
71
91
|
Plan lifecycle:
|
|
72
92
|
audit --save persists the dry-run plan to ~/.fullstackgtm/plans. Approve
|
|
73
93
|
specific operations (optionally with --value <opId>=<v> for placeholders),
|
|
@@ -104,6 +124,17 @@ Audit options:
|
|
|
104
124
|
--stale-days <n> Days without activity before an open deal is stale
|
|
105
125
|
--fail-on <severity> Exit 2 if any finding is at or above info|warning|critical
|
|
106
126
|
|
|
127
|
+
Report options (report renders the audit as a client-ready deliverable):
|
|
128
|
+
--plan <path> Render an existing plan JSON instead of re-auditing
|
|
129
|
+
(add --input <snapshot.json> for record counts)
|
|
130
|
+
--client <name> Organization name shown in the heading and summary
|
|
131
|
+
--title <text> Report heading (default "GTM Data Health Report")
|
|
132
|
+
--prepared-by <name> Attribution shown in the footer
|
|
133
|
+
--format <fmt> markdown (default) or html (self-contained, printable;
|
|
134
|
+
inferred from an --out path ending in .html)
|
|
135
|
+
--max-examples <n> Example records listed per rule (default 10)
|
|
136
|
+
--out <path> Write the report to a file instead of stdout
|
|
137
|
+
|
|
107
138
|
Apply options:
|
|
108
139
|
--plan <path> Patch plan JSON produced by \`audit --out\`
|
|
109
140
|
--provider hubspot Connector to apply through
|
|
@@ -334,6 +365,64 @@ async function audit(args: string[]) {
|
|
|
334
365
|
}
|
|
335
366
|
}
|
|
336
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Render an audit as a client-facing deliverable. Same sources and audit
|
|
370
|
+
* options as `audit`; `--plan` instead renders an existing plan JSON without
|
|
371
|
+
* re-fetching (useful for a plan produced earlier or by another machine).
|
|
372
|
+
*/
|
|
373
|
+
async function reportCommand(args: string[]) {
|
|
374
|
+
const loaded = loadConfig(option(args, "--config") ?? undefined);
|
|
375
|
+
const configuredRules = await resolveConfiguredRules(loaded);
|
|
376
|
+
|
|
377
|
+
let plan: PatchPlan;
|
|
378
|
+
let snapshot: CanonicalGtmSnapshot | undefined;
|
|
379
|
+
const planPath = option(args, "--plan");
|
|
380
|
+
if (planPath) {
|
|
381
|
+
plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8")) as PatchPlan;
|
|
382
|
+
const input = option(args, "--input");
|
|
383
|
+
if (input) {
|
|
384
|
+
snapshot = JSON.parse(
|
|
385
|
+
readFileSync(resolve(process.cwd(), input), "utf8"),
|
|
386
|
+
) as CanonicalGtmSnapshot;
|
|
387
|
+
}
|
|
388
|
+
} else {
|
|
389
|
+
snapshot = await readSnapshot(args);
|
|
390
|
+
const policy = mergePolicy(defaultPolicy(), loaded?.config);
|
|
391
|
+
const today = option(args, "--today");
|
|
392
|
+
if (today) policy.today = today;
|
|
393
|
+
const staleDealDays = numericOption(args, "--stale-days");
|
|
394
|
+
if (staleDealDays !== undefined) policy.staleDealDays = staleDealDays;
|
|
395
|
+
plan = auditSnapshot(snapshot, policy, selectedRules(args, configuredRules));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const reportOptions: ReportOptions = {
|
|
399
|
+
title: option(args, "--title") ?? undefined,
|
|
400
|
+
clientName: option(args, "--client") ?? undefined,
|
|
401
|
+
preparedBy: option(args, "--prepared-by") ?? undefined,
|
|
402
|
+
date: option(args, "--today") ?? undefined,
|
|
403
|
+
maxExamplesPerRule: numericOption(args, "--max-examples"),
|
|
404
|
+
rules: configuredRules,
|
|
405
|
+
snapshot,
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const out = option(args, "--out");
|
|
409
|
+
const format = option(args, "--format") ?? (out?.endsWith(".html") ? "html" : "markdown");
|
|
410
|
+
if (format !== "markdown" && format !== "html") {
|
|
411
|
+
throw new Error("--format must be markdown or html");
|
|
412
|
+
}
|
|
413
|
+
const rendered =
|
|
414
|
+
format === "html"
|
|
415
|
+
? auditReportToHtml(plan, reportOptions)
|
|
416
|
+
: auditReportToMarkdown(plan, reportOptions);
|
|
417
|
+
|
|
418
|
+
if (out) {
|
|
419
|
+
writeFileSync(resolve(process.cwd(), out), rendered);
|
|
420
|
+
console.log(`Wrote ${format} report (${plan.findings.length} findings) to ${out}`);
|
|
421
|
+
} else {
|
|
422
|
+
console.log(rendered.trimEnd());
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
337
426
|
async function rulesCommand(args: string[]) {
|
|
338
427
|
const loaded = loadConfig(option(args, "--config") ?? undefined);
|
|
339
428
|
const rules = await resolveConfiguredRules(loaded);
|
|
@@ -376,6 +465,83 @@ function parseValueOverrides(args: string[]) {
|
|
|
376
465
|
return valueOverrides;
|
|
377
466
|
}
|
|
378
467
|
|
|
468
|
+
async function suggest(args: string[]) {
|
|
469
|
+
const planId = option(args, "--plan-id");
|
|
470
|
+
const planPath = option(args, "--plan");
|
|
471
|
+
if (!planId && !planPath) throw new Error("suggest requires --plan <path> or --plan-id <id>");
|
|
472
|
+
let plan: PatchPlan;
|
|
473
|
+
if (planId) {
|
|
474
|
+
const stored = await createFilePlanStore().get(planId);
|
|
475
|
+
if (!stored) throw new Error(`No stored plan with id ${planId}.`);
|
|
476
|
+
plan = stored.plan;
|
|
477
|
+
} else {
|
|
478
|
+
plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath!), "utf8")) as PatchPlan;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const snapshot = await readSnapshot(args);
|
|
482
|
+
const suggestions = suggestValues(plan, snapshot);
|
|
483
|
+
const payload = { planId: planId ?? planPath, suggestions };
|
|
484
|
+
|
|
485
|
+
const outPath = option(args, "--out");
|
|
486
|
+
if (outPath) writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(payload, null, 2)}\n`);
|
|
487
|
+
|
|
488
|
+
if (args.includes("--json")) {
|
|
489
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (suggestions.length === 0) {
|
|
494
|
+
console.log("No requires_human_* placeholder operations in this plan — nothing to suggest.");
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const byConfidence: Record<string, number> = {};
|
|
498
|
+
for (const s of suggestions) byConfidence[s.confidence] = (byConfidence[s.confidence] ?? 0) + 1;
|
|
499
|
+
console.log(`Suggestions for ${suggestions.length} placeholder operation(s):\n`);
|
|
500
|
+
for (const s of suggestions) {
|
|
501
|
+
const marker =
|
|
502
|
+
s.confidence === "high" ? "✓" : s.confidence === "low" ? "~" : s.confidence === "create" ? "+" : "✗";
|
|
503
|
+
console.log(`${marker} [${s.confidence}] ${s.operationId} ${s.objectName ?? s.objectId}`);
|
|
504
|
+
console.log(` ${s.suggestedValue ? `→ ${s.suggestedValue}` : "(no suggestion)"} — ${s.reason}`);
|
|
505
|
+
}
|
|
506
|
+
console.log(`\n${Object.entries(byConfidence).map(([k, v]) => `${k}: ${v}`).join(" · ")}`);
|
|
507
|
+
if (planId && (byConfidence.high ?? 0) > 0 && !outPath) {
|
|
508
|
+
console.log(
|
|
509
|
+
`\nChain it:\n fullstackgtm suggest --plan-id ${planId} ${snapshotSourceHint(args)}--out suggestions.json\n fullstackgtm plans approve ${planId} --values-from suggestions.json\n fullstackgtm apply --plan-id ${planId} --provider <name>`,
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function snapshotSourceHint(args: string[]) {
|
|
515
|
+
const provider = option(args, "--provider");
|
|
516
|
+
if (provider) return `--provider ${provider} `;
|
|
517
|
+
const input = option(args, "--input");
|
|
518
|
+
if (input) return `--input ${input} `;
|
|
519
|
+
return "";
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function readSuggestionValues(path: string, minConfidence: string, includeCreates: boolean) {
|
|
523
|
+
const raw = JSON.parse(readFileSync(resolve(process.cwd(), path), "utf8")) as {
|
|
524
|
+
suggestions?: ValueSuggestion[];
|
|
525
|
+
};
|
|
526
|
+
if (!Array.isArray(raw.suggestions)) {
|
|
527
|
+
throw new Error(
|
|
528
|
+
`${path} is not a suggestions file (expected { suggestions: [...] } from \`fullstackgtm suggest --out\`).`,
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
const accepted = new Set(minConfidence === "low" ? ["high", "low"] : ["high"]);
|
|
532
|
+
const overrides: Record<string, string> = {};
|
|
533
|
+
let skipped = 0;
|
|
534
|
+
for (const s of raw.suggestions) {
|
|
535
|
+
if (!s.suggestedValue) continue;
|
|
536
|
+
if (accepted.has(s.confidence) || (includeCreates && s.confidence === "create")) {
|
|
537
|
+
overrides[s.operationId] = s.suggestedValue;
|
|
538
|
+
} else {
|
|
539
|
+
skipped += 1;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return { overrides, skipped };
|
|
543
|
+
}
|
|
544
|
+
|
|
379
545
|
async function apply(args: string[]) {
|
|
380
546
|
const provider = option(args, "--provider");
|
|
381
547
|
if (!provider) throw new Error("apply requires --provider <name>");
|
|
@@ -565,16 +731,50 @@ async function plansCommand(args: string[]) {
|
|
|
565
731
|
|
|
566
732
|
if (subcommand === "approve") {
|
|
567
733
|
const planId = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
|
|
568
|
-
if (!planId) throw new Error("Usage: fullstackgtm plans approve <planId> --operations <ids|all>");
|
|
734
|
+
if (!planId) throw new Error("Usage: fullstackgtm plans approve <planId> --operations <ids|all> | --values-from <suggestions.json>");
|
|
569
735
|
const operations = option(rest, "--operations");
|
|
570
|
-
|
|
736
|
+
const valuesFrom = option(rest, "--values-from");
|
|
737
|
+
if (!operations && !valuesFrom) {
|
|
738
|
+
throw new Error("plans approve requires --operations <ids|all> and/or --values-from <suggestions.json>");
|
|
739
|
+
}
|
|
571
740
|
const stored = await store.get(planId);
|
|
572
741
|
if (!stored) throw new Error(`No stored plan with id ${planId}.`);
|
|
742
|
+
|
|
743
|
+
// Values from a `fullstackgtm suggest --out` file. High-confidence only by
|
|
744
|
+
// default; widen with --min-confidence low, opt into record-creating
|
|
745
|
+
// values (create:<Name>) with --include-creates. Explicit --value wins.
|
|
746
|
+
let fileOverrides: Record<string, string> = {};
|
|
747
|
+
if (valuesFrom) {
|
|
748
|
+
const minConfidence = option(rest, "--min-confidence") ?? "high";
|
|
749
|
+
if (!["high", "low"].includes(minConfidence)) {
|
|
750
|
+
throw new Error("--min-confidence must be high or low");
|
|
751
|
+
}
|
|
752
|
+
const { overrides, skipped } = readSuggestionValues(
|
|
753
|
+
valuesFrom,
|
|
754
|
+
minConfidence,
|
|
755
|
+
rest.includes("--include-creates"),
|
|
756
|
+
);
|
|
757
|
+
fileOverrides = overrides;
|
|
758
|
+
if (Object.keys(overrides).length === 0) {
|
|
759
|
+
throw new Error(
|
|
760
|
+
`No suggestions in ${valuesFrom} meet the confidence bar (${skipped} below it). Re-run with --min-confidence low or --include-creates, or pass explicit --value overrides.`,
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
if (skipped > 0) {
|
|
764
|
+
console.log(`Skipped ${skipped} suggestion(s) below the confidence bar (widen with --min-confidence low / --include-creates).`);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const explicitOverrides = parseValueOverrides(rest);
|
|
573
768
|
const operationIds =
|
|
574
769
|
operations === "all"
|
|
575
770
|
? stored.plan.operations.map((operation) => operation.id)
|
|
576
|
-
: operations
|
|
577
|
-
|
|
771
|
+
: operations
|
|
772
|
+
? operations.split(",").map((id) => id.trim()).filter(Boolean)
|
|
773
|
+
: Object.keys(fileOverrides);
|
|
774
|
+
const updated = await store.approveOperations(planId, operationIds, {
|
|
775
|
+
...fileOverrides,
|
|
776
|
+
...explicitOverrides,
|
|
777
|
+
});
|
|
578
778
|
console.log(
|
|
579
779
|
`Approved ${updated.approvedOperationIds.length} operation(s) on ${planId}. Apply with \`fullstackgtm apply --plan-id ${planId} --provider <name>\`.`,
|
|
580
780
|
);
|
|
@@ -656,11 +856,17 @@ async function brokerLogin(baseUrl: string) {
|
|
|
656
856
|
// Self-reported, shown to the approver so they can recognize this request
|
|
657
857
|
// and refuse one they didn't initiate.
|
|
658
858
|
const requesterLabel = `${os.hostname()} (${process.platform}, ${os.userInfo().username})`;
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
859
|
+
let startResponse: Response;
|
|
860
|
+
try {
|
|
861
|
+
startResponse = await fetch(`${base}/api/cli/auth/start`, {
|
|
862
|
+
method: "POST",
|
|
863
|
+
headers: { "Content-Type": "application/json" },
|
|
864
|
+
body: JSON.stringify({ requesterLabel }),
|
|
865
|
+
});
|
|
866
|
+
} catch (error) {
|
|
867
|
+
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
868
|
+
throw new Error(`Cannot reach the hosted deployment at ${base}${cause}. Check the --via URL and network access.`);
|
|
869
|
+
}
|
|
664
870
|
if (!startResponse.ok) {
|
|
665
871
|
throw new Error(
|
|
666
872
|
`Could not start pairing with ${base} (${startResponse.status}). Is this a FullStackGTM deployment?`,
|
|
@@ -926,6 +1132,7 @@ export function doctorReport(env: Record<string, string | undefined> = process.e
|
|
|
926
1132
|
return {
|
|
927
1133
|
package: packageInfo,
|
|
928
1134
|
node: { version: process.versions.node, ok: nodeMajor >= 20, required: ">=20" },
|
|
1135
|
+
profile: activeProfile(),
|
|
929
1136
|
credentialStore: { path: storePath, exists: existsSync(storePath) },
|
|
930
1137
|
config: { path: configPath, exists: existsSync(configPath) },
|
|
931
1138
|
providers,
|
|
@@ -967,6 +1174,7 @@ function doctorCommand(args: string[]) {
|
|
|
967
1174
|
const lines = [
|
|
968
1175
|
`Package: ${report.package.name} ${report.package.version}`,
|
|
969
1176
|
`Node: v${report.node.version} (${report.node.required} required) ${mark(report.node.ok)}`,
|
|
1177
|
+
`Profile: ${report.profile}${report.profile === DEFAULT_PROFILE ? "" : " (named profile — credentials and plans are scoped to it)"}`,
|
|
970
1178
|
`Cred store: ${report.credentialStore.path} (${report.credentialStore.exists ? "present" : "not created yet — created on first login"})`,
|
|
971
1179
|
`Config: ${report.config.exists ? report.config.path : "none — defaults apply"}`,
|
|
972
1180
|
"",
|
|
@@ -987,8 +1195,41 @@ function doctorCommand(args: string[]) {
|
|
|
987
1195
|
if (!report.node.ok) process.exitCode = 1;
|
|
988
1196
|
}
|
|
989
1197
|
|
|
1198
|
+
/**
|
|
1199
|
+
* Pull the global `--profile <name>` flag out of argv (it may appear before
|
|
1200
|
+
* or after the command) and activate it. Stripping it keeps positional
|
|
1201
|
+
* detection in subcommands — `login <provider>`, `plans show <id>` — simple.
|
|
1202
|
+
*/
|
|
1203
|
+
function extractProfile(argv: string[]): string[] {
|
|
1204
|
+
const index = argv.indexOf("--profile");
|
|
1205
|
+
if (index === -1) return argv;
|
|
1206
|
+
const name = argv[index + 1];
|
|
1207
|
+
if (!name) throw new Error("--profile requires a name, e.g. --profile acme");
|
|
1208
|
+
setActiveProfile(name);
|
|
1209
|
+
return [...argv.slice(0, index), ...argv.slice(index + 2)];
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function profilesCommand(args: string[]) {
|
|
1213
|
+
const profiles = listProfiles();
|
|
1214
|
+
const current = activeProfile();
|
|
1215
|
+
if (args.includes("--json")) {
|
|
1216
|
+
console.log(JSON.stringify({ active: current, profiles }, null, 2));
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
for (const profile of profiles) {
|
|
1220
|
+
console.log(`${profile === current ? "*" : " "} ${profile}`);
|
|
1221
|
+
}
|
|
1222
|
+
if (!profiles.includes(current)) {
|
|
1223
|
+
console.log(`* ${current} (selected; created on first login)`);
|
|
1224
|
+
}
|
|
1225
|
+
console.log(
|
|
1226
|
+
"\nSelect with --profile <name> on any command, or set FULLSTACKGTM_PROFILE. " +
|
|
1227
|
+
"Each profile keeps its own credentials and stored plans.",
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
990
1231
|
export async function runCli(argv: string[]) {
|
|
991
|
-
const [command, ...args] = argv;
|
|
1232
|
+
const [command, ...args] = extractProfile(argv);
|
|
992
1233
|
if (!command || command === "--help" || command === "-h") {
|
|
993
1234
|
console.log(usage());
|
|
994
1235
|
return;
|
|
@@ -1014,6 +1255,10 @@ export async function runCli(argv: string[]) {
|
|
|
1014
1255
|
await audit(args);
|
|
1015
1256
|
return;
|
|
1016
1257
|
}
|
|
1258
|
+
if (command === "report") {
|
|
1259
|
+
await reportCommand(args);
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1017
1262
|
if (command === "rules") {
|
|
1018
1263
|
await rulesCommand(args);
|
|
1019
1264
|
return;
|
|
@@ -1022,6 +1267,14 @@ export async function runCli(argv: string[]) {
|
|
|
1022
1267
|
doctorCommand(args);
|
|
1023
1268
|
return;
|
|
1024
1269
|
}
|
|
1270
|
+
if (command === "suggest") {
|
|
1271
|
+
await suggest(args);
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
if (command === "profiles") {
|
|
1275
|
+
profilesCommand(args);
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1025
1278
|
if (command === "diff") {
|
|
1026
1279
|
await diffCommand(args);
|
|
1027
1280
|
return;
|