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
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,96 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
5
5
|
and the project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
|
|
7
7
|
|
|
8
|
+
## [0.11.1] — 2026-06-11
|
|
9
|
+
|
|
10
|
+
Write-path integrity: fixes our own dupe faucets, found auditing the apply
|
|
11
|
+
path for the new [CRM-health lifecycle](./docs/crm-health-lifecycle.md) doc.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **docs/crm-health-lifecycle.md**: the full CRUD lifecycle for keeping a
|
|
16
|
+
CRM healthy — Prevent → Detect → Remediate → Verify/Attribute — grounded
|
|
17
|
+
in verified platform behavior (HubSpot/Salesforce dedupe and merge
|
|
18
|
+
support) and the build order toward governed merges and a resolve gate.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **`create:<Name>` is now resolve-first**: it links to an unambiguous
|
|
23
|
+
existing company/account instead of creating, refuses on ambiguity, and
|
|
24
|
+
creates only on a confirmed miss — and never creates the same name twice
|
|
25
|
+
within one apply run (HubSpot search is eventually consistent, so the
|
|
26
|
+
same-run record is authoritative).
|
|
27
|
+
- **HubSpot compare-and-set on `link_record` is no longer blind**:
|
|
28
|
+
`readField("deal"|"contact", id, "accountId")` reads the actual company
|
|
29
|
+
association (it is not a property), so replaying an applied link returns
|
|
30
|
+
`conflict` instead of silently re-creating companies.
|
|
31
|
+
- **`create_task` is idempotent**: the operation id is stamped into the
|
|
32
|
+
task body as a token and pre-checked (fail-open), so replayed plans no
|
|
33
|
+
longer duplicate merge-review tasks on either provider.
|
|
34
|
+
- **`duplicate-account-domain` normalizes domains** the same way merge
|
|
35
|
+
does — `www.acme.com`, `https://acme.com/about`, and `acme.com` now
|
|
36
|
+
group as duplicates.
|
|
37
|
+
|
|
38
|
+
## [0.11.0] — 2026-06-11
|
|
39
|
+
|
|
40
|
+
Canonicalizes the paths discovered dogfooding against a real portal: the
|
|
41
|
+
suggest chain (every `requires_human_account_selection` answer was derivable
|
|
42
|
+
from the snapshot but required ad-hoc scripting), governed record creation,
|
|
43
|
+
client-ready reports, and multi-org profiles.
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- **`fullstackgtm suggest --plan-id <id> | --plan <path> [source options]`**:
|
|
48
|
+
deterministic value suggestions for `requires_human_*` placeholder
|
|
49
|
+
operations, derived from snapshot evidence — account-name matching
|
|
50
|
+
cross-checked against contact→account associations, plus the
|
|
51
|
+
only-active-user case for owner selection. Every suggestion carries a
|
|
52
|
+
confidence level (`high`/`low`/`create`/`none`) and a written reason;
|
|
53
|
+
conflicting or ambiguous evidence yields no suggestion, never a guess.
|
|
54
|
+
`--out` writes a suggestions file; `--json` for agents. Exposed
|
|
55
|
+
programmatically as `suggestValues` and over MCP as `fullstackgtm_suggest`.
|
|
56
|
+
- **`plans approve <id> --values-from <suggestions.json>`**: bulk-approve
|
|
57
|
+
with suggested values — high-confidence only by default,
|
|
58
|
+
`--min-confidence low` and `--include-creates` widen the bar explicitly.
|
|
59
|
+
Explicit `--value` flags still win.
|
|
60
|
+
- **`create:<Name>` link values**: approving a `link_record` operation with
|
|
61
|
+
`--value <op>=create:Acme` creates the company (HubSpot) / account
|
|
62
|
+
(Salesforce) and links to it in one audited operation — record creation
|
|
63
|
+
stays inside the typed, human-approved model instead of a side channel.
|
|
64
|
+
- New builtin rule `duplicate-open-deal` (data-quality): flags multiple open
|
|
65
|
+
deals sharing a normalized name, scoped to the account when linked —
|
|
66
|
+
typically an integration re-creating deals instead of upserting, which
|
|
67
|
+
counts the same revenue several times in pipeline and forecast. Emits one
|
|
68
|
+
finding and one approval-gated merge-review task per duplicate group.
|
|
69
|
+
Found dogfooding: an outreach-tool sync had tripled five open deals in our
|
|
70
|
+
own portal and no existing rule caught it.
|
|
71
|
+
- `fullstackgtm report` — render an audit (or an existing plan via `--plan`)
|
|
72
|
+
as a client-ready deliverable in markdown or self-contained HTML:
|
|
73
|
+
at-a-glance metrics, prose summary, per-rule detail with capped example
|
|
74
|
+
records (`--max-examples`), and recommended next steps. `--client`,
|
|
75
|
+
`--title`, `--prepared-by`, and `--format` customize the output;
|
|
76
|
+
`--out report.html` infers HTML. Exposed programmatically as
|
|
77
|
+
`auditReportToMarkdown` / `auditReportToHtml`.
|
|
78
|
+
- Credential profiles for multi-organization use: the global
|
|
79
|
+
`--profile <name>` flag (or `FULLSTACKGTM_PROFILE`) scopes stored logins
|
|
80
|
+
AND stored plans to `profiles/<name>/` under the fullstackgtm home, so one
|
|
81
|
+
operator can hold several clients' credentials without mixing them — and a
|
|
82
|
+
plan proposed against one org's CRM can never be applied through
|
|
83
|
+
another's. New `profiles` command lists them; `doctor` reports the active
|
|
84
|
+
profile. The default profile keeps the existing flat layout, so current
|
|
85
|
+
installs are unaffected. Exposed programmatically as `setActiveProfile`,
|
|
86
|
+
`activeProfile`, `listProfiles`, and `DEFAULT_PROFILE`.
|
|
87
|
+
|
|
88
|
+
### Fixed
|
|
89
|
+
|
|
90
|
+
- `validateHubspotToken` / `validateSalesforceToken` no longer echo the
|
|
91
|
+
provider's raw error body into the login failure message (observed live: a
|
|
92
|
+
HubSpot 401 body printed to the terminal). Status line only, matching the
|
|
93
|
+
no-body-interpolation rule applied elsewhere in 1.0.1.
|
|
94
|
+
- Unreachable hosts during `login salesforce --instance-url` and
|
|
95
|
+
`login --via <url>` now name the target and what to check, completing the
|
|
96
|
+
0.10.1 fix that only covered the audit/connector path.
|
|
97
|
+
|
|
8
98
|
## [0.10.1] — 2026-06-11
|
|
9
99
|
|
|
10
100
|
Fixes from a full fresh-user journey audit (install → demo → MCP → real CRM),
|
package/INSTALL_FOR_AGENTS.md
CHANGED
|
@@ -73,6 +73,20 @@ fullstackgtm audit --provider hubspot --save # persists plan to ~/.fullstack
|
|
|
73
73
|
fullstackgtm plans list # a human reviews and approves
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
+
For `requires_human_*` placeholders, chain the deterministic suggestion engine
|
|
77
|
+
instead of guessing values yourself:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
fullstackgtm suggest --plan-id <id> --provider hubspot --out suggestions.json # read-only
|
|
81
|
+
fullstackgtm plans approve <id> --values-from suggestions.json # high-confidence only
|
|
82
|
+
fullstackgtm apply --plan-id <id> --provider hubspot
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Every suggestion carries a confidence (`high`/`low`/`create`/`none`) and a
|
|
86
|
+
reason derived from snapshot evidence. Surface `low`/`create`/`none` entries
|
|
87
|
+
to your human rather than widening the bar unilaterally — `create:<Name>`
|
|
88
|
+
values create a new CRM record when applied.
|
|
89
|
+
|
|
76
90
|
## 6. MCP server (optional)
|
|
77
91
|
|
|
78
92
|
The MCP entrypoint needs optional peers that plain `npx fullstackgtm-mcp`
|
package/README.md
CHANGED
|
@@ -47,6 +47,39 @@ HUBSPOT_ACCESS_TOKEN=pat-... npx fullstackgtm apply \
|
|
|
47
47
|
|
|
48
48
|
Nothing is ever written without an explicit `--approve`. Operations whose value is a human decision (`requires_human_*` placeholders, e.g. which owner to assign) are refused unless you supply a concrete `--value` override.
|
|
49
49
|
|
|
50
|
+
## From findings to fixes: the suggest chain
|
|
51
|
+
|
|
52
|
+
Most placeholder answers are already derivable from your own CRM data. `suggest` computes them deterministically — account-name matching cross-checked against contact associations — with a confidence level and a written reason per operation, so you (or an agent) approve evidence, not guesses:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
fullstackgtm audit --provider hubspot --save # → Saved plan patch_plan_abc123
|
|
56
|
+
fullstackgtm suggest --plan-id patch_plan_abc123 --provider hubspot --out suggestions.json
|
|
57
|
+
# review suggestions.json: every value carries confidence (high/low/create/none) + a reason
|
|
58
|
+
fullstackgtm plans approve patch_plan_abc123 --values-from suggestions.json # high-confidence only by default
|
|
59
|
+
fullstackgtm apply --plan-id patch_plan_abc123 --provider hubspot
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Widen the bar deliberately: `--min-confidence low` accepts single-signal matches; `--include-creates` accepts `create:<Name>` values — approving one **creates the missing company/account record and links to it** in a single audited operation, so even record creation stays inside the typed, human-approved model. Conflicting or ambiguous evidence always yields *no* suggestion with an explanation, never a guess.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# 3. Hand the findings to whoever owns the CRM: a client-ready report
|
|
66
|
+
npx fullstackgtm report --provider hubspot --client "Acme" --out acme-health.html
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`report` renders the same audit as a deliverable — severity counts up front, a prose summary, per-rule detail with example records, and next steps — as markdown or self-contained HTML (printable, emailable, no external assets).
|
|
70
|
+
|
|
71
|
+
### Working across organizations
|
|
72
|
+
|
|
73
|
+
Consultants and fractional operators hold credentials for several CRMs at once. A profile scopes stored logins *and* stored plans to one organization:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
fullstackgtm --profile acme login hubspot
|
|
77
|
+
fullstackgtm --profile acme audit --provider hubspot --save
|
|
78
|
+
fullstackgtm profiles # list profiles, * marks the active one
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Set `FULLSTACKGTM_PROFILE=acme` to pin a shell (or agent sandbox) to one client. Plans saved under a profile are invisible to every other profile, so a patch plan proposed against one client's CRM can never be applied through another client's credentials.
|
|
82
|
+
|
|
50
83
|
## Built for agents (and the RevOps humans they work for)
|
|
51
84
|
|
|
52
85
|
Every command is designed to compose in an agent loop — deterministic output, machine-readable everywhere, meaningful exit codes:
|
package/dist/cli.d.ts
CHANGED
package/dist/cli.js
CHANGED
|
@@ -9,13 +9,15 @@ import { DEFAULT_LOOPBACK_PORT, openInBrowser, runHubspotLoopbackLogin, validate
|
|
|
9
9
|
import { createSalesforceConnector } from "./connectors/salesforce.js";
|
|
10
10
|
import { createStripeConnector } from "./connectors/stripe.js";
|
|
11
11
|
import { pollSalesforceDeviceLogin, startSalesforceDeviceLogin, validateSalesforceToken, } from "./connectors/salesforceAuth.js";
|
|
12
|
-
import { credentialsPath, deleteCredential, getCredential, resolveHubspotConnection, resolveSalesforceConnection, storeCredential, } from "./credentials.js";
|
|
12
|
+
import { activeProfile, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, listProfiles, resolveHubspotConnection, resolveSalesforceConnection, setActiveProfile, storeCredential, } from "./credentials.js";
|
|
13
13
|
import { generateDemoSnapshot } from "./demo.js";
|
|
14
14
|
import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
|
|
15
15
|
import { mergeSnapshots } from "./merge.js";
|
|
16
16
|
import { createFilePlanStore } from "./planStore.js";
|
|
17
|
+
import { auditReportToHtml, auditReportToMarkdown } from "./report.js";
|
|
17
18
|
import { builtinAuditRules } from "./rules.js";
|
|
18
19
|
import { sampleSnapshot } from "./sampleData.js";
|
|
20
|
+
import { suggestValues } from "./suggest.js";
|
|
19
21
|
function usage() {
|
|
20
22
|
return `FullStackGTM — audit GTM data across providers, propose reviewable patch plans,
|
|
21
23
|
and apply only explicitly approved operations.
|
|
@@ -35,14 +37,28 @@ Usage:
|
|
|
35
37
|
echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot
|
|
36
38
|
fullstackgtm snapshot [source options] [--since <iso>] [--out <path> | --archive <dir>]
|
|
37
39
|
fullstackgtm audit [source options] [audit options] [--save]
|
|
40
|
+
fullstackgtm report [source options] [audit options] [report options]
|
|
38
41
|
fullstackgtm diff --before <a.json> --after <b.json> [--json] [--fail-on-new-findings]
|
|
39
42
|
fullstackgtm merge --input <a.json> --input <b.json> [...] --out <merged.json> [--json]
|
|
40
|
-
fullstackgtm
|
|
43
|
+
fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
|
|
44
|
+
derive values for requires_human_* placeholders
|
|
45
|
+
from snapshot evidence, with confidence + reasons
|
|
46
|
+
fullstackgtm plans list [--status <s>] | show <id> | reject <id>
|
|
47
|
+
fullstackgtm plans approve <id> --operations <ids|all> [--value <opId>=<v>]
|
|
48
|
+
fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low] [--include-creates]
|
|
41
49
|
fullstackgtm apply --plan-id <id> --provider <name>
|
|
42
50
|
fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [options]
|
|
43
51
|
fullstackgtm rules [--json]
|
|
52
|
+
fullstackgtm profiles [--json] list credential profiles
|
|
44
53
|
fullstackgtm doctor [--json] check install, credentials, and next step
|
|
45
54
|
|
|
55
|
+
Profiles (multi-organization use):
|
|
56
|
+
--profile <name> Scope credentials AND stored plans to a named profile
|
|
57
|
+
(also: FULLSTACKGTM_PROFILE). One profile per client
|
|
58
|
+
org keeps logins isolated and prevents a plan proposed
|
|
59
|
+
against one CRM from being applied through another's
|
|
60
|
+
credentials. Omitted = the default profile.
|
|
61
|
+
|
|
46
62
|
Plan lifecycle:
|
|
47
63
|
audit --save persists the dry-run plan to ~/.fullstackgtm/plans. Approve
|
|
48
64
|
specific operations (optionally with --value <opId>=<v> for placeholders),
|
|
@@ -79,6 +95,17 @@ Audit options:
|
|
|
79
95
|
--stale-days <n> Days without activity before an open deal is stale
|
|
80
96
|
--fail-on <severity> Exit 2 if any finding is at or above info|warning|critical
|
|
81
97
|
|
|
98
|
+
Report options (report renders the audit as a client-ready deliverable):
|
|
99
|
+
--plan <path> Render an existing plan JSON instead of re-auditing
|
|
100
|
+
(add --input <snapshot.json> for record counts)
|
|
101
|
+
--client <name> Organization name shown in the heading and summary
|
|
102
|
+
--title <text> Report heading (default "GTM Data Health Report")
|
|
103
|
+
--prepared-by <name> Attribution shown in the footer
|
|
104
|
+
--format <fmt> markdown (default) or html (self-contained, printable;
|
|
105
|
+
inferred from an --out path ending in .html)
|
|
106
|
+
--max-examples <n> Example records listed per rule (default 10)
|
|
107
|
+
--out <path> Write the report to a file instead of stdout
|
|
108
|
+
|
|
82
109
|
Apply options:
|
|
83
110
|
--plan <path> Patch plan JSON produced by \`audit --out\`
|
|
84
111
|
--provider hubspot Connector to apply through
|
|
@@ -288,6 +315,60 @@ async function audit(args) {
|
|
|
288
315
|
process.exitCode = 2;
|
|
289
316
|
}
|
|
290
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* Render an audit as a client-facing deliverable. Same sources and audit
|
|
320
|
+
* options as `audit`; `--plan` instead renders an existing plan JSON without
|
|
321
|
+
* re-fetching (useful for a plan produced earlier or by another machine).
|
|
322
|
+
*/
|
|
323
|
+
async function reportCommand(args) {
|
|
324
|
+
const loaded = loadConfig(option(args, "--config") ?? undefined);
|
|
325
|
+
const configuredRules = await resolveConfiguredRules(loaded);
|
|
326
|
+
let plan;
|
|
327
|
+
let snapshot;
|
|
328
|
+
const planPath = option(args, "--plan");
|
|
329
|
+
if (planPath) {
|
|
330
|
+
plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8"));
|
|
331
|
+
const input = option(args, "--input");
|
|
332
|
+
if (input) {
|
|
333
|
+
snapshot = JSON.parse(readFileSync(resolve(process.cwd(), input), "utf8"));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
snapshot = await readSnapshot(args);
|
|
338
|
+
const policy = mergePolicy(defaultPolicy(), loaded?.config);
|
|
339
|
+
const today = option(args, "--today");
|
|
340
|
+
if (today)
|
|
341
|
+
policy.today = today;
|
|
342
|
+
const staleDealDays = numericOption(args, "--stale-days");
|
|
343
|
+
if (staleDealDays !== undefined)
|
|
344
|
+
policy.staleDealDays = staleDealDays;
|
|
345
|
+
plan = auditSnapshot(snapshot, policy, selectedRules(args, configuredRules));
|
|
346
|
+
}
|
|
347
|
+
const reportOptions = {
|
|
348
|
+
title: option(args, "--title") ?? undefined,
|
|
349
|
+
clientName: option(args, "--client") ?? undefined,
|
|
350
|
+
preparedBy: option(args, "--prepared-by") ?? undefined,
|
|
351
|
+
date: option(args, "--today") ?? undefined,
|
|
352
|
+
maxExamplesPerRule: numericOption(args, "--max-examples"),
|
|
353
|
+
rules: configuredRules,
|
|
354
|
+
snapshot,
|
|
355
|
+
};
|
|
356
|
+
const out = option(args, "--out");
|
|
357
|
+
const format = option(args, "--format") ?? (out?.endsWith(".html") ? "html" : "markdown");
|
|
358
|
+
if (format !== "markdown" && format !== "html") {
|
|
359
|
+
throw new Error("--format must be markdown or html");
|
|
360
|
+
}
|
|
361
|
+
const rendered = format === "html"
|
|
362
|
+
? auditReportToHtml(plan, reportOptions)
|
|
363
|
+
: auditReportToMarkdown(plan, reportOptions);
|
|
364
|
+
if (out) {
|
|
365
|
+
writeFileSync(resolve(process.cwd(), out), rendered);
|
|
366
|
+
console.log(`Wrote ${format} report (${plan.findings.length} findings) to ${out}`);
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
console.log(rendered.trimEnd());
|
|
370
|
+
}
|
|
371
|
+
}
|
|
291
372
|
async function rulesCommand(args) {
|
|
292
373
|
const loaded = loadConfig(option(args, "--config") ?? undefined);
|
|
293
374
|
const rules = await resolveConfiguredRules(loaded);
|
|
@@ -322,6 +403,78 @@ function parseValueOverrides(args) {
|
|
|
322
403
|
}
|
|
323
404
|
return valueOverrides;
|
|
324
405
|
}
|
|
406
|
+
async function suggest(args) {
|
|
407
|
+
const planId = option(args, "--plan-id");
|
|
408
|
+
const planPath = option(args, "--plan");
|
|
409
|
+
if (!planId && !planPath)
|
|
410
|
+
throw new Error("suggest requires --plan <path> or --plan-id <id>");
|
|
411
|
+
let plan;
|
|
412
|
+
if (planId) {
|
|
413
|
+
const stored = await createFilePlanStore().get(planId);
|
|
414
|
+
if (!stored)
|
|
415
|
+
throw new Error(`No stored plan with id ${planId}.`);
|
|
416
|
+
plan = stored.plan;
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
plan = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8"));
|
|
420
|
+
}
|
|
421
|
+
const snapshot = await readSnapshot(args);
|
|
422
|
+
const suggestions = suggestValues(plan, snapshot);
|
|
423
|
+
const payload = { planId: planId ?? planPath, suggestions };
|
|
424
|
+
const outPath = option(args, "--out");
|
|
425
|
+
if (outPath)
|
|
426
|
+
writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(payload, null, 2)}\n`);
|
|
427
|
+
if (args.includes("--json")) {
|
|
428
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (suggestions.length === 0) {
|
|
432
|
+
console.log("No requires_human_* placeholder operations in this plan — nothing to suggest.");
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const byConfidence = {};
|
|
436
|
+
for (const s of suggestions)
|
|
437
|
+
byConfidence[s.confidence] = (byConfidence[s.confidence] ?? 0) + 1;
|
|
438
|
+
console.log(`Suggestions for ${suggestions.length} placeholder operation(s):\n`);
|
|
439
|
+
for (const s of suggestions) {
|
|
440
|
+
const marker = s.confidence === "high" ? "✓" : s.confidence === "low" ? "~" : s.confidence === "create" ? "+" : "✗";
|
|
441
|
+
console.log(`${marker} [${s.confidence}] ${s.operationId} ${s.objectName ?? s.objectId}`);
|
|
442
|
+
console.log(` ${s.suggestedValue ? `→ ${s.suggestedValue}` : "(no suggestion)"} — ${s.reason}`);
|
|
443
|
+
}
|
|
444
|
+
console.log(`\n${Object.entries(byConfidence).map(([k, v]) => `${k}: ${v}`).join(" · ")}`);
|
|
445
|
+
if (planId && (byConfidence.high ?? 0) > 0 && !outPath) {
|
|
446
|
+
console.log(`\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>`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function snapshotSourceHint(args) {
|
|
450
|
+
const provider = option(args, "--provider");
|
|
451
|
+
if (provider)
|
|
452
|
+
return `--provider ${provider} `;
|
|
453
|
+
const input = option(args, "--input");
|
|
454
|
+
if (input)
|
|
455
|
+
return `--input ${input} `;
|
|
456
|
+
return "";
|
|
457
|
+
}
|
|
458
|
+
function readSuggestionValues(path, minConfidence, includeCreates) {
|
|
459
|
+
const raw = JSON.parse(readFileSync(resolve(process.cwd(), path), "utf8"));
|
|
460
|
+
if (!Array.isArray(raw.suggestions)) {
|
|
461
|
+
throw new Error(`${path} is not a suggestions file (expected { suggestions: [...] } from \`fullstackgtm suggest --out\`).`);
|
|
462
|
+
}
|
|
463
|
+
const accepted = new Set(minConfidence === "low" ? ["high", "low"] : ["high"]);
|
|
464
|
+
const overrides = {};
|
|
465
|
+
let skipped = 0;
|
|
466
|
+
for (const s of raw.suggestions) {
|
|
467
|
+
if (!s.suggestedValue)
|
|
468
|
+
continue;
|
|
469
|
+
if (accepted.has(s.confidence) || (includeCreates && s.confidence === "create")) {
|
|
470
|
+
overrides[s.operationId] = s.suggestedValue;
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
skipped += 1;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return { overrides, skipped };
|
|
477
|
+
}
|
|
325
478
|
async function apply(args) {
|
|
326
479
|
const provider = option(args, "--provider");
|
|
327
480
|
if (!provider)
|
|
@@ -484,17 +637,43 @@ async function plansCommand(args) {
|
|
|
484
637
|
if (subcommand === "approve") {
|
|
485
638
|
const planId = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
|
|
486
639
|
if (!planId)
|
|
487
|
-
throw new Error("Usage: fullstackgtm plans approve <planId> --operations <ids|all>");
|
|
640
|
+
throw new Error("Usage: fullstackgtm plans approve <planId> --operations <ids|all> | --values-from <suggestions.json>");
|
|
488
641
|
const operations = option(rest, "--operations");
|
|
489
|
-
|
|
490
|
-
|
|
642
|
+
const valuesFrom = option(rest, "--values-from");
|
|
643
|
+
if (!operations && !valuesFrom) {
|
|
644
|
+
throw new Error("plans approve requires --operations <ids|all> and/or --values-from <suggestions.json>");
|
|
645
|
+
}
|
|
491
646
|
const stored = await store.get(planId);
|
|
492
647
|
if (!stored)
|
|
493
648
|
throw new Error(`No stored plan with id ${planId}.`);
|
|
649
|
+
// Values from a `fullstackgtm suggest --out` file. High-confidence only by
|
|
650
|
+
// default; widen with --min-confidence low, opt into record-creating
|
|
651
|
+
// values (create:<Name>) with --include-creates. Explicit --value wins.
|
|
652
|
+
let fileOverrides = {};
|
|
653
|
+
if (valuesFrom) {
|
|
654
|
+
const minConfidence = option(rest, "--min-confidence") ?? "high";
|
|
655
|
+
if (!["high", "low"].includes(minConfidence)) {
|
|
656
|
+
throw new Error("--min-confidence must be high or low");
|
|
657
|
+
}
|
|
658
|
+
const { overrides, skipped } = readSuggestionValues(valuesFrom, minConfidence, rest.includes("--include-creates"));
|
|
659
|
+
fileOverrides = overrides;
|
|
660
|
+
if (Object.keys(overrides).length === 0) {
|
|
661
|
+
throw new Error(`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.`);
|
|
662
|
+
}
|
|
663
|
+
if (skipped > 0) {
|
|
664
|
+
console.log(`Skipped ${skipped} suggestion(s) below the confidence bar (widen with --min-confidence low / --include-creates).`);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
const explicitOverrides = parseValueOverrides(rest);
|
|
494
668
|
const operationIds = operations === "all"
|
|
495
669
|
? stored.plan.operations.map((operation) => operation.id)
|
|
496
|
-
: operations
|
|
497
|
-
|
|
670
|
+
: operations
|
|
671
|
+
? operations.split(",").map((id) => id.trim()).filter(Boolean)
|
|
672
|
+
: Object.keys(fileOverrides);
|
|
673
|
+
const updated = await store.approveOperations(planId, operationIds, {
|
|
674
|
+
...fileOverrides,
|
|
675
|
+
...explicitOverrides,
|
|
676
|
+
});
|
|
498
677
|
console.log(`Approved ${updated.approvedOperationIds.length} operation(s) on ${planId}. Apply with \`fullstackgtm apply --plan-id ${planId} --provider <name>\`.`);
|
|
499
678
|
return;
|
|
500
679
|
}
|
|
@@ -566,11 +745,18 @@ async function brokerLogin(baseUrl) {
|
|
|
566
745
|
// Self-reported, shown to the approver so they can recognize this request
|
|
567
746
|
// and refuse one they didn't initiate.
|
|
568
747
|
const requesterLabel = `${os.hostname()} (${process.platform}, ${os.userInfo().username})`;
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
748
|
+
let startResponse;
|
|
749
|
+
try {
|
|
750
|
+
startResponse = await fetch(`${base}/api/cli/auth/start`, {
|
|
751
|
+
method: "POST",
|
|
752
|
+
headers: { "Content-Type": "application/json" },
|
|
753
|
+
body: JSON.stringify({ requesterLabel }),
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
catch (error) {
|
|
757
|
+
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
758
|
+
throw new Error(`Cannot reach the hosted deployment at ${base}${cause}. Check the --via URL and network access.`);
|
|
759
|
+
}
|
|
574
760
|
if (!startResponse.ok) {
|
|
575
761
|
throw new Error(`Could not start pairing with ${base} (${startResponse.status}). Is this a FullStackGTM deployment?`);
|
|
576
762
|
}
|
|
@@ -806,6 +992,7 @@ export function doctorReport(env = process.env) {
|
|
|
806
992
|
return {
|
|
807
993
|
package: packageInfo,
|
|
808
994
|
node: { version: process.versions.node, ok: nodeMajor >= 20, required: ">=20" },
|
|
995
|
+
profile: activeProfile(),
|
|
809
996
|
credentialStore: { path: storePath, exists: existsSync(storePath) },
|
|
810
997
|
config: { path: configPath, exists: existsSync(configPath) },
|
|
811
998
|
providers,
|
|
@@ -844,6 +1031,7 @@ function doctorCommand(args) {
|
|
|
844
1031
|
const lines = [
|
|
845
1032
|
`Package: ${report.package.name} ${report.package.version}`,
|
|
846
1033
|
`Node: v${report.node.version} (${report.node.required} required) ${mark(report.node.ok)}`,
|
|
1034
|
+
`Profile: ${report.profile}${report.profile === DEFAULT_PROFILE ? "" : " (named profile — credentials and plans are scoped to it)"}`,
|
|
847
1035
|
`Cred store: ${report.credentialStore.path} (${report.credentialStore.exists ? "present" : "not created yet — created on first login"})`,
|
|
848
1036
|
`Config: ${report.config.exists ? report.config.path : "none — defaults apply"}`,
|
|
849
1037
|
"",
|
|
@@ -862,8 +1050,39 @@ function doctorCommand(args) {
|
|
|
862
1050
|
if (!report.node.ok)
|
|
863
1051
|
process.exitCode = 1;
|
|
864
1052
|
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Pull the global `--profile <name>` flag out of argv (it may appear before
|
|
1055
|
+
* or after the command) and activate it. Stripping it keeps positional
|
|
1056
|
+
* detection in subcommands — `login <provider>`, `plans show <id>` — simple.
|
|
1057
|
+
*/
|
|
1058
|
+
function extractProfile(argv) {
|
|
1059
|
+
const index = argv.indexOf("--profile");
|
|
1060
|
+
if (index === -1)
|
|
1061
|
+
return argv;
|
|
1062
|
+
const name = argv[index + 1];
|
|
1063
|
+
if (!name)
|
|
1064
|
+
throw new Error("--profile requires a name, e.g. --profile acme");
|
|
1065
|
+
setActiveProfile(name);
|
|
1066
|
+
return [...argv.slice(0, index), ...argv.slice(index + 2)];
|
|
1067
|
+
}
|
|
1068
|
+
function profilesCommand(args) {
|
|
1069
|
+
const profiles = listProfiles();
|
|
1070
|
+
const current = activeProfile();
|
|
1071
|
+
if (args.includes("--json")) {
|
|
1072
|
+
console.log(JSON.stringify({ active: current, profiles }, null, 2));
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
for (const profile of profiles) {
|
|
1076
|
+
console.log(`${profile === current ? "*" : " "} ${profile}`);
|
|
1077
|
+
}
|
|
1078
|
+
if (!profiles.includes(current)) {
|
|
1079
|
+
console.log(`* ${current} (selected; created on first login)`);
|
|
1080
|
+
}
|
|
1081
|
+
console.log("\nSelect with --profile <name> on any command, or set FULLSTACKGTM_PROFILE. " +
|
|
1082
|
+
"Each profile keeps its own credentials and stored plans.");
|
|
1083
|
+
}
|
|
865
1084
|
export async function runCli(argv) {
|
|
866
|
-
const [command, ...args] = argv;
|
|
1085
|
+
const [command, ...args] = extractProfile(argv);
|
|
867
1086
|
if (!command || command === "--help" || command === "-h") {
|
|
868
1087
|
console.log(usage());
|
|
869
1088
|
return;
|
|
@@ -888,6 +1107,10 @@ export async function runCli(argv) {
|
|
|
888
1107
|
await audit(args);
|
|
889
1108
|
return;
|
|
890
1109
|
}
|
|
1110
|
+
if (command === "report") {
|
|
1111
|
+
await reportCommand(args);
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
891
1114
|
if (command === "rules") {
|
|
892
1115
|
await rulesCommand(args);
|
|
893
1116
|
return;
|
|
@@ -896,6 +1119,14 @@ export async function runCli(argv) {
|
|
|
896
1119
|
doctorCommand(args);
|
|
897
1120
|
return;
|
|
898
1121
|
}
|
|
1122
|
+
if (command === "suggest") {
|
|
1123
|
+
await suggest(args);
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
if (command === "profiles") {
|
|
1127
|
+
profilesCommand(args);
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
899
1130
|
if (command === "diff") {
|
|
900
1131
|
await diffCommand(args);
|
|
901
1132
|
return;
|