saasfunnels 0.2.0 → 0.2.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/README.md CHANGED
@@ -10,10 +10,10 @@ This repository contains only the MIT-licensed CLI and its bounded runtime contr
10
10
 
11
11
  ## Install
12
12
 
13
- After the beta is published:
13
+ Install the stable CLI:
14
14
 
15
15
  ```bash
16
- npm install --global saasfunnels@beta
16
+ npm install --global saasfunnels
17
17
  ```
18
18
 
19
19
  ## Use
@@ -24,6 +24,49 @@ saasfunnels verify --json
24
24
  saasfunnels mcp serve
25
25
  ```
26
26
 
27
+ ### Plans and pricing
28
+
29
+ ```bash
30
+ saasfunnels plans discover # propose files, no network call
31
+ saasfunnels plans discover --apply # write .saasfunnels/plan-sources.json
32
+ saasfunnels plans handoff --integration-id <id> \
33
+ --repository-key <repo> --repository-revision <sha> --send
34
+ ```
35
+
36
+ `plans discover` reads the working tree and proposes files that define plans and
37
+ prices. Files that reference a Stripe price are reported as evidence; files that
38
+ only look commercial by name and content are reported separately as guesses.
39
+ Nothing leaves the machine until `.saasfunnels/plan-sources.json` lists the files
40
+ and `plans handoff --send` is run, because plan mapping receives file contents
41
+ rather than the `file:line` references a feature manifest sends. Commit that file
42
+ so the approved set is reviewable.
43
+
44
+ ### GitHub Action
45
+
46
+ ```yaml
47
+ - uses: LeadEngine-ai/saasfunnels-cli/action@v0.2.1
48
+ with:
49
+ api-key: ${{ secrets.SAASFUNNELS_API_KEY }}
50
+ cli-version: 0.2.1 # pin it; a floating version rebaselines drift
51
+ discovery-roots: app,lib
52
+ ```
53
+
54
+ On a pull request the Action scans as a `candidate`: the result is compared
55
+ against the branch's baseline and discarded. Only the default branch advances
56
+ the lineage. Changing `discovery-roots` starts a new lineage, so drift is
57
+ measured against a comparable scan rather than a wider or narrower one.
58
+
59
+ Plan and pricing upload is opt-in:
60
+
61
+ ```yaml
62
+ plan-sources: "true"
63
+ integration-id: <stripe integration id>
64
+ ```
65
+
66
+ It runs only on the default branch, requires a committed
67
+ `.saasfunnels/plan-sources.json`, and fails with an explanation rather than
68
+ uploading anything if that file is missing.
69
+
27
70
  Local validation does not require credentials. Live reads use `SAASFUNNELS_API_KEY`; Direct API smoke events use `SAASFUNNELS_INGEST_API_KEY`. Set `SAASFUNNELS_API_BASE_URL` to target a controlled preview or the current SaaSFunnels host.
28
71
 
29
72
  Hosted interactive MCP uses Clerk OAuth at the application `/mcp` URL and does not require this CLI or a copied API key.
package/dist/library.js CHANGED
@@ -24260,7 +24260,7 @@ function validateFeatureCatalogManifest(value) {
24260
24260
 
24261
24261
  // src/identity.ts
24262
24262
  var SAASFUNNELS_CLI_NAME = "saasfunnels";
24263
- var SAASFUNNELS_CLI_VERSION = "0.1.0-beta.1";
24263
+ var SAASFUNNELS_CLI_VERSION = "0.2.1";
24264
24264
  var SAASFUNNELS_PRODUCT_NAME = "SaaSFunnels";
24265
24265
  var SAASFUNNELS_DEFAULT_API_BASE_URL = "https://app.saasfunnels.ai";
24266
24266
  var SAASFUNNELS_ENV = Object.freeze({
@@ -4001,10 +4001,10 @@ var require_resolve_block_map = __commonJS({
4001
4001
  let offset = bm.offset;
4002
4002
  let commentEnd = null;
4003
4003
  for (const collItem of bm.items) {
4004
- const { start, key: key2, sep, value } = collItem;
4004
+ const { start, key: key2, sep: sep3, value } = collItem;
4005
4005
  const keyProps = resolveProps.resolveProps(start, {
4006
4006
  indicator: "explicit-key-ind",
4007
- next: key2 ?? sep?.[0],
4007
+ next: key2 ?? sep3?.[0],
4008
4008
  offset,
4009
4009
  onError,
4010
4010
  parentIndent: bm.indent,
@@ -4018,7 +4018,7 @@ var require_resolve_block_map = __commonJS({
4018
4018
  else if ("indent" in key2 && key2.indent !== bm.indent)
4019
4019
  onError(offset, "BAD_INDENT", startColMsg);
4020
4020
  }
4021
- if (!keyProps.anchor && !keyProps.tag && !sep) {
4021
+ if (!keyProps.anchor && !keyProps.tag && !sep3) {
4022
4022
  commentEnd = keyProps.end;
4023
4023
  if (keyProps.comment) {
4024
4024
  if (map2.comment)
@@ -4042,7 +4042,7 @@ var require_resolve_block_map = __commonJS({
4042
4042
  ctx.atKey = false;
4043
4043
  if (utilMapIncludes.mapIncludes(ctx, map2.items, keyNode))
4044
4044
  onError(keyStart, "DUPLICATE_KEY", "Map keys must be unique");
4045
- const valueProps = resolveProps.resolveProps(sep ?? [], {
4045
+ const valueProps = resolveProps.resolveProps(sep3 ?? [], {
4046
4046
  indicator: "map-value-ind",
4047
4047
  next: value,
4048
4048
  offset: keyNode.range[2],
@@ -4058,7 +4058,7 @@ var require_resolve_block_map = __commonJS({
4058
4058
  if (ctx.options.strict && keyProps.start < valueProps.found.offset - 1024)
4059
4059
  onError(keyNode.range, "KEY_OVER_1024_CHARS", "The : indicator must be at most 1024 chars after the start of an implicit block mapping key");
4060
4060
  }
4061
- const valueNode = value ? composeNode(ctx, value, valueProps, onError) : composeEmptyNode(ctx, offset, sep, null, valueProps, onError);
4061
+ const valueNode = value ? composeNode(ctx, value, valueProps, onError) : composeEmptyNode(ctx, offset, sep3, null, valueProps, onError);
4062
4062
  if (ctx.schema.compat)
4063
4063
  utilFlowIndentCheck.flowIndentCheck(bm.indent, value, onError);
4064
4064
  offset = valueNode.range[2];
@@ -4149,7 +4149,7 @@ var require_resolve_end = __commonJS({
4149
4149
  let comment = "";
4150
4150
  if (end) {
4151
4151
  let hasSpace = false;
4152
- let sep = "";
4152
+ let sep3 = "";
4153
4153
  for (const token of end) {
4154
4154
  const { source, type } = token;
4155
4155
  switch (type) {
@@ -4163,13 +4163,13 @@ var require_resolve_end = __commonJS({
4163
4163
  if (!comment)
4164
4164
  comment = cb;
4165
4165
  else
4166
- comment += sep + cb;
4167
- sep = "";
4166
+ comment += sep3 + cb;
4167
+ sep3 = "";
4168
4168
  break;
4169
4169
  }
4170
4170
  case "newline":
4171
4171
  if (comment)
4172
- sep += source;
4172
+ sep3 += source;
4173
4173
  hasSpace = true;
4174
4174
  break;
4175
4175
  default:
@@ -4212,18 +4212,18 @@ var require_resolve_flow_collection = __commonJS({
4212
4212
  let offset = fc.offset + fc.start.source.length;
4213
4213
  for (let i = 0; i < fc.items.length; ++i) {
4214
4214
  const collItem = fc.items[i];
4215
- const { start, key: key2, sep, value } = collItem;
4215
+ const { start, key: key2, sep: sep3, value } = collItem;
4216
4216
  const props = resolveProps.resolveProps(start, {
4217
4217
  flow: fcName,
4218
4218
  indicator: "explicit-key-ind",
4219
- next: key2 ?? sep?.[0],
4219
+ next: key2 ?? sep3?.[0],
4220
4220
  offset,
4221
4221
  onError,
4222
4222
  parentIndent: fc.indent,
4223
4223
  startOnNewline: false
4224
4224
  });
4225
4225
  if (!props.found) {
4226
- if (!props.anchor && !props.tag && !sep && !value) {
4226
+ if (!props.anchor && !props.tag && !sep3 && !value) {
4227
4227
  if (i === 0 && props.comma)
4228
4228
  onError(props.comma, "UNEXPECTED_TOKEN", `Unexpected , in ${fcName}`);
4229
4229
  else if (i < fc.items.length - 1)
@@ -4277,8 +4277,8 @@ var require_resolve_flow_collection = __commonJS({
4277
4277
  }
4278
4278
  }
4279
4279
  }
4280
- if (!isMap && !sep && !props.found) {
4281
- const valueNode = value ? composeNode(ctx, value, props, onError) : composeEmptyNode(ctx, props.end, sep, null, props, onError);
4280
+ if (!isMap && !sep3 && !props.found) {
4281
+ const valueNode = value ? composeNode(ctx, value, props, onError) : composeEmptyNode(ctx, props.end, sep3, null, props, onError);
4282
4282
  coll.items.push(valueNode);
4283
4283
  offset = valueNode.range[2];
4284
4284
  if (isBlock(value))
@@ -4290,7 +4290,7 @@ var require_resolve_flow_collection = __commonJS({
4290
4290
  if (isBlock(key2))
4291
4291
  onError(keyNode.range, "BLOCK_IN_FLOW", blockMsg);
4292
4292
  ctx.atKey = false;
4293
- const valueProps = resolveProps.resolveProps(sep ?? [], {
4293
+ const valueProps = resolveProps.resolveProps(sep3 ?? [], {
4294
4294
  flow: fcName,
4295
4295
  indicator: "map-value-ind",
4296
4296
  next: value,
@@ -4301,8 +4301,8 @@ var require_resolve_flow_collection = __commonJS({
4301
4301
  });
4302
4302
  if (valueProps.found) {
4303
4303
  if (!isMap && !props.found && ctx.options.strict) {
4304
- if (sep)
4305
- for (const st of sep) {
4304
+ if (sep3)
4305
+ for (const st of sep3) {
4306
4306
  if (st === valueProps.found)
4307
4307
  break;
4308
4308
  if (st.type === "newline") {
@@ -4319,7 +4319,7 @@ var require_resolve_flow_collection = __commonJS({
4319
4319
  else
4320
4320
  onError(valueProps.start, "MISSING_CHAR", `Missing , or : between ${fcName} items`);
4321
4321
  }
4322
- const valueNode = value ? composeNode(ctx, value, valueProps, onError) : valueProps.found ? composeEmptyNode(ctx, valueProps.end, sep, null, valueProps, onError) : null;
4322
+ const valueNode = value ? composeNode(ctx, value, valueProps, onError) : valueProps.found ? composeEmptyNode(ctx, valueProps.end, sep3, null, valueProps, onError) : null;
4323
4323
  if (valueNode) {
4324
4324
  if (isBlock(value))
4325
4325
  onError(valueNode.range, "BLOCK_IN_FLOW", blockMsg);
@@ -4499,7 +4499,7 @@ var require_resolve_block_scalar = __commonJS({
4499
4499
  chompStart = i + 1;
4500
4500
  }
4501
4501
  let value = "";
4502
- let sep = "";
4502
+ let sep3 = "";
4503
4503
  let prevMoreIndented = false;
4504
4504
  for (let i = 0; i < contentStart; ++i)
4505
4505
  value += lines[i][0].slice(trimIndent) + "\n";
@@ -4516,24 +4516,24 @@ var require_resolve_block_scalar = __commonJS({
4516
4516
  indent = "";
4517
4517
  }
4518
4518
  if (type === Scalar.Scalar.BLOCK_LITERAL) {
4519
- value += sep + indent.slice(trimIndent) + content;
4520
- sep = "\n";
4519
+ value += sep3 + indent.slice(trimIndent) + content;
4520
+ sep3 = "\n";
4521
4521
  } else if (indent.length > trimIndent || content[0] === " ") {
4522
- if (sep === " ")
4523
- sep = "\n";
4524
- else if (!prevMoreIndented && sep === "\n")
4525
- sep = "\n\n";
4526
- value += sep + indent.slice(trimIndent) + content;
4527
- sep = "\n";
4522
+ if (sep3 === " ")
4523
+ sep3 = "\n";
4524
+ else if (!prevMoreIndented && sep3 === "\n")
4525
+ sep3 = "\n\n";
4526
+ value += sep3 + indent.slice(trimIndent) + content;
4527
+ sep3 = "\n";
4528
4528
  prevMoreIndented = true;
4529
4529
  } else if (content === "") {
4530
- if (sep === "\n")
4530
+ if (sep3 === "\n")
4531
4531
  value += "\n";
4532
4532
  else
4533
- sep = "\n";
4533
+ sep3 = "\n";
4534
4534
  } else {
4535
- value += sep + content;
4536
- sep = " ";
4535
+ value += sep3 + content;
4536
+ sep3 = " ";
4537
4537
  prevMoreIndented = false;
4538
4538
  }
4539
4539
  }
@@ -4715,25 +4715,25 @@ var require_resolve_flow_scalar = __commonJS({
4715
4715
  if (!match)
4716
4716
  return source;
4717
4717
  let res = match[1];
4718
- let sep = " ";
4718
+ let sep3 = " ";
4719
4719
  let pos = first.lastIndex;
4720
4720
  line.lastIndex = pos;
4721
4721
  while (match = line.exec(source)) {
4722
4722
  if (match[1] === "") {
4723
- if (sep === "\n")
4724
- res += sep;
4723
+ if (sep3 === "\n")
4724
+ res += sep3;
4725
4725
  else
4726
- sep = "\n";
4726
+ sep3 = "\n";
4727
4727
  } else {
4728
- res += sep + match[1];
4729
- sep = " ";
4728
+ res += sep3 + match[1];
4729
+ sep3 = " ";
4730
4730
  }
4731
4731
  pos = line.lastIndex;
4732
4732
  }
4733
4733
  const last = /[ \t]*(.*)/sy;
4734
4734
  last.lastIndex = pos;
4735
4735
  match = last.exec(source);
4736
- return res + sep + (match?.[1] ?? "");
4736
+ return res + sep3 + (match?.[1] ?? "");
4737
4737
  }
4738
4738
  function doubleQuotedValue(source, onError) {
4739
4739
  let res = "";
@@ -5543,14 +5543,14 @@ var require_cst_stringify = __commonJS({
5543
5543
  }
5544
5544
  }
5545
5545
  }
5546
- function stringifyItem({ start, key: key2, sep, value }) {
5546
+ function stringifyItem({ start, key: key2, sep: sep3, value }) {
5547
5547
  let res = "";
5548
5548
  for (const st of start)
5549
5549
  res += st.source;
5550
5550
  if (key2)
5551
5551
  res += stringifyToken(key2);
5552
- if (sep)
5553
- for (const st of sep)
5552
+ if (sep3)
5553
+ for (const st of sep3)
5554
5554
  res += st.source;
5555
5555
  if (value)
5556
5556
  res += stringifyToken(value);
@@ -6717,18 +6717,18 @@ var require_parser = __commonJS({
6717
6717
  if (this.type === "map-value-ind") {
6718
6718
  const prev = getPrevProps(this.peek(2));
6719
6719
  const start = getFirstKeyStartProps(prev);
6720
- let sep;
6720
+ let sep3;
6721
6721
  if (scalar.end) {
6722
- sep = scalar.end;
6723
- sep.push(this.sourceToken);
6722
+ sep3 = scalar.end;
6723
+ sep3.push(this.sourceToken);
6724
6724
  delete scalar.end;
6725
6725
  } else
6726
- sep = [this.sourceToken];
6726
+ sep3 = [this.sourceToken];
6727
6727
  const map2 = {
6728
6728
  type: "block-map",
6729
6729
  offset: scalar.offset,
6730
6730
  indent: scalar.indent,
6731
- items: [{ start, key: scalar, sep }]
6731
+ items: [{ start, key: scalar, sep: sep3 }]
6732
6732
  };
6733
6733
  this.onKeyLine = true;
6734
6734
  this.stack[this.stack.length - 1] = map2;
@@ -6881,15 +6881,15 @@ var require_parser = __commonJS({
6881
6881
  } else if (isFlowToken(it.key) && !includesToken(it.sep, "newline")) {
6882
6882
  const start2 = getFirstKeyStartProps(it.start);
6883
6883
  const key2 = it.key;
6884
- const sep = it.sep;
6885
- sep.push(this.sourceToken);
6884
+ const sep3 = it.sep;
6885
+ sep3.push(this.sourceToken);
6886
6886
  delete it.key;
6887
6887
  delete it.sep;
6888
6888
  this.stack.push({
6889
6889
  type: "block-map",
6890
6890
  offset: this.offset,
6891
6891
  indent: this.indent,
6892
- items: [{ start: start2, key: key2, sep }]
6892
+ items: [{ start: start2, key: key2, sep: sep3 }]
6893
6893
  });
6894
6894
  } else if (start.length > 0) {
6895
6895
  it.sep = it.sep.concat(start, this.sourceToken);
@@ -7083,13 +7083,13 @@ var require_parser = __commonJS({
7083
7083
  const prev = getPrevProps(parent);
7084
7084
  const start = getFirstKeyStartProps(prev);
7085
7085
  fixFlowSeqItems(fc);
7086
- const sep = fc.end.splice(1, fc.end.length);
7087
- sep.push(this.sourceToken);
7086
+ const sep3 = fc.end.splice(1, fc.end.length);
7087
+ sep3.push(this.sourceToken);
7088
7088
  const map2 = {
7089
7089
  type: "block-map",
7090
7090
  offset: fc.offset,
7091
7091
  indent: fc.indent,
7092
- items: [{ start, key: fc, sep }]
7092
+ items: [{ start, key: fc, sep: sep3 }]
7093
7093
  };
7094
7094
  this.onKeyLine = true;
7095
7095
  this.stack[this.stack.length - 1] = map2;
@@ -7371,8 +7371,8 @@ var require_dist = __commonJS({
7371
7371
  import { createInterface as createInterface2 } from "node:readline/promises";
7372
7372
 
7373
7373
  // src/cli.ts
7374
- import { dirname as dirname2, join as join2, resolve as resolve2 } from "node:path";
7375
- import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
7374
+ import { dirname as dirname3, join as join4, resolve as resolve4 } from "node:path";
7375
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
7376
7376
 
7377
7377
  // src/runtime-contracts.js
7378
7378
  import { createHash } from "node:crypto";
@@ -22575,9 +22575,9 @@ function boundedTraitValue(value) {
22575
22575
  }
22576
22576
  function pickAccountTraits(records, keys = accountTraitKeys) {
22577
22577
  const traits = {};
22578
- for (const record3 of records) {
22578
+ for (const record4 of records) {
22579
22579
  for (const key2 of keys) {
22580
- const value = boundedTraitValue(record3[key2]);
22580
+ const value = boundedTraitValue(record4[key2]);
22581
22581
  if (value !== void 0) traits[key2] = value;
22582
22582
  }
22583
22583
  }
@@ -23310,8 +23310,8 @@ function collectSemanticCandidates(payload, source) {
23310
23310
  records.push({ path: "event.properties", record: objectValue2(wrappedEvent.properties) });
23311
23311
  }
23312
23312
  return records.flatMap(
23313
- ({ path, record: record3 }) => ["semantic_type", "event_type"].flatMap((field) => {
23314
- const value = nonEmptyString(record3[field]);
23313
+ ({ path, record: record4 }) => ["semantic_type", "event_type"].flatMap((field) => {
23314
+ const value = nonEmptyString(record4[field]);
23315
23315
  return value ? [{ path: path ? `${path}.${field}` : field, value }] : [];
23316
23316
  })
23317
23317
  );
@@ -24050,6 +24050,7 @@ var featureBindingKinds = [
24050
24050
  ];
24051
24051
  var defaultRoots = ["app", "src", "lib", "components", "pages"];
24052
24052
  var ignoredSegments = /* @__PURE__ */ new Set([
24053
+ ".claude",
24053
24054
  ".git",
24054
24055
  ".next",
24055
24056
  ".output",
@@ -24653,7 +24654,7 @@ async function runFeatureSetup(input) {
24653
24654
  (edit) => changedEdits.some((changed) => changed.id === edit.id)
24654
24655
  );
24655
24656
  changeRecordPath = ".saasfunnels/feature-installation.json";
24656
- const record3 = {
24657
+ const record4 = {
24657
24658
  appliedAt: (/* @__PURE__ */ new Date()).toISOString(),
24658
24659
  environment: input.environment,
24659
24660
  files: changedEdits.map((edit) => ({
@@ -24670,7 +24671,7 @@ async function runFeatureSetup(input) {
24670
24671
  path: change.path
24671
24672
  })),
24672
24673
  {
24673
- contents: `${JSON.stringify(record3, null, 2)}
24674
+ contents: `${JSON.stringify(record4, null, 2)}
24674
24675
  `,
24675
24676
  path: changeRecordPath
24676
24677
  }
@@ -24801,7 +24802,7 @@ Applied reviewed edits. Change record: ${result3.changeRecordPath}` : result3.id
24801
24802
 
24802
24803
  // src/identity.ts
24803
24804
  var SAASFUNNELS_CLI_NAME = "saasfunnels";
24804
- var SAASFUNNELS_CLI_VERSION = "0.1.0-beta.1";
24805
+ var SAASFUNNELS_CLI_VERSION = "0.2.1";
24805
24806
  var SAASFUNNELS_PRODUCT_NAME = "SaaSFunnels";
24806
24807
  var SAASFUNNELS_DEFAULT_API_BASE_URL = "https://app.saasfunnels.ai";
24807
24808
  var SAASFUNNELS_ENV = Object.freeze({
@@ -24899,6 +24900,382 @@ function formatFeatureDriftCheckSummary(input) {
24899
24900
  };
24900
24901
  }
24901
24902
 
24903
+ // src/plan-branches.ts
24904
+ import { readdir as readdir2, readFile as readFile2, stat as stat2 } from "node:fs/promises";
24905
+ import { extname as extname2, join as join2, relative as relative2, resolve as resolve2, sep } from "node:path";
24906
+ var maxPlanBranchFileCharacters = 2e6;
24907
+ var codeExtensions = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
24908
+ var nonDefinitionPattern = /(^|\/)(__tests__|__mocks__|tests?|specs?|fixtures?|testing|mocks?|mock-data|stories|__stories__)(\/)|\.(test|spec|d|stories)\.[cm]?[jt]sx?$/i;
24909
+ var comparisonPattern = /\b([A-Za-z_$][\w$]*(?:\.[\w$]+)*)\s*(===|==|!==|!=)\s*(?:["'`]([A-Za-z][A-Za-z0-9_ -]{0,39})["'`]|([A-Za-z_$][\w$]*(?:\.[\w$]+){0,2}))/g;
24910
+ var planIdentifierPattern = /(plan|tier|level|subscription|package)/i;
24911
+ var enumOpenPattern = /\b(?:enum|const)\s+([A-Za-z_$][\w$]*)\s*(?:=\s*)?\{/;
24912
+ var memberPattern = /^\s*([A-Za-z_$][\w$]*)\s*[:=]\s*["'`]([A-Za-z][A-Za-z0-9_ -]{0,39})["'`]/;
24913
+ var blockClosePattern = /^\s*\}/;
24914
+ var simpleConstPattern = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::\s*[\w<>[\]|\s]+)?=\s*["'`]([A-Za-z][A-Za-z0-9_ -]{0,39})["'`]/;
24915
+ var denialPattern = /\b(?:throw|redirect|notFound|forbidden)\b|\breturn\s+(?:null|false|undefined)\b|upgrade|paywall|locked|disabled|403/i;
24916
+ var restrictionNamePattern = /\b(?:needs?|requires?|must)[A-Z_]|\b(?:is|has)?(?:Locked|Blocked|Restricted|Gated|Disabled)|upgrade|paywall/i;
24917
+ var numericBranchPattern = /\?\s*[\d_]+\s*:\s*[\d_]+|:\s*[\d_]+\s*[,;)]/;
24918
+ var renderPattern = /<[A-Z][\w.]*|className=|return\s*\(/;
24919
+ var marketingPattern = /\b(?:upgrade|get started|contact sales|choose|current plan|most popular|per month|per year|\/mo\b|\/yr\b)|\$\{?\d/i;
24920
+ var escapePattern = /\breturn\s+(?:null|false|undefined)\b|\bthrow\b|\bredirect\b|\bnotFound\b/;
24921
+ function collectConstants(lines, into) {
24922
+ let container = null;
24923
+ for (const line of lines) {
24924
+ if (container && blockClosePattern.test(line)) {
24925
+ container = null;
24926
+ continue;
24927
+ }
24928
+ if (!container) {
24929
+ const open = line.match(enumOpenPattern);
24930
+ if (open?.[1]) {
24931
+ container = open[1];
24932
+ continue;
24933
+ }
24934
+ const simple = line.match(simpleConstPattern);
24935
+ if (simple?.[1] && simple[2]) record3(into, simple[1], simple[2]);
24936
+ continue;
24937
+ }
24938
+ const member = line.match(memberPattern);
24939
+ if (member?.[1] && member[2]) {
24940
+ record3(into, `${container}.${member[1]}`, member[2]);
24941
+ record3(into, member[1], member[2]);
24942
+ }
24943
+ }
24944
+ }
24945
+ function record3(into, name, value) {
24946
+ const normalized = value.toLowerCase();
24947
+ if (!into.has(name)) {
24948
+ into.set(name, normalized);
24949
+ return;
24950
+ }
24951
+ if (into.get(name) !== normalized) into.set(name, null);
24952
+ }
24953
+ function resolveConstant(index, reference) {
24954
+ const direct = index.get(reference);
24955
+ if (direct !== void 0) return direct;
24956
+ const segments = reference.split(".");
24957
+ if (segments.length > 1) {
24958
+ const tail = segments.slice(-2).join(".");
24959
+ const viaTail = index.get(tail);
24960
+ if (viaTail !== void 0) return viaTail;
24961
+ const viaMember = index.get(segments[segments.length - 1]);
24962
+ if (viaMember !== void 0) return viaMember;
24963
+ }
24964
+ return void 0;
24965
+ }
24966
+ function normalize(path) {
24967
+ return path.split(sep).join("/");
24968
+ }
24969
+ function allowed(relativePath, excludes) {
24970
+ if (!relativePath || relativePath.startsWith("..")) return false;
24971
+ const segments = relativePath.split("/");
24972
+ if (segments.some((segment) => ignoredSegments.has(segment))) return false;
24973
+ if (segments.some((segment) => /^\.next[-.]?/.test(segment))) return false;
24974
+ if (sensitivePathPattern.test(relativePath)) return false;
24975
+ if (nonDefinitionPattern.test(relativePath)) return false;
24976
+ return !excludes.some((exclude) => relativePath.startsWith(exclude));
24977
+ }
24978
+ function nearestSymbol2(lines, lineIndex) {
24979
+ for (let index = lineIndex; index >= Math.max(0, lineIndex - 20); index -= 1) {
24980
+ const match = lines[index]?.match(
24981
+ /(?:export\s+)?(?:async\s+)?(?:function|class|const|let|var)\s+([A-Za-z_$][\w$]*)/
24982
+ );
24983
+ if (match?.[1]) return match[1];
24984
+ }
24985
+ return "module";
24986
+ }
24987
+ function assignedName(line) {
24988
+ return line.match(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/)?.[1] ?? null;
24989
+ }
24990
+ function bindingUsages(lines, lineIndex, name) {
24991
+ const reference = new RegExp(`\\b${name}\\b`);
24992
+ const usages = [];
24993
+ lines.forEach((line, index) => {
24994
+ if (index === lineIndex || !reference.test(line)) return;
24995
+ usages.push(line);
24996
+ });
24997
+ return usages.join("\n");
24998
+ }
24999
+ function shapeAt(lines, lineIndex) {
25000
+ const line = lines[lineIndex] ?? "";
25001
+ const window = lines.slice(lineIndex, lineIndex + 4).join("\n");
25002
+ if (numericBranchPattern.test(line)) return "limit";
25003
+ if (renderPattern.test(window) && marketingPattern.test(window) && !escapePattern.test(window)) {
25004
+ return "presentation";
25005
+ }
25006
+ return "boolean";
25007
+ }
25008
+ function polarityAt(lines, lineIndex, negated) {
25009
+ const window = lines.slice(lineIndex, lineIndex + 3).join("\n");
25010
+ const bound = assignedName(lines[lineIndex] ?? "");
25011
+ const named = bound ? restrictionNamePattern.test(bound) : false;
25012
+ const usages = bound ? bindingUsages(lines, lineIndex, bound) : "";
25013
+ if (!named && !denialPattern.test(window) && !denialPattern.test(usages)) {
25014
+ return "unclear";
25015
+ }
25016
+ return negated ? "unclear" : "deny";
25017
+ }
25018
+ function clusterPlanBranches(branches) {
25019
+ const clusters = /* @__PURE__ */ new Map();
25020
+ for (const branch of branches) {
25021
+ const segments = branch.repositoryPath.split("/");
25022
+ const location = segments.slice(0, -1).join("/") || ".";
25023
+ const key2 = `${branch.shape}|${location}`;
25024
+ const existing = clusters.get(key2);
25025
+ if (existing) existing.push(branch);
25026
+ else clusters.set(key2, [branch]);
25027
+ }
25028
+ return [...clusters.entries()].map(([key2, grouped]) => {
25029
+ const polarities = new Set(grouped.map((branch) => branch.polarity));
25030
+ const decided = polarities.has("deny") && polarities.has("grant") ? "unclear" : polarities.has("deny") ? "deny" : polarities.has("grant") ? "grant" : "unclear";
25031
+ return {
25032
+ branches: grouped,
25033
+ location: key2.slice(key2.indexOf("|") + 1),
25034
+ planValues: [...new Set(grouped.map((branch) => branch.planValue))].sort(),
25035
+ polarity: decided,
25036
+ shape: grouped[0].shape
25037
+ };
25038
+ }).sort(
25039
+ (left, right) => right.branches.length - left.branches.length || left.location.localeCompare(right.location)
25040
+ );
25041
+ }
25042
+ async function discoverPlanBranches(input) {
25043
+ const excludes = input.excludes ?? [];
25044
+ const planValues = new Set(
25045
+ input.planValues.map((value) => value.trim().toLowerCase()).filter(Boolean)
25046
+ );
25047
+ if (!planValues.size) return [];
25048
+ const roots = input.roots ?? [".", ...defaultRoots];
25049
+ const seen = /* @__PURE__ */ new Set();
25050
+ const branches = [];
25051
+ const constants = /* @__PURE__ */ new Map();
25052
+ const pending = [];
25053
+ const queue = roots.map((root) => resolve2(input.cwd, root));
25054
+ while (queue.length) {
25055
+ const current = queue.shift();
25056
+ if (seen.has(current)) continue;
25057
+ seen.add(current);
25058
+ const relativePath = normalize(relative2(input.cwd, current));
25059
+ if (relativePath && !allowed(relativePath, excludes)) continue;
25060
+ let entryStat;
25061
+ try {
25062
+ entryStat = await stat2(current);
25063
+ } catch {
25064
+ continue;
25065
+ }
25066
+ if (entryStat.isDirectory()) {
25067
+ const entries = await readdir2(current, { withFileTypes: true });
25068
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
25069
+ queue.push(join2(current, entry.name));
25070
+ }
25071
+ continue;
25072
+ }
25073
+ if (!entryStat.isFile() || !relativePath) continue;
25074
+ if (!codeExtensions.has(extname2(current).toLowerCase())) continue;
25075
+ if (entryStat.size > maxPlanBranchFileCharacters) continue;
25076
+ const lines = (await readFile2(current, "utf8")).split(/\r?\n/);
25077
+ collectConstants(lines, constants);
25078
+ lines.forEach((line, lineIndex) => {
25079
+ comparisonPattern.lastIndex = 0;
25080
+ let match;
25081
+ while (match = comparisonPattern.exec(line)) {
25082
+ const [, identifier, operator, literal2, reference] = match;
25083
+ if (!planIdentifierPattern.test(identifier ?? "")) continue;
25084
+ if (!literal2 && !reference) continue;
25085
+ pending.push({
25086
+ line: lineIndex + 1,
25087
+ literal: literal2 ?? null,
25088
+ negated: operator.startsWith("!"),
25089
+ polarity: polarityAt(lines, lineIndex, operator.startsWith("!")),
25090
+ reference: reference ?? null,
25091
+ repositoryPath: relativePath,
25092
+ shape: shapeAt(lines, lineIndex),
25093
+ symbol: nearestSymbol2(lines, lineIndex)
25094
+ });
25095
+ }
25096
+ });
25097
+ }
25098
+ for (const item of pending) {
25099
+ const value = item.literal ? item.literal.toLowerCase() : resolveConstant(constants, item.reference);
25100
+ if (!value || !planValues.has(value)) continue;
25101
+ branches.push({
25102
+ line: item.line,
25103
+ planValue: value,
25104
+ polarity: item.polarity,
25105
+ repositoryPath: item.repositoryPath,
25106
+ shape: item.shape,
25107
+ symbol: item.symbol
25108
+ });
25109
+ }
25110
+ return branches.sort(
25111
+ (left, right) => left.repositoryPath.localeCompare(right.repositoryPath) || left.line - right.line
25112
+ );
25113
+ }
25114
+
25115
+ // src/plan-sources.ts
25116
+ import { readFile as readFile3, readdir as readdir3, stat as stat3, writeFile as writeFile2, mkdir as mkdir2 } from "node:fs/promises";
25117
+ import { dirname as dirname2, extname as extname3, join as join3, relative as relative3, resolve as resolve3, sep as sep2 } from "node:path";
25118
+ var planSourceApprovalPath = ".saasfunnels/plan-sources.json";
25119
+ var maxPlanSourceFiles = 12;
25120
+ var maxPlanSourceCharacters = 512e3;
25121
+ var planSourceStems = [
25122
+ "billing",
25123
+ "entitlement",
25124
+ "entitlements",
25125
+ "plan",
25126
+ "plans",
25127
+ "price",
25128
+ "prices",
25129
+ "pricing",
25130
+ "subscription",
25131
+ "subscriptions",
25132
+ "tier",
25133
+ "tiers"
25134
+ ];
25135
+ var planSourceKindByExtension = /* @__PURE__ */ new Map([
25136
+ [".cjs", "typescript"],
25137
+ [".js", "typescript"],
25138
+ [".json", "json"],
25139
+ [".jsonc", "json"],
25140
+ [".mjs", "typescript"],
25141
+ [".ts", "typescript"],
25142
+ [".tsx", "typescript"],
25143
+ [".yaml", "yaml"],
25144
+ [".yml", "yaml"]
25145
+ ]);
25146
+ var billingIntervalPattern = /\b(month|monthly|year|yearly|annual|annually|week|weekly|day|daily|recurring|interval|billing_period)\b/i;
25147
+ var chargeAmountPattern = /\b(unit_amount|unit_amount_decimal|amount|amount_decimal|price|prices|currency|usd|eur|gbp|cents)\b/i;
25148
+ var stripePriceIdPattern = /["'`]price_(?=[A-Za-z0-9]*\d)[A-Za-z0-9]{14,}["'`]/;
25149
+ var nonDefinitionPattern2 = /(^|\/)(__tests__|__mocks__|tests?|specs?)(\/)|\.(test|spec|d)\.[cm]?[jt]sx?$/i;
25150
+ var maxNamedOnlyCandidates = 10;
25151
+ function normalize2(path) {
25152
+ return path.split(sep2).join("/");
25153
+ }
25154
+ function allowed2(relativePath, excludes) {
25155
+ if (!relativePath || relativePath.startsWith("..")) return false;
25156
+ const segments = relativePath.split("/");
25157
+ if (segments.some((segment) => ignoredSegments.has(segment))) return false;
25158
+ if (segments.some((segment) => /^\.next[-.]?/.test(segment))) return false;
25159
+ if (sensitivePathPattern.test(relativePath)) return false;
25160
+ if (nonDefinitionPattern2.test(relativePath)) return false;
25161
+ return !excludes.some((exclude) => relativePath.startsWith(exclude));
25162
+ }
25163
+ function looksLikePlanSource(relativePath) {
25164
+ const base = relativePath.split("/").pop() ?? "";
25165
+ const stem = base.slice(0, base.length - extname3(base).length).toLowerCase();
25166
+ const parts = stem.split(/[^a-z0-9]+/).filter(Boolean);
25167
+ return parts.some((part) => planSourceStems.includes(part));
25168
+ }
25169
+ async function discoverPlanSourceCandidates(input) {
25170
+ const excludes = input.excludes ?? [];
25171
+ const roots = input.roots ?? [".", "config", ...defaultRoots];
25172
+ const seen = /* @__PURE__ */ new Set();
25173
+ const candidates = [];
25174
+ const queue = roots.map((root) => resolve3(input.cwd, root));
25175
+ while (queue.length && candidates.length < 200) {
25176
+ const current = queue.shift();
25177
+ if (seen.has(current)) continue;
25178
+ seen.add(current);
25179
+ const relativePath = normalize2(relative3(input.cwd, current));
25180
+ if (relativePath && !allowed2(relativePath, excludes)) continue;
25181
+ let entryStat;
25182
+ try {
25183
+ entryStat = await stat3(current);
25184
+ } catch {
25185
+ continue;
25186
+ }
25187
+ if (entryStat.isDirectory()) {
25188
+ const entries = await readdir3(current, { withFileTypes: true });
25189
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
25190
+ queue.push(join3(current, entry.name));
25191
+ }
25192
+ continue;
25193
+ }
25194
+ if (!entryStat.isFile()) continue;
25195
+ const kind = planSourceKindByExtension.get(extname3(current).toLowerCase());
25196
+ if (!kind || !relativePath) continue;
25197
+ if (entryStat.size > maxPlanSourceCharacters) continue;
25198
+ const contents = await readFile3(current, "utf8");
25199
+ const hasStripePriceId = stripePriceIdPattern.test(contents);
25200
+ const named = looksLikePlanSource(relativePath);
25201
+ const looksPriced = billingIntervalPattern.test(contents) && chargeAmountPattern.test(contents);
25202
+ if (!hasStripePriceId && !(named && looksPriced)) continue;
25203
+ candidates.push({
25204
+ characters: contents.length,
25205
+ confidenceBasisPoints: hasStripePriceId ? 9e3 : 4e3,
25206
+ hasStripePriceId,
25207
+ kind,
25208
+ path: relativePath,
25209
+ rationale: hasStripePriceId ? named ? "Filename suggests commercial configuration and the file references a Stripe price." : "The file references a Stripe price." : "Filename suggests commercial configuration and the file states amounts and billing intervals."
25210
+ });
25211
+ }
25212
+ const sorted = candidates.sort(
25213
+ (left, right) => right.confidenceBasisPoints - left.confidenceBasisPoints || left.path.localeCompare(right.path)
25214
+ );
25215
+ const confirmed = sorted.filter((candidate) => candidate.hasStripePriceId);
25216
+ const namedOnly = sorted.filter((candidate) => !candidate.hasStripePriceId);
25217
+ return [...confirmed, ...namedOnly.slice(0, maxNamedOnlyCandidates)];
25218
+ }
25219
+ async function readApprovedPlanSources(cwd2) {
25220
+ try {
25221
+ const raw = await readFile3(resolve3(cwd2, planSourceApprovalPath), "utf8");
25222
+ const parsed = JSON.parse(raw);
25223
+ return Array.isArray(parsed.files) ? parsed.files.filter((value) => typeof value === "string") : [];
25224
+ } catch {
25225
+ return [];
25226
+ }
25227
+ }
25228
+ async function writeApprovedPlanSources(cwd2, files) {
25229
+ const target = resolve3(cwd2, planSourceApprovalPath);
25230
+ await mkdir2(dirname2(target), { recursive: true });
25231
+ await writeFile2(
25232
+ target,
25233
+ `${JSON.stringify(
25234
+ {
25235
+ files: [...files].sort(),
25236
+ note: "Only these files are uploaded for plan mapping. Review before committing.",
25237
+ schemaVersion: 1
25238
+ },
25239
+ null,
25240
+ 2
25241
+ )}
25242
+ `,
25243
+ "utf8"
25244
+ );
25245
+ return target;
25246
+ }
25247
+ async function buildPlanMappingHandoff(input) {
25248
+ if (input.files.length > maxPlanSourceFiles) {
25249
+ throw new Error(
25250
+ `Plan mapping accepts at most ${maxPlanSourceFiles} files; ${input.files.length} are approved. Remove some from ${planSourceApprovalPath}.`
25251
+ );
25252
+ }
25253
+ const observedAt = input.observedAt ?? (/* @__PURE__ */ new Date()).toISOString();
25254
+ const inputs = [];
25255
+ for (const file2 of [...input.files].sort()) {
25256
+ const relativePath = normalize2(file2);
25257
+ if (!allowed2(relativePath, [])) {
25258
+ throw new Error(`${relativePath} is not a readable plan source path.`);
25259
+ }
25260
+ const kind = planSourceKindByExtension.get(extname3(relativePath).toLowerCase());
25261
+ if (!kind) {
25262
+ throw new Error(`${relativePath} is not a supported plan source type.`);
25263
+ }
25264
+ const content = await readFile3(resolve3(input.cwd, relativePath), "utf8");
25265
+ if (content.length > maxPlanSourceCharacters) {
25266
+ throw new Error(
25267
+ `${relativePath} is ${content.length} characters; the limit is ${maxPlanSourceCharacters}.`
25268
+ );
25269
+ }
25270
+ inputs.push({ content, kind, label: relativePath, observedAt });
25271
+ }
25272
+ return { inputs };
25273
+ }
25274
+ function planMappingRequestKey(input) {
25275
+ const slug = `${input.repositoryKey}:${input.repositoryRevision}`.toLowerCase().replace(/[^a-z0-9_.:-]+/g, "-").replace(/^-+|-+$/g, "");
25276
+ return `plans-${slug}`.slice(0, 160);
25277
+ }
25278
+
24902
25279
  // src/mcp.ts
24903
25280
  import { createInterface } from "node:readline";
24904
25281
 
@@ -26194,7 +26571,15 @@ async function serveSaaSFunnelsMcp(options = {}) {
26194
26571
  var cliSchemaVersion = "2026-07-02.1";
26195
26572
  var targetNames = /* @__PURE__ */ new Set(["codex", "claude-code", "cursor", "markdown"]);
26196
26573
  var sourceNames2 = /* @__PURE__ */ new Set(["direct", "posthog", "segment"]);
26197
- var tokenPattern2 = /\b((?:pv|sk|pk|rk|whsec|xox[baprs]|gh[pousr])_[A-Za-z0-9_=-]{8,}|[A-Za-z0-9_-]{32,})\b/g;
26574
+ var tokenPattern2 = /\b((?:pv|sk|pk|rk|whsec|xox[baprs]|gh[pousr])_[A-Za-z0-9_=-]{8,}|(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{32,})\b/g;
26575
+ var pathValuedKeys = /* @__PURE__ */ new Set([
26576
+ "approvalPath",
26577
+ "file",
26578
+ "files",
26579
+ "location",
26580
+ "path",
26581
+ "repositoryPath"
26582
+ ]);
26198
26583
  function parseArgs2(argv2) {
26199
26584
  const args = [];
26200
26585
  const flags = {};
@@ -26240,9 +26625,30 @@ function result(exitCode, stdout = "", stderr = "") {
26240
26625
  stdout: redactOutput(stdout)
26241
26626
  };
26242
26627
  }
26628
+ function redactStructured(value, key2) {
26629
+ if (typeof value === "string") {
26630
+ return key2 && pathValuedKeys.has(key2) ? value : redactOutput(value);
26631
+ }
26632
+ if (Array.isArray(value)) {
26633
+ return value.map((item) => redactStructured(item, key2));
26634
+ }
26635
+ if (value && typeof value === "object") {
26636
+ return Object.fromEntries(
26637
+ Object.entries(value).map(([name, item]) => [
26638
+ name,
26639
+ redactStructured(item, name)
26640
+ ])
26641
+ );
26642
+ }
26643
+ return value;
26644
+ }
26243
26645
  function jsonResult(exitCode, data) {
26244
- return result(exitCode, `${JSON.stringify(data, null, 2)}
26245
- `);
26646
+ return {
26647
+ exitCode,
26648
+ stderr: "",
26649
+ stdout: `${JSON.stringify(redactStructured(data), null, 2)}
26650
+ `
26651
+ };
26246
26652
  }
26247
26653
  function usage() {
26248
26654
  return `${SAASFUNNELS_PRODUCT_NAME} CLI
@@ -26256,6 +26662,10 @@ Usage:
26256
26662
  ${SAASFUNNELS_CLI_NAME} features setup [--root <paths>] [--exclude <paths>] [--accept <ids|all>] [--reject <ids>] [--map <id=key>] [--manifest-only] [--apply] [--json]
26257
26663
  ${SAASFUNNELS_CLI_NAME} features check --feature <key> --account-id <uuid> [--environment test] [--json]
26258
26664
  ${SAASFUNNELS_CLI_NAME} features handoff [--file <path>] --repository-revision <revision> --repository-key <key> [--discovery-roots <paths>] [--scan-role series|candidate] [--producer cli|github_action|github_app] [--send] [--json]
26665
+ ${SAASFUNNELS_CLI_NAME} plans discover [--root <paths>] [--exclude <paths>] [--apply] [--json]
26666
+ ${SAASFUNNELS_CLI_NAME} plans branches --plans <names> [--root <paths>] [--json]
26667
+ ${SAASFUNNELS_CLI_NAME} plans branches --plans <names> --repository-key <repo> --repository-revision <sha> --send
26668
+ ${SAASFUNNELS_CLI_NAME} plans handoff --repository-key <key> --repository-revision <rev> --integration-id <uuid> [--send] [--json]
26259
26669
  ${SAASFUNNELS_CLI_NAME} catalog discover [feature setup options]
26260
26670
  ${SAASFUNNELS_CLI_NAME} catalog validate [file] [--json]
26261
26671
  ${SAASFUNNELS_CLI_NAME} catalog diff [feature setup options]
@@ -26278,10 +26688,10 @@ function flagList(flags, name) {
26278
26688
  function filesystem(options) {
26279
26689
  return options.fs ?? {
26280
26690
  mkdir: async (path, mkdirOptions) => {
26281
- await mkdir2(path, mkdirOptions);
26691
+ await mkdir3(path, mkdirOptions);
26282
26692
  },
26283
- readFile: readFile2,
26284
- writeFile: writeFile2
26693
+ readFile: readFile4,
26694
+ writeFile: writeFile3
26285
26695
  };
26286
26696
  }
26287
26697
  function cwd(options) {
@@ -26311,19 +26721,19 @@ function sourceFlag(flags) {
26311
26721
  }
26312
26722
  async function readJsonFile(filePath, options) {
26313
26723
  const raw = await filesystem(options).readFile(
26314
- resolve2(cwd(options), filePath),
26724
+ resolve4(cwd(options), filePath),
26315
26725
  "utf8"
26316
26726
  );
26317
26727
  return JSON.parse(raw);
26318
26728
  }
26319
26729
  async function commandInit(flags, options) {
26320
26730
  const fs = filesystem(options);
26321
- const configPath = join2(cwd(options), ".saasfunnels", "config.json");
26731
+ const configPath = join4(cwd(options), ".saasfunnels", "config.json");
26322
26732
  const config2 = {
26323
26733
  api_base_url: apiBaseUrl2(options, flags),
26324
26734
  schema_version: cliSchemaVersion
26325
26735
  };
26326
- await fs.mkdir(dirname2(configPath), { recursive: true });
26736
+ await fs.mkdir(dirname3(configPath), { recursive: true });
26327
26737
  await fs.writeFile(
26328
26738
  configPath,
26329
26739
  `${JSON.stringify(config2, null, 2)}
@@ -26358,9 +26768,9 @@ async function commandAgentInstall(args, flags, options) {
26358
26768
  const artifacts = getDeveloperToolAgentHandoffArtifacts({ endpoint });
26359
26769
  const artifact = artifacts[target];
26360
26770
  const targetPath = flagString(flags, "path") ?? saasFunnelsPublicText(artifact.target_path);
26361
- const absoluteTarget = join2(cwd(options), targetPath);
26771
+ const absoluteTarget = join4(cwd(options), targetPath);
26362
26772
  const fs = filesystem(options);
26363
- await fs.mkdir(dirname2(absoluteTarget), { recursive: true });
26773
+ await fs.mkdir(dirname3(absoluteTarget), { recursive: true });
26364
26774
  await fs.writeFile(
26365
26775
  absoluteTarget,
26366
26776
  saasFunnelsPublicText(artifact.file_contents),
@@ -26500,7 +26910,7 @@ async function commandFeatures(args, flags, options) {
26500
26910
  }
26501
26911
  const discoveryRoots = (flagString(flags, "discovery-roots") ?? "").split(",").map((root) => root.trim()).filter(Boolean);
26502
26912
  const source = await filesystem(options).readFile(
26503
- resolve2(cwd(options), path),
26913
+ resolve4(cwd(options), path),
26504
26914
  "utf8"
26505
26915
  );
26506
26916
  const validation = parseFeatureManifestSource(source);
@@ -26516,7 +26926,7 @@ ${validation.errors.map((error51) => `- ${error51}`).join("\n")}
26516
26926
  let sdkVersions = [];
26517
26927
  try {
26518
26928
  const packageSource = await filesystem(options).readFile(
26519
- resolve2(cwd(options), "package.json"),
26929
+ resolve4(cwd(options), "package.json"),
26520
26930
  "utf8"
26521
26931
  );
26522
26932
  sdkVersions = featureSdkVersionsFromPackageJson(
@@ -26566,8 +26976,7 @@ ${validation.errors.map((error51) => `- ${error51}`).join("\n")}
26566
26976
  "Feature instrumentation handoff accepted. Open the Features review link returned by SaaSFunnels.\n"
26567
26977
  ) : result(1, "", "Feature instrumentation handoff was rejected.\n");
26568
26978
  }
26569
- return jsonMode(flags) ? jsonResult(0, handoff) : result(0, `${JSON.stringify(handoff, null, 2)}
26570
- `);
26979
+ return jsonMode(flags) ? jsonResult(0, handoff) : jsonResult(0, handoff);
26571
26980
  }
26572
26981
  return result(
26573
26982
  2,
@@ -26588,7 +26997,7 @@ async function commandCatalog(args, flags, options) {
26588
26997
  if (action === "validate") {
26589
26998
  const path = args[1] ?? ".saasfunnels/catalog.yaml";
26590
26999
  const source = await filesystem(options).readFile(
26591
- resolve2(cwd(options), path),
27000
+ resolve4(cwd(options), path),
26592
27001
  "utf8"
26593
27002
  );
26594
27003
  const validation = parseFeatureManifestSource(source);
@@ -26838,7 +27247,7 @@ async function commandVerify(flags, options) {
26838
27247
  if (catalogPath) {
26839
27248
  try {
26840
27249
  const source = await filesystem(options).readFile(
26841
- resolve2(cwd(options), catalogPath),
27250
+ resolve4(cwd(options), catalogPath),
26842
27251
  "utf8"
26843
27252
  );
26844
27253
  const validation = parseFeatureManifestSource(source);
@@ -26946,6 +27355,236 @@ async function commandMcp(args, flags, options) {
26946
27355
  return result(2, "", `Usage: ${SAASFUNNELS_CLI_NAME} mcp serve
26947
27356
  `);
26948
27357
  }
27358
+ async function commandPlans(args, flags, options) {
27359
+ if (args[0] === "branches") {
27360
+ const planValues = flagList(flags, "plans");
27361
+ if (!planValues.length)
27362
+ return result(
27363
+ 2,
27364
+ "",
27365
+ "Missing --plans with the plan names from your Stripe catalog.\n"
27366
+ );
27367
+ const branches = await discoverPlanBranches({
27368
+ cwd: cwd(options),
27369
+ excludes: flagList(flags, "exclude"),
27370
+ planValues,
27371
+ roots: flagList(flags, "root").length ? flagList(flags, "root") : void 0
27372
+ });
27373
+ const clusters = clusterPlanBranches(branches);
27374
+ if (flags.json) {
27375
+ return jsonResult(0, { branches, clusters });
27376
+ }
27377
+ if (!branches.length) {
27378
+ return result(
27379
+ 0,
27380
+ "No plan branches found. This codebase may read entitlements rather than comparing plan names.\n",
27381
+ ""
27382
+ );
27383
+ }
27384
+ const lines = [
27385
+ `${branches.length} plan branch(es) in ${clusters.length} group(s):`,
27386
+ ""
27387
+ ];
27388
+ for (const cluster of clusters) {
27389
+ const polarity = cluster.polarity === "deny" ? "restricts" : cluster.polarity === "grant" ? "grants" : "unclear";
27390
+ lines.push(
27391
+ ` ${cluster.location} \u2014 ${cluster.planValues.join(", ")} (${cluster.shape}, ${polarity}, ${cluster.branches.length} site(s))`
27392
+ );
27393
+ }
27394
+ lines.push("");
27395
+ lines.push("Each group is one place your product differs by plan. Naming what");
27396
+ lines.push("each one gates is a review step; nothing is guessed here.");
27397
+ if (!hasFlag(flags, "send")) {
27398
+ lines.push("");
27399
+ lines.push("Add --send to hand these to your workspace for review.");
27400
+ return result(0, `${lines.join("\n")}
27401
+ `, "");
27402
+ }
27403
+ const repositoryKey = flagString(flags, "repository-key");
27404
+ if (!repositoryKey)
27405
+ return result(2, "", "Missing --repository-key for the plan branch handoff.\n");
27406
+ const repositoryRevision = flagString(flags, "repository-revision");
27407
+ if (!repositoryRevision)
27408
+ return result(2, "", "Missing --repository-revision for the plan branch handoff.\n");
27409
+ const key2 = envValue2(options, SAASFUNNELS_ENV.apiKey);
27410
+ if (!key2)
27411
+ return result(2, "", "Missing SAASFUNNELS_API_KEY with features:write for --send.\n");
27412
+ const payload = {
27413
+ clusters: clusters.map((cluster) => ({
27414
+ branchCount: cluster.branches.length,
27415
+ branches: cluster.branches.map((branch) => ({
27416
+ line: branch.line,
27417
+ planValue: branch.planValue,
27418
+ polarity: branch.polarity,
27419
+ repositoryPath: branch.repositoryPath,
27420
+ shape: branch.shape,
27421
+ symbol: branch.symbol
27422
+ })),
27423
+ location: cluster.location,
27424
+ planValues: cluster.planValues,
27425
+ polarity: cluster.polarity,
27426
+ shape: cluster.shape
27427
+ })),
27428
+ environment: flagString(flags, "environment") ?? "test",
27429
+ planValues,
27430
+ producer: flagString(flags, "producer") ?? "cli",
27431
+ repositoryKey,
27432
+ repositoryRevision,
27433
+ schemaVersion: 1
27434
+ };
27435
+ const response = await (options.fetch ?? fetch)(
27436
+ `${apiBaseUrl2(options, flags)}/api/developer-tools/plans/branches`,
27437
+ {
27438
+ body: JSON.stringify(payload),
27439
+ headers: {
27440
+ authorization: `Bearer ${key2}`,
27441
+ "content-type": "application/json"
27442
+ },
27443
+ method: "POST"
27444
+ }
27445
+ );
27446
+ const body = await response.json();
27447
+ if (!response.ok)
27448
+ return result(1, "", `${body.error ?? "The plan branch handoff failed."}
27449
+ `);
27450
+ if (jsonMode(flags)) return jsonResult(0, body.data ?? {});
27451
+ lines.push("");
27452
+ lines.push(`Sent ${body.data?.clusterCount ?? clusters.length} group(s) for review.`);
27453
+ if (body.data?.retainedReviews)
27454
+ lines.push(
27455
+ `${body.data.retainedReviews} already reviewed and left as answered.`
27456
+ );
27457
+ if (body.data?.reviewUrl) lines.push(body.data.reviewUrl);
27458
+ return result(0, `${lines.join("\n")}
27459
+ `, "");
27460
+ }
27461
+ if (args[0] === "discover") {
27462
+ const candidates = await discoverPlanSourceCandidates({
27463
+ cwd: cwd(options),
27464
+ excludes: flagList(flags, "exclude"),
27465
+ roots: flagList(flags, "root").length ? flagList(flags, "root") : void 0
27466
+ });
27467
+ if (!candidates.length) {
27468
+ return jsonMode(flags) ? jsonResult(0, { candidates: [], ok: true }) : result(
27469
+ 0,
27470
+ "No plan or pricing definition files found. Map plans to Features in SaaSFunnels instead.\n"
27471
+ );
27472
+ }
27473
+ const approved = candidates.slice(0, maxPlanSourceFiles);
27474
+ if (hasFlag(flags, "apply")) {
27475
+ if (options.prompt) {
27476
+ const confirmation = await options.prompt(
27477
+ `Approve these files for upload to SaaSFunnels: ${approved.map((candidate) => candidate.path).join(", ")}? [y/N] `
27478
+ );
27479
+ if (!/^y(?:es)?\$/i.test(confirmation.trim())) {
27480
+ return result(2, "", "Plan source approval was cancelled.\n");
27481
+ }
27482
+ }
27483
+ await writeApprovedPlanSources(
27484
+ cwd(options),
27485
+ approved.map((candidate) => candidate.path)
27486
+ );
27487
+ }
27488
+ if (jsonMode(flags))
27489
+ return jsonResult(0, {
27490
+ approvalPath: planSourceApprovalPath,
27491
+ candidates,
27492
+ ok: true,
27493
+ written: hasFlag(flags, "apply")
27494
+ });
27495
+ const confirmed = candidates.filter((candidate) => candidate.hasStripePriceId);
27496
+ const namedOnly = candidates.filter((candidate) => !candidate.hasStripePriceId);
27497
+ const lines = [];
27498
+ if (confirmed.length) {
27499
+ lines.push("Defines Stripe prices:");
27500
+ confirmed.forEach(
27501
+ (candidate) => lines.push(` ${candidate.path} (${candidate.kind})`)
27502
+ );
27503
+ }
27504
+ if (namedOnly.length) {
27505
+ if (confirmed.length) lines.push("");
27506
+ lines.push(
27507
+ confirmed.length ? "Named like pricing configuration, no Stripe price found:" : "No file defines a Stripe price. These matched by name only:"
27508
+ );
27509
+ namedOnly.forEach(
27510
+ (candidate) => lines.push(` ${candidate.path} (${candidate.kind})`)
27511
+ );
27512
+ }
27513
+ return result(
27514
+ 0,
27515
+ `${lines.join("\n")}
27516
+
27517
+ ${hasFlag(flags, "apply") ? `Approved ${approved.length} file(s) in ${planSourceApprovalPath}. Review and commit it.` : `Re-run with --apply to record these in ${planSourceApprovalPath}.`}
27518
+ `
27519
+ );
27520
+ }
27521
+ if (args[0] === "handoff") {
27522
+ const repositoryKey = flagString(flags, "repository-key");
27523
+ if (!repositoryKey)
27524
+ return result(2, "", "Missing --repository-key for the plan mapping handoff.\n");
27525
+ const repositoryRevision = flagString(flags, "repository-revision");
27526
+ if (!repositoryRevision)
27527
+ return result(
27528
+ 2,
27529
+ "",
27530
+ "Missing --repository-revision for the plan mapping handoff.\n"
27531
+ );
27532
+ const integrationId = flagString(flags, "integration-id");
27533
+ if (!integrationId)
27534
+ return result(2, "", "Missing --integration-id for the plan mapping handoff.\n");
27535
+ const files = await readApprovedPlanSources(cwd(options));
27536
+ if (!files.length)
27537
+ return result(
27538
+ 2,
27539
+ "",
27540
+ `No approved plan sources. Run "${SAASFUNNELS_CLI_NAME} plans discover --apply" and commit ${planSourceApprovalPath}.
27541
+ `
27542
+ );
27543
+ const { inputs } = await buildPlanMappingHandoff({ cwd: cwd(options), files });
27544
+ const requestKey = planMappingRequestKey({ repositoryKey, repositoryRevision });
27545
+ if (!hasFlag(flags, "send")) {
27546
+ return jsonMode(flags) ? jsonResult(0, {
27547
+ files: inputs.map((entry) => entry.label),
27548
+ ok: true,
27549
+ requestKey
27550
+ }) : result(
27551
+ 0,
27552
+ `Would upload for plan mapping:
27553
+ ${inputs.map((entry) => `- ${entry.label}`).join("\n")}
27554
+
27555
+ Add --send to upload.
27556
+ `
27557
+ );
27558
+ }
27559
+ const key2 = envValue2(options, SAASFUNNELS_ENV.apiKey);
27560
+ if (!key2)
27561
+ return result(
27562
+ 2,
27563
+ "",
27564
+ "Missing SAASFUNNELS_API_KEY with catalog access for --send.\n"
27565
+ );
27566
+ const response = await (options.fetch ?? fetch)(
27567
+ `${apiBaseUrl2(options, flags)}/api/funnels/catalog/plan-mapping/imports`,
27568
+ {
27569
+ body: JSON.stringify({ inputs, integrationId, requestKey }),
27570
+ headers: {
27571
+ authorization: `Bearer ${key2}`,
27572
+ "content-type": "application/json"
27573
+ },
27574
+ method: "POST"
27575
+ }
27576
+ );
27577
+ const body = await response.json();
27578
+ return jsonMode(flags) ? jsonResult(response.ok ? 0 : 1, body) : response.ok ? result(
27579
+ 0,
27580
+ `Staged ${inputs.length} plan source(s) for review in SaaSFunnels.
27581
+ `
27582
+ ) : result(1, "", "The plan mapping handoff was rejected.\n");
27583
+ }
27584
+ return result(2, "", `Unknown plans command.
27585
+
27586
+ ${usage()}`);
27587
+ }
26949
27588
  async function runSaaSFunnelsCli(argv2, options = {}) {
26950
27589
  const parsed = parseArgs2(argv2);
26951
27590
  const [command, ...args] = parsed.args;
@@ -26961,6 +27600,8 @@ async function runSaaSFunnelsCli(argv2, options = {}) {
26961
27600
  return await commandFeatures(args, parsed.flags, options);
26962
27601
  if (command === "catalog")
26963
27602
  return await commandCatalog(args, parsed.flags, options);
27603
+ if (command === "plans")
27604
+ return await commandPlans(args, parsed.flags, options);
26964
27605
  if (command === "doctor") return await commandDoctor(parsed.flags, options);
26965
27606
  if (command === "readiness")
26966
27607
  return await commandReadiness(parsed.flags, options);
@@ -1,6 +1,9 @@
1
1
  import { mkdir, rename, rm, writeFile } from "node:fs/promises";
2
2
  import { z } from "zod";
3
3
  declare const featureBindingKinds: readonly ["server_enforcement", "browser_presentation", "usage_reporter", "funnel_trigger", "discovery"];
4
+ export declare const defaultRoots: string[];
5
+ export declare const ignoredSegments: Set<string>;
6
+ export declare const sensitivePathPattern: RegExp;
4
7
  export declare const featureCatalogManifestSchema: z.ZodObject<{
5
8
  environment: z.ZodEnum<{
6
9
  test: "test";
@@ -1,5 +1,5 @@
1
1
  export declare const SAASFUNNELS_CLI_NAME = "saasfunnels";
2
- export declare const SAASFUNNELS_CLI_VERSION = "0.1.0-beta.1";
2
+ export declare const SAASFUNNELS_CLI_VERSION = "0.2.1";
3
3
  export declare const SAASFUNNELS_PRODUCT_NAME = "SaaSFunnels";
4
4
  export declare const SAASFUNNELS_DEFAULT_API_BASE_URL = "https://app.saasfunnels.ai";
5
5
  export declare const SAASFUNNELS_ENV: Readonly<{
@@ -68,7 +68,7 @@ export type SaaSFunnelsMcpOptions = {
68
68
  output?: Pick<NodeJS.WritableStream, "write">;
69
69
  };
70
70
  declare const mcpProtocolVersion = "2025-06-18";
71
- declare const mcpServerVersion = "0.1.0-beta.1";
71
+ declare const mcpServerVersion = "0.2.1";
72
72
  export declare function saasFunnelsMcpResources(): McpResourceDefinition[];
73
73
  export declare function readSaaSFunnelsMcpResource(uri: string, options?: SaaSFunnelsMcpOptions): string;
74
74
  export declare function saasFunnelsMcpToolDefinitions({ allowFunnelTests, allowFunnelWrites, allowTestWrites, includeHostedAccountTools, }?: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saasfunnels",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "description": "SaaSFunnels CLI and local stdio MCP server",
6
6
  "author": "SaaSFunnels",
@@ -57,7 +57,8 @@
57
57
  "verify:metadata": "node scripts/verify-package-metadata.mjs",
58
58
  "verify:pack": "node scripts/verify-pack.mjs",
59
59
  "verify:installed": "node scripts/smoke-installed-package.mjs",
60
- "release:verify": "npm run typecheck && npm test && npm run security:secrets && npm run security:audit && npm run verify:metadata && npm run verify:pack && npm run verify:installed"
60
+ "release:verify": "npm run typecheck && npm test && npm run security:secrets && npm run security:audit && npm run verify:metadata && npm run verify:pack && npm run verify:installed",
61
+ "eval": "node eval/run.mjs"
61
62
  },
62
63
  "devDependencies": {
63
64
  "@npmcli/package-json": "7.0.5",