kalup 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -234,6 +234,20 @@ var ir_1_schema_default = {
234
234
  "properties": { "type": { "const": "object" } }
235
235
  },
236
236
  "then": { "properties": { "definition": { "$ref": "#/$defs/objectDefinition" } } }
237
+ },
238
+ {
239
+ "if": {
240
+ "required": ["type"],
241
+ "properties": { "type": { "const": "pipeline" } }
242
+ },
243
+ "then": { "properties": { "definition": { "$ref": "#/$defs/pipelineDefinition" } } }
244
+ },
245
+ {
246
+ "if": {
247
+ "required": ["type"],
248
+ "properties": { "type": { "const": "stage" } }
249
+ },
250
+ "then": { "properties": { "definition": { "$ref": "#/$defs/stageDefinition" } } }
237
251
  }
238
252
  ]
239
253
  },
@@ -357,6 +371,43 @@ var ir_1_schema_default = {
357
371
  },
358
372
  "additionalProperties": false
359
373
  },
374
+ "pipelineDefinition": {
375
+ "description": "A pipeline. Its stages are resources of their own; stages lists their IDs in display order.",
376
+ "type": "object",
377
+ "required": [
378
+ "label",
379
+ "displayOrder",
380
+ "stages"
381
+ ],
382
+ "properties": {
383
+ "label": { "type": "string" },
384
+ "displayOrder": {
385
+ "type": "integer",
386
+ "minimum": 0
387
+ },
388
+ "stages": {
389
+ "type": "array",
390
+ "items": { "type": "string" }
391
+ }
392
+ },
393
+ "additionalProperties": false
394
+ },
395
+ "stageDefinition": {
396
+ "description": "A pipeline stage. Its metadata field depends on the object: probability on deals, ticketState on tickets, state on custom objects.",
397
+ "type": "object",
398
+ "required": ["label"],
399
+ "properties": {
400
+ "label": { "type": "string" },
401
+ "probability": {
402
+ "type": "number",
403
+ "minimum": 0,
404
+ "maximum": 1
405
+ },
406
+ "ticketState": { "enum": ["OPEN", "CLOSED"] },
407
+ "state": { "enum": ["OPEN", "CLOSED"] }
408
+ },
409
+ "additionalProperties": false
410
+ },
360
411
  "binding": {
361
412
  "description": "App terms, filled in by the loader. key and codec for properties, export for objects.",
362
413
  "type": "object",
@@ -541,6 +592,14 @@ var ir_1_schema_default = {
541
592
  "object": {
542
593
  "type": "array",
543
594
  "items": { "type": "string" }
595
+ },
596
+ "pipeline": {
597
+ "type": "array",
598
+ "items": { "type": "string" }
599
+ },
600
+ "stage": {
601
+ "type": "array",
602
+ "items": { "type": "string" }
544
603
  }
545
604
  },
546
605
  "additionalProperties": false
@@ -628,6 +687,16 @@ var ir_1_schema_default = {
628
687
  "type": "object",
629
688
  "patternProperties": { "^[a-z]+:\\S+$": { "type": "string" } },
630
689
  "additionalProperties": false
690
+ },
691
+ "pipelines": {
692
+ "description": "Whether the object's pipelines list was read. Absent when its pipelines are not in scope.",
693
+ "type": "object",
694
+ "required": ["status"],
695
+ "properties": {
696
+ "status": { "enum": ["read", "unreadable"] },
697
+ "missingScope": { "type": "string" }
698
+ },
699
+ "additionalProperties": false
631
700
  }
632
701
  },
633
702
  "additionalProperties": false
@@ -1261,6 +1330,44 @@ const RESERVED_PREFIX = /^(hs_|a\d+_)/;
1261
1330
  function reservedPrefix(name) {
1262
1331
  return RESERVED_PREFIX.exec(name)?.[1];
1263
1332
  }
1333
+ /** Every stage metadata field Kalup manages, in the order the writer and the plan list them. */
1334
+ const STAGE_FIELDS = [
1335
+ "probability",
1336
+ "ticketState",
1337
+ "state"
1338
+ ];
1339
+ const FIELD_BY_OBJECT = {
1340
+ deals: "probability",
1341
+ tickets: "ticketState"
1342
+ };
1343
+ /**
1344
+ * The standard objects that have pipelines: the pipelines path answered for each on 2026-10-05 (leads with a 403 for a
1345
+ * missing scope). Every custom object may have them too.
1346
+ */
1347
+ const PIPELINE_OBJECTS = /* @__PURE__ */ new Set([
1348
+ "appointments",
1349
+ "companies",
1350
+ "contacts",
1351
+ "courses",
1352
+ "deals",
1353
+ "leads",
1354
+ "listings",
1355
+ "orders",
1356
+ "services",
1357
+ "tickets"
1358
+ ]);
1359
+ /** Whether an object can have pipelines: one of PIPELINE_OBJECTS, or a custom object. */
1360
+ function hasPipelines(object, custom) {
1361
+ return custom || PIPELINE_OBJECTS.has(object);
1362
+ }
1363
+ /**
1364
+ * The metadata field of a stage of `object`, or undefined for an object whose pipelines Kalup reads and compares but
1365
+ * does not write: every standard object but deals and tickets. `custom` says whether the object is a custom object.
1366
+ */
1367
+ function stageField(object, custom) {
1368
+ if (custom) return "state";
1369
+ return Object.hasOwn(FIELD_BY_OBJECT, object) ? FIELD_BY_OBJECT[object] : void 0;
1370
+ }
1264
1371
  const ADDRESS = /^[a-z]+:\S+$/;
1265
1372
  /** Whether `value` is an address: a lowercase type, a colon and a path without whitespace. */
1266
1373
  function isAddress(value) {
@@ -1314,6 +1421,124 @@ const PROPERTY_FIELDS = [
1314
1421
  "externalOptions",
1315
1422
  "referencedObjectType"
1316
1423
  ];
1424
+ /**
1425
+ * The objects whose properties the properties API lists by name. A config key outside this set names a custom object
1426
+ * schema. The set is the 2026-09 `crm.schemas.<object>.read` scope list plus the objects that carry their own scopes
1427
+ * (products, leads, goals, marketing events, feedback submissions, users).
1428
+ */
1429
+ const STANDARD_OBJECTS = /* @__PURE__ */ new Set([
1430
+ "appointments",
1431
+ "calls",
1432
+ "carts",
1433
+ "commerce_payments",
1434
+ "communications",
1435
+ "companies",
1436
+ "contacts",
1437
+ "courses",
1438
+ "deals",
1439
+ "emails",
1440
+ "feedback_submissions",
1441
+ "goals",
1442
+ "invoices",
1443
+ "leads",
1444
+ "line_items",
1445
+ "listings",
1446
+ "marketing_events",
1447
+ "meetings",
1448
+ "notes",
1449
+ "orders",
1450
+ "postal_mail",
1451
+ "products",
1452
+ "projects",
1453
+ "quotes",
1454
+ "services",
1455
+ "subscriptions",
1456
+ "tasks",
1457
+ "tickets",
1458
+ "users"
1459
+ ]);
1460
+ /**
1461
+ * The object type ID of each standard object, by config key. Limits Tracking keys its per-object entries by these.
1462
+ * Source: "Object type ID values", retrieved 2026-09-24:
1463
+ * https://developers.hubspot.com/docs/api-reference/latest/crm/understanding-the-crm
1464
+ * A plain record: look a key up with `Object.hasOwn`, so `constructor` finds nothing.
1465
+ */
1466
+ const STANDARD_OBJECT_TYPE_IDS = {
1467
+ appointments: "0-421",
1468
+ calls: "0-48",
1469
+ carts: "0-142",
1470
+ commerce_payments: "0-101",
1471
+ communications: "0-18",
1472
+ companies: "0-2",
1473
+ contacts: "0-1",
1474
+ courses: "0-410",
1475
+ deals: "0-3",
1476
+ emails: "0-49",
1477
+ feedback_submissions: "0-19",
1478
+ goals: "0-74",
1479
+ invoices: "0-53",
1480
+ leads: "0-136",
1481
+ line_items: "0-8",
1482
+ listings: "0-420",
1483
+ marketing_events: "0-54",
1484
+ meetings: "0-47",
1485
+ notes: "0-46",
1486
+ orders: "0-123",
1487
+ postal_mail: "0-116",
1488
+ products: "0-7",
1489
+ projects: "0-970",
1490
+ quotes: "0-14",
1491
+ services: "0-162",
1492
+ subscriptions: "0-69",
1493
+ tasks: "0-27",
1494
+ tickets: "0-5",
1495
+ users: "0-115"
1496
+ };
1497
+ /** One object's pull scope from its settings under `objects` and the property names its object file defines. */
1498
+ function scopeOf(scope = {}, defined = []) {
1499
+ return {
1500
+ custom: scope.custom ?? true,
1501
+ defined: new Set(defined),
1502
+ exclude: excluder(scope.exclude),
1503
+ include: new Set(scope.include ?? [])
1504
+ };
1505
+ }
1506
+ /** The names of the properties the object files define on `object`, references included. */
1507
+ function definedOn(ir, object) {
1508
+ const prefix = `property:${object}/`;
1509
+ return Object.keys(ir.resources).filter((address) => address.startsWith(prefix)).map((address) => address.slice(prefix.length));
1510
+ }
1511
+ /**
1512
+ * A portal property is in scope when the object files define it or `include` names it, whatever `custom` and
1513
+ * `exclude` say, or when it is custom, `custom` is on and `exclude` does not name it. validate refuses a name both
1514
+ * `include` and `exclude` hold.
1515
+ */
1516
+ function inScope(scope, property) {
1517
+ const { name } = property;
1518
+ return scope.defined.has(name) || scope.include.has(name) || scope.custom && !property.hubspotDefined && !scope.exclude(name);
1519
+ }
1520
+ /** The `exclude` patterns of one object as a predicate over internal names. */
1521
+ function excluder(patterns = []) {
1522
+ const matchers = patterns.map(addressMatcher);
1523
+ return (name) => matchers.some((matches) => matches(name));
1524
+ }
1525
+ /**
1526
+ * Whether an object's pipelines are in scope: `pipelines: true` under `objects`, or a pipeline or stage of it in the
1527
+ * files or in removed.ts. Unlike `custom`, which filters what pull writes while the properties are read anyway, a
1528
+ * pipelines list nobody asked for is never read: an upgrade must not widen what a project manages without a line in
1529
+ * config saying so.
1530
+ */
1531
+ function pipelinesInScope(scope, ir, object) {
1532
+ const ours = (address) => address.startsWith(`pipeline:${object}/`) || address.startsWith(`stage:${object}/`);
1533
+ return scope?.pipelines === true || Object.keys(ir.resources).some(ours) || Object.keys(ir.tombstones).some(ours);
1534
+ }
1535
+ /** The --only glob as a predicate over addresses. `*` matches any run of characters, `/` included. */
1536
+ function addressMatcher(glob) {
1537
+ if (glob === void 0) return () => true;
1538
+ const literals = glob.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
1539
+ const pattern = new RegExp(`^${literals.join(".*")}$`);
1540
+ return (address) => pattern.test(address);
1541
+ }
1317
1542
  const punct = "{}[]()<>,:;.=&|?!+-*/%~^@#";
1318
1543
  const escapes = {
1319
1544
  n: "\n",
@@ -1542,8 +1767,8 @@ const CHAIN_FLAGS = [
1542
1767
  const toolOwned = ["@kalup/core", "kalup"];
1543
1768
  const typeLineFix = "write `export type <Name>Data = InferProperties<typeof <Name>.properties> & { id: string }`";
1544
1769
  /**
1545
- * Parses one object file, kalup.config.ts or removed.ts into plain data. Throws IssueError on anything outside
1546
- * the grammar. `kind` is the kind the file must be, when its path decides it; otherwise the content does.
1770
+ * Parses one object file, pipeline file, kalup.config.ts or removed.ts into plain data. Throws IssueError on anything
1771
+ * outside the grammar. `kind` is the kind the file must be, when its path decides it; otherwise the content does.
1547
1772
  */
1548
1773
  function read(text, file, kind) {
1549
1774
  const src = (text.charCodeAt(0) === 65279 ? text.slice(1) : text).replace(/\r\n?/g, "\n");
@@ -1574,6 +1799,14 @@ function read(text, file, kind) {
1574
1799
  },
1575
1800
  lines: s.lines
1576
1801
  };
1802
+ if (found === "pipeline") return {
1803
+ kind: "pipeline",
1804
+ data: {
1805
+ ...top,
1806
+ ...parsePipelineFile(s, imports)
1807
+ },
1808
+ lines: s.lines
1809
+ };
1577
1810
  return {
1578
1811
  kind: "object",
1579
1812
  data: {
@@ -1586,7 +1819,7 @@ function read(text, file, kind) {
1586
1819
  function kindAt(s) {
1587
1820
  let j = s.i;
1588
1821
  while (at(s, j).kind === "comment") j += 1;
1589
- if (!(is(at(s, j), "ident", "export") && is(at(s, j + 1), "ident", "default"))) return "object";
1822
+ if (!(is(at(s, j), "ident", "export") && is(at(s, j + 1), "ident", "default"))) return is(at(s, j + 1), "ident", "const") && is(at(s, j + 4), "ident", "definePipeline") ? "pipeline" : "object";
1590
1823
  return is(at(s, j + 2), "ident", "defineRemoved") ? "removed" : "config";
1591
1824
  }
1592
1825
  function at(s, i) {
@@ -1795,6 +2028,10 @@ const SETTINGS = {
1795
2028
  levels: ["object"],
1796
2029
  example: "'Firm'"
1797
2030
  },
2031
+ pipelines: {
2032
+ levels: ["object"],
2033
+ example: "true"
2034
+ },
1798
2035
  protected: {
1799
2036
  levels: ["target"],
1800
2037
  example: "true"
@@ -1915,10 +2152,19 @@ const definitionFields = {
1915
2152
  lifecycle: lifecycle$1
1916
2153
  };
1917
2154
  const definition = shape(definitionFields, [], misplaced("a property definition"));
1918
- const overrideDefinition = shape(definitionFields, [], (key) => ({
2155
+ const stageState = oneOf("OPEN", "CLOSED");
2156
+ const stageFields$1 = {
2157
+ probability: num,
2158
+ ticketState: stageState,
2159
+ state: stageState
2160
+ };
2161
+ const overrideDefinition = shape({
2162
+ ...definitionFields,
2163
+ ...stageFields$1
2164
+ }, [], (key) => ({
1919
2165
  code: "E_OVERRIDE_DEFINITION",
1920
2166
  message: key === "type" ? "'type' comes from the builder, so it cannot differ per target" : `unknown field '${key}' in a definition override`,
1921
- fix: "override only label, description, group, fieldType, formField, options, hidden, displayOrder, the display hints, calculationFormula or lifecycle"
2167
+ fix: "override only label, description, group, fieldType, formField, options, hidden, displayOrder, the display hints, calculationFormula or lifecycle, or a stage metadata field"
1922
2168
  }));
1923
2169
  const customFields = {
1924
2170
  labels: shape({
@@ -1956,7 +2202,8 @@ const config = shape({
1956
2202
  include: list$1(str),
1957
2203
  exclude: list$1(str),
1958
2204
  custom: bool,
1959
- as: str
2205
+ as: str,
2206
+ pipelines: bool
1960
2207
  }, [], misplaced())),
1961
2208
  targets: map(shape({
1962
2209
  portalId: num,
@@ -2127,6 +2374,75 @@ function parseTypeLine(s, exports, typed) {
2127
2374
  if (typed.has(ref.value)) fail$1(s, "E_NOT_DATA", nameTok, `'${nameTok.value}' is exported twice`, "remove the duplicate type export");
2128
2375
  typed.add(ref.value);
2129
2376
  }
2377
+ const PIPELINE_FIX = "only imports and `export const <Name> = definePipeline('<object>', {...})` are allowed here";
2378
+ const stage = shape({
2379
+ id: str,
2380
+ label: str,
2381
+ ...stageFields$1
2382
+ }, ["id", "label"]);
2383
+ function parsePipelineFile(s, imports) {
2384
+ const exports = [];
2385
+ for (;;) {
2386
+ const cs = takeComments(s);
2387
+ if (at(s, s.i).kind === "eof") {
2388
+ if (!exports.length) fail$1(s, "E_MISSING_EXPORT", at(s, 0), "no definePipeline export in this file", "add `export const <Name> = definePipeline('<object>', {...})`");
2389
+ if (cs[0]) failComment(s, cs[0]);
2390
+ return {
2391
+ imports,
2392
+ exports
2393
+ };
2394
+ }
2395
+ expect(s, "ident", "export", PIPELINE_FIX);
2396
+ expect(s, "ident", "const", PIPELINE_FIX);
2397
+ exports.push(parsePipeline(s, cs, exports));
2398
+ }
2399
+ }
2400
+ function parsePipeline(s, cs, exports) {
2401
+ const nameTok = next(s);
2402
+ if (nameTok.kind !== "ident") fail$1(s, "E_NOT_DATA", nameTok, `expected an export name but found ${show$3(nameTok)}`, "write export const <Name> = definePipeline(...)");
2403
+ const name = nameTok.value;
2404
+ if (exports.some((x) => x.name === name)) fail$1(s, "E_DUPLICATE_KEY", nameTok, `duplicate export '${name}'`, "rename one of the two exports", name);
2405
+ expect(s, "punct", "=", "write export const <Name> = definePipeline(...)", name);
2406
+ const b = next(s);
2407
+ if (!is(b, "ident", "definePipeline")) fail$1(s, "E_NOT_DATA", b, `'${b.value}' is not definePipeline`, "write definePipeline('<object>', {...})", name);
2408
+ expect(s, "punct", "(", void 0, name);
2409
+ const object = str(s, name);
2410
+ expect(s, "punct", ",", "add the pipeline literal as the second argument", name);
2411
+ s.lines[name] = nameTok.line;
2412
+ const fields = {};
2413
+ const open = peek(s);
2414
+ entries(s, name, false, (key, tok) => {
2415
+ const path = join$2(name, key);
2416
+ if (key === "id" || key === "label") fields[key] = str(s, path);
2417
+ else if (key === "displayOrder") fields.displayOrder = num(s, path);
2418
+ else if (key === "stages") {
2419
+ const stages = [];
2420
+ entries(s, path, true, (k, _t, scs) => {
2421
+ stages.push({
2422
+ key: k,
2423
+ ...stage(s, join$2(path, k)),
2424
+ comments: texts(scs)
2425
+ });
2426
+ });
2427
+ fields.stages = stages;
2428
+ } else fail$1(s, "E_NOT_DATA", tok, `unknown field '${key}'`, "use one of id, label, displayOrder, stages", name);
2429
+ });
2430
+ for (const k of [
2431
+ "id",
2432
+ "label",
2433
+ "displayOrder",
2434
+ "stages"
2435
+ ]) if (fields[k] === void 0) fail$1(s, "E_NOT_DATA", open, `missing field '${k}'`, `add ${k}`, name);
2436
+ skip(s, ",");
2437
+ expect(s, "punct", ")", void 0, name);
2438
+ skip(s, ";");
2439
+ return {
2440
+ ...fields,
2441
+ name,
2442
+ object,
2443
+ comments: texts(cs)
2444
+ };
2445
+ }
2130
2446
  function parseConfig(s, imports) {
2131
2447
  const fix = "write export default defineConfig({...})";
2132
2448
  expect(s, "ident", "export", fix);
@@ -2209,6 +2525,10 @@ function inDir(at, file) {
2209
2525
  function objectPath(at, object) {
2210
2526
  return `${at.dir}/objects/${object}.ts`;
2211
2527
  }
2528
+ /** The file pull writes an object's new pipelines into: `<dir>/pipelines/<object>.ts`. */
2529
+ function pipelinePath(at, object) {
2530
+ return `${at.dir}/pipelines/${object}.ts`;
2531
+ }
2212
2532
  /** An object file's path from the barrel, without the extension, such as `./objects/companies`. */
2213
2533
  function barrelPath(at, file) {
2214
2534
  return `./${file.slice(at.dir.length + 1, -3)}`;
@@ -2218,8 +2538,9 @@ const TRAILING_SEPARATORS = /[\\/]+$/;
2218
2538
  const SEPARATOR = /[\\/]/;
2219
2539
  /**
2220
2540
  * Builds the IR from a map of relative path to text. Reads kalup.config.ts, then in the folder of object files
2221
- * (<dir>/) removed.ts, every <dir>/** /*.ts except index.ts, and blueprints.lock.json, whose provenance it merges into
2222
- * the resources the lock lists. Throws an IssueError, with every issue found, when the files cannot yield one IR.
2541
+ * (<dir>/) removed.ts, every <dir>/** /*.ts except index.ts (those under <dir>/pipelines/ as pipeline files), and
2542
+ * blueprints.lock.json, whose provenance it merges into the resources the lock lists. Throws an IssueError, with every
2543
+ * issue found, when the files cannot yield one IR.
2223
2544
  */
2224
2545
  function loadFiles(files, options = {}) {
2225
2546
  const issues = [];
@@ -2342,19 +2663,22 @@ function readObjectFiles(files, at, issues) {
2342
2663
  const out = [];
2343
2664
  for (const file of Object.keys(files).sort()) {
2344
2665
  if (!inDir(at, file) || file === at.barrel || file === at.removed) continue;
2345
- const later = notReadYet(at, file);
2346
- if (later) {
2347
- issues.push(unsupported$1(at, file, `this version does not read ${later} yet`));
2348
- continue;
2349
- }
2666
+ const pipelines = inPipelines(at, file);
2350
2667
  try {
2351
- const result = read(files[file] ?? "", file);
2352
- if (result.kind === "object") out.push({
2353
- file,
2354
- data: result.data,
2355
- lines: result.lines
2356
- });
2357
- else if (result.kind === "config") issues.push(unsupported$1(at, file, `a defineConfig file under ${at.dir}/ is not an object file`));
2668
+ const result = read(files[file] ?? "", file, pipelines ? "pipeline" : void 0);
2669
+ if (result.kind === "object" || result.kind === "pipeline") {
2670
+ if (result.kind === "pipeline" && !pipelines) {
2671
+ const fix = `move it to ${at.dir}/pipelines/`;
2672
+ issues.push(unsupported$1(at, file, `a definePipeline file belongs under ${at.dir}/pipelines/`, fix));
2673
+ continue;
2674
+ }
2675
+ out.push({
2676
+ file,
2677
+ kind: result.kind,
2678
+ data: result.data,
2679
+ lines: result.lines
2680
+ });
2681
+ } else if (result.kind === "config") issues.push(unsupported$1(at, file, `a defineConfig file under ${at.dir}/ is not an object file`));
2358
2682
  else {
2359
2683
  const fix = `move its entries to ${at.removed}`;
2360
2684
  issues.push(unsupported$1(at, file, `a defineRemoved file belongs at ${at.removed}`, fix));
@@ -2366,9 +2690,9 @@ function readObjectFiles(files, at, issues) {
2366
2690
  }
2367
2691
  return out;
2368
2692
  }
2369
- /** What a file in the folder of object files holds that this version does not read yet. */
2370
- function notReadYet(at, file) {
2371
- return file.startsWith(`${at.dir}/pipelines/`) ? "pipelines" : void 0;
2693
+ /** Whether a file lies in the pipelines folder, `<dir>/pipelines/`, which holds definePipeline files only. */
2694
+ function inPipelines(at, file) {
2695
+ return file.startsWith(`${at.dir}/pipelines/`);
2372
2696
  }
2373
2697
  function unsupported$1(at, file, message, fix = `move ${file} out of ${at.dir}/ until a release reads it`) {
2374
2698
  return {
@@ -2397,26 +2721,36 @@ function flatten(objectFiles, issues) {
2397
2721
  resources[address] = resource;
2398
2722
  sources[address] = source;
2399
2723
  };
2400
- for (const { file, data, lines } of objectFiles) for (const e of data.exports) {
2401
- const at = (configPath) => ({
2402
- file,
2403
- line: lines[configPath] ?? 1,
2404
- configPath
2405
- });
2406
- if (e.builder === "defineCustomObject") {
2407
- const resource = objectResource$1(e, at(e.name), issues);
2408
- if (resource) add(`object:${e.object}`, resource, at(e.name));
2724
+ for (const { file, kind, data, lines } of objectFiles) {
2725
+ if (kind === "pipeline") {
2726
+ for (const e of data.exports) flattenPipeline(e, (configPath) => ({
2727
+ file,
2728
+ line: lines[configPath] ?? 1,
2729
+ configPath
2730
+ }), add, issues);
2731
+ continue;
2409
2732
  }
2410
- for (const group of e.groups) {
2411
- const resource = {
2412
- type: "group",
2413
- managed: true,
2414
- definition: { label: group.label }
2415
- };
2416
- add(`group:${e.object}/${group.name}`, resource, at(`${e.name}.groups.${group.name}`));
2733
+ for (const e of data.exports) {
2734
+ const at = (configPath) => ({
2735
+ file,
2736
+ line: lines[configPath] ?? 1,
2737
+ configPath
2738
+ });
2739
+ if (e.builder === "defineCustomObject") {
2740
+ const resource = objectResource$1(e, at(e.name), issues);
2741
+ if (resource) add(`object:${e.object}`, resource, at(e.name));
2742
+ }
2743
+ for (const group of e.groups) {
2744
+ const resource = {
2745
+ type: "group",
2746
+ managed: true,
2747
+ definition: { label: group.label }
2748
+ };
2749
+ add(`group:${e.object}/${group.name}`, resource, at(`${e.name}.groups.${group.name}`));
2750
+ }
2751
+ flattenProperties(e, at, add, issues);
2752
+ optionsStated.push(...statedOptions(e, (address) => sources[address]?.file === file));
2417
2753
  }
2418
- flattenProperties(e, at, add, issues);
2419
- optionsStated.push(...statedOptions(e, (address) => sources[address]?.file === file));
2420
2754
  }
2421
2755
  return {
2422
2756
  resources,
@@ -2424,6 +2758,51 @@ function flatten(objectFiles, issues) {
2424
2758
  optionsStated: [...new Set(optionsStated)]
2425
2759
  };
2426
2760
  }
2761
+ /** The address of a pipeline, and of one of its stages. */
2762
+ function pipelineAddress(object, id) {
2763
+ return `pipeline:${object}/${id}`;
2764
+ }
2765
+ function stageAddress(object, pipeline, stage) {
2766
+ return `stage:${object}/${pipeline}/${stage}`;
2767
+ }
2768
+ function flattenPipeline(e, at, add, issues) {
2769
+ const unaddressable = (id, source) => {
2770
+ const held = isAddress(pipelineAddress(e.object, id)) && !id.includes("/") && !e.object.includes("/");
2771
+ if (!held) issues.push({
2772
+ code: "E_PIPELINE_ID",
2773
+ message: `the ID '${id}' holds whitespace or a slash, so no address can hold it`,
2774
+ ...source,
2775
+ fix: "use an ID without whitespace or slashes"
2776
+ });
2777
+ return !held;
2778
+ };
2779
+ if (unaddressable(e.id, at(`${e.name}.id`))) return;
2780
+ add(pipelineAddress(e.object, e.id), {
2781
+ type: "pipeline",
2782
+ managed: true,
2783
+ definition: {
2784
+ label: e.label,
2785
+ displayOrder: e.displayOrder,
2786
+ stages: e.stages.map((s) => s.id)
2787
+ },
2788
+ binding: { export: e.name }
2789
+ }, at(e.name));
2790
+ for (const st of e.stages) {
2791
+ const stage = stageAddress(e.object, e.id, st.id);
2792
+ if (unaddressable(st.id, at(`${e.name}.stages.${st.key}.id`))) continue;
2793
+ add(stage, {
2794
+ type: "stage",
2795
+ managed: true,
2796
+ definition: compact$5({
2797
+ label: st.label,
2798
+ probability: st.probability,
2799
+ ticketState: st.ticketState,
2800
+ state: st.state
2801
+ }),
2802
+ binding: { key: st.key }
2803
+ }, at(`${e.name}.stages.${st.key}`));
2804
+ }
2805
+ }
2427
2806
  function statedOptions(e, mine) {
2428
2807
  return e.properties.filter((p) => p.definition?.lifecycle?.options !== void 0).map((p) => `property:${e.object}/${p.name}`).filter(mine);
2429
2808
  }
@@ -2599,7 +2978,10 @@ function byCodeUnit(a, b) {
2599
2978
  function basename$1(root) {
2600
2979
  return root.replace(TRAILING_SEPARATORS, "").split(SEPARATOR).pop() ?? "";
2601
2980
  }
2602
- /** The definition fields a target may override, by resource type. */
2981
+ /**
2982
+ * The definition fields a target may override, by resource type. A stage takes the metadata field of its pipeline's
2983
+ * object only, which validate checks.
2984
+ */
2603
2985
  const OVERRIDABLE = {
2604
2986
  property: [
2605
2987
  "label",
@@ -2616,7 +2998,14 @@ const OVERRIDABLE = {
2616
2998
  "textDisplayHint",
2617
2999
  "calculationFormula"
2618
3000
  ],
2619
- group: ["label"]
3001
+ group: ["label"],
3002
+ pipeline: ["label", "displayOrder"],
3003
+ stage: [
3004
+ "label",
3005
+ "probability",
3006
+ "ticketState",
3007
+ "state"
3008
+ ]
2620
3009
  };
2621
3010
  /** The lifecycle fields a target may override on a property, each on its own. */
2622
3011
  const OVERRIDABLE_LIFECYCLE = [
@@ -2646,10 +3035,18 @@ function effectiveResources(ir, target) {
2646
3035
  */
2647
3036
  function withDefinition(address, resource, override) {
2648
3037
  const { type, path } = parseAddress(address);
2649
- if (!(resource.managed && (type === "property" || type === "group"))) return resource;
2650
- const stated = pick$3(override, OVERRIDABLE[type]);
2651
- const object = path.slice(0, path.indexOf("/"));
2652
- const merged = {
3038
+ if (!(resource.managed && Object.hasOwn(OVERRIDABLE, type))) return resource;
3039
+ const fields = OVERRIDABLE[type];
3040
+ const stated = pick$3(override, fields);
3041
+ if (type === "pipeline" || type === "stage") return {
3042
+ ...resource,
3043
+ definition: {
3044
+ ...resource.definition,
3045
+ ...stated
3046
+ }
3047
+ };
3048
+ const object = path.slice(0, path.indexOf("/"));
3049
+ const merged = {
2653
3050
  ...resource.definition,
2654
3051
  ...definitionToIR(object, resource.binding?.codec ?? "string", stated)
2655
3052
  };
@@ -2673,10 +3070,26 @@ function own$5(record, key) {
2673
3070
  return Object.hasOwn(record, key) ? record[key] : void 0;
2674
3071
  }
2675
3072
  const CONFIG$2$1 = "kalup.config.ts";
2676
- /** The resource types a tombstone may name in this version. */
2677
- const REMOVABLE$1 = ["property", "group"];
2678
- /** The path of a property or group address: its object and its name. */
2679
- const ON_OBJECT$1 = /^[^\s/]+\/[^\s/]+$/;
3073
+ /** The resource types a tombstone may name in this version, and the shape of each one's path. */
3074
+ /** The address types a tombstone may name, each with its form and the shape of its path. */
3075
+ const REMOVABLE = {
3076
+ property: {
3077
+ form: "property:<object>/<name>",
3078
+ path: /^[^\s/]+\/[^\s/]+$/
3079
+ },
3080
+ group: {
3081
+ form: "group:<object>/<name>",
3082
+ path: /^[^\s/]+\/[^\s/]+$/
3083
+ },
3084
+ pipeline: {
3085
+ form: "pipeline:<object>/<id>",
3086
+ path: /^[^\s/]+\/[^\s/]+$/
3087
+ },
3088
+ stage: {
3089
+ form: "stage:<object>/<pipeline>/<stage>",
3090
+ path: /^[^\s/]+\/[^\s/]+\/[^\s/]+$/
3091
+ }
3092
+ };
2680
3093
  /** The definition fields a property can own, the names `lifecycle.ignoreChanges` may use. */
2681
3094
  const DEFINITION_FIELDS = PROPERTY_FIELDS.filter((field) => field !== "type" && field !== "externalOptions" && field !== "referencedObjectType");
2682
3095
  function validate$2(loaded, options = {}) {
@@ -2714,12 +3127,163 @@ function validate$2(loaded, options = {}) {
2714
3127
  warnings
2715
3128
  });
2716
3129
  checkTombstones(loaded, issues);
3130
+ checkPipelines(loaded, issues);
2717
3131
  return {
2718
3132
  issues,
2719
3133
  warnings
2720
3134
  };
2721
3135
  }
2722
3136
  /**
3137
+ * The rules HubSpot keeps for pipelines and stages, observed 2026-10-01 and 2026-10-05: IDs of a length it stores and
3138
+ * not held elsewhere (a pipeline ID across the portal, a stage ID across one object's pipelines), at least one stage
3139
+ * and on tickets one closed one, labels unique ignoring case (a stage's also ignoring spaces around it), and each
3140
+ * stage's metadata field the one its object takes.
3141
+ */
3142
+ function checkPipelines(loaded, issues) {
3143
+ const { ir, sources } = loaded;
3144
+ const at = (address, suffix = "") => {
3145
+ const source = sources[address] ?? {
3146
+ file: "",
3147
+ line: 0,
3148
+ configPath: address
3149
+ };
3150
+ return {
3151
+ file: source.file,
3152
+ line: source.line,
3153
+ configPath: source.configPath + suffix
3154
+ };
3155
+ };
3156
+ const pipelineIds = /* @__PURE__ */ new Map();
3157
+ const pipelineLabels = /* @__PURE__ */ new Map();
3158
+ const stageIds = /* @__PURE__ */ new Map();
3159
+ for (const [address, resource] of Object.entries(ir.resources)) {
3160
+ if (resource.type !== "pipeline") continue;
3161
+ const [object = "", id = ""] = parseAddress(address).path.split("/");
3162
+ const d = resource.definition ?? {};
3163
+ if (!hasPipelines(object, !STANDARD_OBJECTS.has(object))) issues.push({
3164
+ code: "E_PIPELINE_FIELD",
3165
+ message: `${address} is a pipeline of ${object}, which has no pipelines`,
3166
+ ...at(address),
3167
+ fix: "name an object that has pipelines, such as deals, tickets or a custom object"
3168
+ });
3169
+ const first = pipelineIds.get(id);
3170
+ if (first === void 0) pipelineIds.set(id, address);
3171
+ else issues.push({
3172
+ code: "E_PIPELINE_ID",
3173
+ message: `${address} has the ID of ${first}, and HubSpot keeps pipeline IDs unique across objects`,
3174
+ ...at(address, ".id"),
3175
+ fix: "give one of the two another ID"
3176
+ });
3177
+ if (id.length > 36) issues.push(tooLong(address, 36, at(address, ".id")));
3178
+ const label = String(d.label).toLowerCase();
3179
+ const same = pipelineLabels.get(`${object}/${label}`);
3180
+ if (same === void 0) pipelineLabels.set(`${object}/${label}`, address);
3181
+ else issues.push({
3182
+ code: "E_DUPLICATE_LABEL",
3183
+ message: `${same} and ${address} share the label '${d.label}', ignoring case`,
3184
+ ...at(address, ".label"),
3185
+ fix: "give one of the two another label"
3186
+ });
3187
+ const order = d.displayOrder;
3188
+ if (!(Number.isInteger(order) && order >= 0)) issues.push({
3189
+ code: "E_PIPELINE_FIELD",
3190
+ message: `displayOrder ${order} of ${address} is not an integer from 0 up`,
3191
+ ...at(address, ".displayOrder"),
3192
+ fix: "use 0 or more: HubSpot lists pipelines lowest first"
3193
+ });
3194
+ checkStages(ir, address, object, stageIds, at, issues);
3195
+ }
3196
+ }
3197
+ function checkStages(ir, pipeline, object, stageIds, at, issues) {
3198
+ const ids = (ir.resources[pipeline]?.definition?.stages ?? []).filter((id) => Object.hasOwn(ir.resources, `stage:${pipeline.slice(9)}/${id}`));
3199
+ if (ids.length === 0) issues.push({
3200
+ code: "E_PIPELINE_STAGES",
3201
+ message: `${pipeline} has no stage, and HubSpot needs one`,
3202
+ ...at(pipeline, ".stages"),
3203
+ fix: "add a stage, or run kalup rm on the pipeline"
3204
+ });
3205
+ const field = stageField(object, !STANDARD_OBJECTS.has(object));
3206
+ const labels = /* @__PURE__ */ new Map();
3207
+ let closed = false;
3208
+ for (const id of ids) {
3209
+ const address = `stage:${pipeline.slice(9)}/${id}`;
3210
+ const d = ir.resources[address]?.definition ?? {};
3211
+ const first = stageIds.get(`${object}/${id}`);
3212
+ if (first === void 0) stageIds.set(`${object}/${id}`, address);
3213
+ else issues.push({
3214
+ code: "E_PIPELINE_ID",
3215
+ message: `${address} has the ID of ${first}, and HubSpot keeps stage IDs unique across an object's pipelines`,
3216
+ ...at(address, ".id"),
3217
+ fix: "give one of the two another ID, such as the pipeline ID followed by the stage"
3218
+ });
3219
+ if (id.length > 100) issues.push(tooLong(address, 100, at(address, ".id")));
3220
+ const label = String(d.label).trim().toLowerCase();
3221
+ const same = labels.get(label);
3222
+ if (same === void 0) labels.set(label, address);
3223
+ else issues.push({
3224
+ code: "E_DUPLICATE_LABEL",
3225
+ message: `stages ${same} and ${address} share the label '${label}', ignoring case and spaces around it`,
3226
+ ...at(address, ".label"),
3227
+ fix: "give one of the two another label"
3228
+ });
3229
+ closed ||= d.ticketState === "CLOSED";
3230
+ for (const rule of stageRules(object, field, d)) issues.push({
3231
+ code: "E_PIPELINE_FIELD",
3232
+ message: rule.message,
3233
+ ...at(address, rule.field),
3234
+ fix: rule.fix
3235
+ });
3236
+ }
3237
+ if (field === "ticketState" && ids.length > 0 && !closed) issues.push({
3238
+ code: "E_PIPELINE_STAGES",
3239
+ message: `${pipeline} has no stage with ticketState 'CLOSED', and HubSpot needs one`,
3240
+ ...at(pipeline, ".stages"),
3241
+ fix: "mark the stage tickets end in ticketState: 'CLOSED'"
3242
+ });
3243
+ }
3244
+ const FIELD_OBJECTS = {
3245
+ probability: "deal stages",
3246
+ ticketState: "ticket stages",
3247
+ state: "custom object stages"
3248
+ };
3249
+ /**
3250
+ * What is wrong with one stage's metadata, as config or an override states it: a field its object does not take, a deal
3251
+ * stage without a probability (unless `override`, which states only what differs), a probability outside 0..1. Each
3252
+ * entry's `field` is the suffix it points at.
3253
+ */
3254
+ function stageRules(object, field, d, override = false) {
3255
+ const out = [];
3256
+ for (const name of STAGE_FIELDS) {
3257
+ if (d[name] === void 0 || name === field) continue;
3258
+ const takes = field === void 0 ? `a stage of ${object} takes none: Kalup reads and compares its pipelines and does not write them` : `a stage of ${object} takes ${field}`;
3259
+ out.push({
3260
+ field: `.${name}`,
3261
+ message: `${name} is for ${FIELD_OBJECTS[name]}; ${takes}`,
3262
+ fix: field === void 0 ? `remove ${name}` : `replace ${name} with ${field}`
3263
+ });
3264
+ }
3265
+ const p = d.probability;
3266
+ if (field === "probability" && p === void 0 && !override) out.push({
3267
+ field: "",
3268
+ message: "a deal stage needs a probability, and HubSpot refuses one without",
3269
+ fix: "add probability, from 0 to 1: 0 and 1 make the stage closed"
3270
+ });
3271
+ else if (field === "probability" && p !== void 0 && !(typeof p === "number" && p >= 0 && p <= 1)) out.push({
3272
+ field: ".probability",
3273
+ message: `probability ${p} is not from 0 to 1`,
3274
+ fix: "use a number from 0 to 1"
3275
+ });
3276
+ return out;
3277
+ }
3278
+ function tooLong(address, max, at) {
3279
+ return {
3280
+ code: "E_PIPELINE_ID",
3281
+ message: `the ID of ${address} is longer than ${max} characters, which HubSpot answers with an error and does not store`,
3282
+ ...at,
3283
+ fix: `use an ID of at most ${max} characters`
3284
+ };
3285
+ }
3286
+ /**
2723
3287
  * The settings under objects and targets.<t>.objects: a name include and exclude both list, a target object objects
2724
3288
  * does not declare, and a target mode that overrides an object's mode the target says nothing more about.
2725
3289
  */
@@ -2753,6 +3317,12 @@ function checkScopes(loaded, { issues, warnings }) {
2753
3317
  }
2754
3318
  function checkExcludes(config, at, issues) {
2755
3319
  for (const [object, scope] of Object.entries(config.objects)) {
3320
+ if (scope.pipelines === true && !hasPipelines(object, !STANDARD_OBJECTS.has(object))) issues.push({
3321
+ code: "E_SETTING_VALUE",
3322
+ message: `objects.${object}.pipelines is true, and ${object} has no pipelines`,
3323
+ ...at(`objects.${object}.pipelines`),
3324
+ fix: `remove pipelines from objects.${object}`
3325
+ });
2756
3326
  const both = (scope.include ?? []).filter((name) => (scope.exclude ?? []).includes(name));
2757
3327
  if (both.length > 0) issues.push({
2758
3328
  code: "E_SETTING_VALUE",
@@ -2884,6 +3454,7 @@ function checkTargets(loaded, requested, { issues, warnings }) {
2884
3454
  issues,
2885
3455
  warnings
2886
3456
  });
3457
+ checkTargetPipelines(loaded, name, overrides, at, issues);
2887
3458
  }
2888
3459
  const declared = Object.keys(config.targets);
2889
3460
  if (config.defaultTarget !== void 0 && !Object.hasOwn(config.targets, config.defaultTarget)) issues.push({
@@ -2900,7 +3471,7 @@ function checkTargets(loaded, requested, { issues, warnings }) {
2900
3471
  });
2901
3472
  }
2902
3473
  /**
2903
- * Every key of removed.ts is a property or group address on one object, and none is also a resource in config:
3474
+ * Every key of removed.ts is a property, group, pipeline or stage address, and none is also a resource in config:
2904
3475
  * removing a resource takes it out of config.
2905
3476
  */
2906
3477
  function checkTombstones(loaded, issues) {
@@ -2911,7 +3482,7 @@ function checkTombstones(loaded, issues) {
2911
3482
  ...removedLines[key] === void 0 ? {} : { line: removedLines[key] },
2912
3483
  configPath: key
2913
3484
  };
2914
- const fix = "write the address of a property or group, such as 'property:companies/legacy_score'";
3485
+ const fix = "write the address of a property, group, pipeline or stage, such as 'property:companies/legacy_score'";
2915
3486
  if (!isAddress(key)) {
2916
3487
  issues.push({
2917
3488
  code: "E_TOMBSTONE_ADDRESS",
@@ -2922,14 +3493,15 @@ function checkTombstones(loaded, issues) {
2922
3493
  continue;
2923
3494
  }
2924
3495
  const { type, path } = parseAddress(key);
2925
- if (!REMOVABLE$1.includes(type)) issues.push({
3496
+ const shape = Object.hasOwn(REMOVABLE, type) ? REMOVABLE[type] : void 0;
3497
+ if (!shape) issues.push({
2926
3498
  code: "E_TOMBSTONE_ADDRESS",
2927
- message: `cannot remove ${key}: this version removes properties and groups only`,
3499
+ message: `cannot remove ${key}: this version removes properties, groups, pipelines and stages only`,
2928
3500
  ...at,
2929
3501
  fix: `remove ${key} from ${removed}`
2930
3502
  });
2931
- else if (!ON_OBJECT$1.test(path)) {
2932
- const message = `'${key}' is not of the form ${type}:<object>/<name>`;
3503
+ else if (!shape.path.test(path)) {
3504
+ const message = `'${key}' is not of the form ${shape.form}`;
2933
3505
  issues.push({
2934
3506
  code: "E_TOMBSTONE_ADDRESS",
2935
3507
  message,
@@ -3005,8 +3577,15 @@ function checkDefinitions(ir, target, overrides, at, { issues, warnings }) {
3005
3577
  report("", refused, `remove the definition override for ${address} under targets.${target}.overrides`);
3006
3578
  continue;
3007
3579
  }
3008
- const type = parseAddress(address).type;
3580
+ const { type, path } = parseAddress(address);
3009
3581
  checkFields(type, d, report);
3582
+ const order = d.displayOrder;
3583
+ if (type === "pipeline" && order !== void 0 && !(Number.isInteger(order) && order >= 0)) report(".displayOrder", `displayOrder ${order} is not an integer from 0 up`, "use 0 or more");
3584
+ if (type === "stage") {
3585
+ const object = path.slice(0, path.indexOf("/"));
3586
+ const field = stageField(object, !STANDARD_OBJECTS.has(object));
3587
+ for (const rule of stageRules(object, field, d, true)) report(rule.field, rule.message, rule.fix);
3588
+ }
3010
3589
  if (type === "property") {
3011
3590
  checkEffective(ir, address, resource, d, report);
3012
3591
  checkEffectiveOptions(address, resource, d, report);
@@ -3014,6 +3593,41 @@ function checkDefinitions(ir, target, overrides, at, { issues, warnings }) {
3014
3593
  }
3015
3594
  }
3016
3595
  }
3596
+ /**
3597
+ * The pipeline rules on the resources one target's definition overrides leave: a label two pipelines or two stages
3598
+ * would share, a ticket pipeline left with no closed stage. Only what the shared files do not
3599
+ * break already is reported, as E_OVERRIDE_DEFINITION at the first override the message names, else at an override of
3600
+ * a stage of the pipeline it names.
3601
+ */
3602
+ function checkTargetPipelines(loaded, target, overrides, at, issues) {
3603
+ const touched = Object.keys(overrides).filter((address) => (address.startsWith("pipeline:") || address.startsWith("stage:")) && Object.hasOwn(loaded.ir.resources, address) && overrides[address]?.definition !== void 0);
3604
+ if (touched.length === 0) return;
3605
+ const shared = [];
3606
+ checkPipelines(loaded, shared);
3607
+ const known = new Set(shared.map((issue) => issue.message));
3608
+ const found = [];
3609
+ checkPipelines({
3610
+ ...loaded,
3611
+ ir: {
3612
+ ...loaded.ir,
3613
+ resources: effectiveResources(loaded.ir, target)
3614
+ }
3615
+ }, found);
3616
+ const spanning = found.filter((i) => SPANNING.has(i.code) && !known.has(i.message));
3617
+ for (const issue of spanning) {
3618
+ const words = issue.message.split(WORD_BREAK);
3619
+ const pipelineOf = (a) => a.startsWith("stage:") ? `pipeline:${a.slice(6, a.lastIndexOf("/"))}` : a;
3620
+ const address = words.find((word) => touched.includes(word)) ?? touched.find((a) => words.includes(pipelineOf(a))) ?? touched[0];
3621
+ issues.push({
3622
+ code: "E_OVERRIDE_DEFINITION",
3623
+ message: `on target ${target}, ${issue.message}`,
3624
+ ...located$1(at, `${address}.definition`)(),
3625
+ ...issue.fix === void 0 ? {} : { fix: issue.fix }
3626
+ });
3627
+ }
3628
+ }
3629
+ const SPANNING = /* @__PURE__ */ new Set(["E_DUPLICATE_LABEL", "E_PIPELINE_STAGES"]);
3630
+ const WORD_BREAK = /[\s,']+/;
3017
3631
  function located$1(at, path) {
3018
3632
  return (suffix = "") => {
3019
3633
  const full = `${path}${suffix}`;
@@ -3038,14 +3652,16 @@ function checkFields(type, d, report) {
3038
3652
  function refusal$1(address, resource) {
3039
3653
  const { type } = parseAddress(address);
3040
3654
  if (type === "object") return "a custom object schema cannot take a definition override in this release";
3041
- if (type !== "property" && type !== "group") return "only a property or a group can take a definition override";
3655
+ if (!Object.hasOwn(OVERRIDABLE, type)) return "only a property, a group, a pipeline or a stage can take a definition override";
3042
3656
  if (resource.managed) return;
3043
3657
  const d = resource.definition ?? {};
3044
3658
  return d.label !== void 0 && d.group !== void 0 && d.fieldType !== void 0 ? "it is .managed(false), so no target owns its definition" : "it is a reference (its shared definition has no label, group and fieldType), so nothing on it can differ per target";
3045
3659
  }
3046
3660
  function notOverridable(type, field) {
3047
3661
  if (field === "hasUniqueValue" || field === "dataSensitivity") return `${field} is fixed when HubSpot creates the property, so it cannot differ per target`;
3048
- return type === "group" ? `a group override may set label only, not ${field}` : `${field} cannot differ per target`;
3662
+ if (type === "group") return `a group override may set label only, not ${field}`;
3663
+ if (type === "pipeline") return `a pipeline override may set label and displayOrder only, not ${field}`;
3664
+ return type === "stage" ? `a stage override may set label and its metadata only, not ${field}` : `${field} cannot differ per target`;
3049
3665
  }
3050
3666
  function checkEffective(ir, address, resource, d, report) {
3051
3667
  const codec = resource.binding?.codec;
@@ -3221,7 +3837,7 @@ function walkUnresolved(value, visit) {
3221
3837
  }
3222
3838
  const SCHEMA = blueprint_1_schema_default;
3223
3839
  /** `<group|property>:<object>/<name>`. */
3224
- const ON_OBJECT$2 = /^(group|property):([^\s/]+)\/([^\s/]+)$/;
3840
+ const ON_OBJECT = /^(group|property):([^\s/]+)\/([^\s/]+)$/;
3225
3841
  /** Object keys, group names and property names: lowercase, digits and underscores, a letter first. */
3226
3842
  const NAME = /^[a-z][a-z0-9_]*$/;
3227
3843
  /** The codec each HubSpot type implies when a blueprint's binding names none. */
@@ -3262,7 +3878,7 @@ function rules(blueprint) {
3262
3878
  const report = (configPath, message) => issues.push(issue(configPath, message));
3263
3879
  for (const [address, resource] of Object.entries(blueprint.resources)) {
3264
3880
  const at = `resources.${address}`;
3265
- const parts = ON_OBJECT$2.exec(address);
3881
+ const parts = ON_OBJECT.exec(address);
3266
3882
  if (!parts) {
3267
3883
  report(at, `'${address}' is not of the form group:<object>/<name> or property:<object>/<name>`);
3268
3884
  continue;
@@ -3287,7 +3903,7 @@ function checkName(report, at, type, name) {
3287
3903
  function checkProperty(report, at, object, resource) {
3288
3904
  const d = resource.definition;
3289
3905
  const group = d.group.$ref;
3290
- const parts = ON_OBJECT$2.exec(group);
3906
+ const parts = ON_OBJECT.exec(group);
3291
3907
  if (!(parts && parts[1] === "group" && parts[2] === object)) report(`${at}.definition.group`, `the group '${group}' is not a group of ${object}, group:${object}/<name>`);
3292
3908
  else if (!NAME.test(parts[3])) report(`${at}.definition.group`, `group name '${parts[3]}' is not lowercase letters, digits and underscores starting with a letter`);
3293
3909
  const options = d.options ?? [];
@@ -3460,6 +4076,79 @@ const registry = {
3460
4076
  }
3461
4077
  }
3462
4078
  },
4079
+ pipeline: {
4080
+ family: "crm.pipelines",
4081
+ version: "2026-09",
4082
+ status: "ga",
4083
+ expires: "2028-03",
4084
+ identity: "natural",
4085
+ auth: "account",
4086
+ delete: "permanent",
4087
+ scopes: {
4088
+ read: ["crm.schemas.{object}.read"],
4089
+ write: ["crm.schemas.{object}.write"]
4090
+ },
4091
+ tier: "any",
4092
+ limitKey: "pipelines",
4093
+ paths: {
4094
+ list: {
4095
+ method: "GET",
4096
+ path: "/crm/pipelines/2026-09/{objectType}",
4097
+ tag: "read"
4098
+ },
4099
+ read: {
4100
+ method: "GET",
4101
+ path: "/crm/pipelines/2026-09/{objectType}/{pipelineId}",
4102
+ tag: "read"
4103
+ },
4104
+ create: {
4105
+ method: "POST",
4106
+ path: "/crm/pipelines/2026-09/{objectType}",
4107
+ tag: "write"
4108
+ },
4109
+ update: {
4110
+ method: "PATCH",
4111
+ path: "/crm/pipelines/2026-09/{objectType}/{pipelineId}",
4112
+ tag: "write"
4113
+ },
4114
+ delete: {
4115
+ method: "DELETE",
4116
+ path: "/crm/pipelines/2026-09/{objectType}/{pipelineId}",
4117
+ tag: "write"
4118
+ }
4119
+ }
4120
+ },
4121
+ stage: {
4122
+ family: "crm.pipelines",
4123
+ version: "2026-09",
4124
+ status: "ga",
4125
+ expires: "2028-03",
4126
+ identity: "natural",
4127
+ auth: "account",
4128
+ delete: "permanent",
4129
+ scopes: {
4130
+ read: ["crm.schemas.{object}.read"],
4131
+ write: ["crm.schemas.{object}.write"]
4132
+ },
4133
+ tier: "any",
4134
+ paths: {
4135
+ create: {
4136
+ method: "POST",
4137
+ path: "/crm/pipelines/2026-09/{objectType}/{pipelineId}/stages",
4138
+ tag: "write"
4139
+ },
4140
+ update: {
4141
+ method: "PATCH",
4142
+ path: "/crm/pipelines/2026-09/{objectType}/{pipelineId}/stages/{stageId}",
4143
+ tag: "write"
4144
+ },
4145
+ delete: {
4146
+ method: "DELETE",
4147
+ path: "/crm/pipelines/2026-09/{objectType}/{pipelineId}/stages/{stageId}",
4148
+ tag: "write"
4149
+ }
4150
+ }
4151
+ },
3463
4152
  accountInfo: {
3464
4153
  family: "account-info",
3465
4154
  version: "2026-09",
@@ -3496,6 +4185,11 @@ const registry = {
3496
4185
  method: "GET",
3497
4186
  path: "/crm/limits/2026-09/custom-object-types",
3498
4187
  tag: "read"
4188
+ },
4189
+ pipelines: {
4190
+ method: "GET",
4191
+ path: "/crm/limits/2026-09/pipelines",
4192
+ tag: "read"
3499
4193
  }
3500
4194
  }
3501
4195
  }
@@ -3507,7 +4201,9 @@ const registry = {
3507
4201
  const NORM_VERSIONS = {
3508
4202
  property: 1,
3509
4203
  group: 1,
3510
- object: 1
4204
+ object: 1,
4205
+ pipeline: 1,
4206
+ stage: 1
3511
4207
  };
3512
4208
  /**
3513
4209
  * Standard objects whose properties and groups read under a scope other than `crm.schemas.<object>.read`, checked
@@ -3607,7 +4303,10 @@ const fallback = {
3607
4303
  intervalMs: 1e3
3608
4304
  };
3609
4305
  const fallbackWarning = "HubSpot sent no rate-limit headers. Sending at most 8 requests per second.";
3610
- /** The writes milestone 3 sends: properties and property groups. No custom object schema write. */
4306
+ /**
4307
+ * The writes apply sends: properties, property groups, pipelines and stages. No custom object schema write, and no
4308
+ * pipeline or stage PUT, which the registry does not name.
4309
+ */
3611
4310
  const MILESTONE_3_WRITES = [
3612
4311
  {
3613
4312
  type: "property",
@@ -3632,6 +4331,30 @@ const MILESTONE_3_WRITES = [
3632
4331
  {
3633
4332
  type: "group",
3634
4333
  path: "delete"
4334
+ },
4335
+ {
4336
+ type: "pipeline",
4337
+ path: "create"
4338
+ },
4339
+ {
4340
+ type: "pipeline",
4341
+ path: "update"
4342
+ },
4343
+ {
4344
+ type: "pipeline",
4345
+ path: "delete"
4346
+ },
4347
+ {
4348
+ type: "stage",
4349
+ path: "create"
4350
+ },
4351
+ {
4352
+ type: "stage",
4353
+ path: "update"
4354
+ },
4355
+ {
4356
+ type: "stage",
4357
+ path: "delete"
3635
4358
  }
3636
4359
  ];
3637
4360
  /** How many `errors` entries an outcome keeps: a property used in hundreds of places must not balloon it. */
@@ -3833,7 +4556,7 @@ async function exchange(fetch, url, init, timeoutMs, key) {
3833
4556
  kind: "answered",
3834
4557
  status: res.status,
3835
4558
  headers: res.headers,
3836
- text: scrub$1(await res.text(), key)
4559
+ text: scrub$2(await res.text(), key)
3837
4560
  };
3838
4561
  } catch (error) {
3839
4562
  return {
@@ -3848,16 +4571,16 @@ async function exchange(fetch, url, init, timeoutMs, key) {
3848
4571
  clearTimeout(timer);
3849
4572
  }
3850
4573
  }
3851
- function scrub$1(text, key) {
4574
+ function scrub$2(text, key) {
3852
4575
  return key === "" ? text : text.split(key).join("[key]");
3853
4576
  }
3854
4577
  function quote(text, key, max = 120) {
3855
- return sanitize(scrub$1(scrub$1(sanitize(scrub$1(text, key), Number.POSITIVE_INFINITY), key), sanitize(key, Number.POSITIVE_INFINITY)), max);
4578
+ return sanitize(scrub$2(scrub$2(sanitize(scrub$2(text, key), Number.POSITIVE_INFINITY), key), sanitize(key, Number.POSITIVE_INFINITY)), max);
3856
4579
  }
3857
4580
  function scrubJson(value, key) {
3858
- if (typeof value === "string") return scrub$1(value, key);
4581
+ if (typeof value === "string") return scrub$2(value, key);
3859
4582
  if (Array.isArray(value)) return value.map((item) => scrubJson(item, key));
3860
- if (typeof value === "object" && value !== null) return Object.fromEntries(Object.entries(value).map(([name, item]) => [scrub$1(name, key), scrubJson(item, key)]));
4583
+ if (typeof value === "object" && value !== null) return Object.fromEntries(Object.entries(value).map(([name, item]) => [scrub$2(name, key), scrubJson(item, key)]));
3861
4584
  return value;
3862
4585
  }
3863
4586
  function readStep(answer, attempt, context) {
@@ -4066,119 +4789,241 @@ function portalMidnight(now, timeZone) {
4066
4789
  return new Date(Date.UTC(part.year ?? 0, (part.month ?? 1) - 1, (part.day ?? 1) + 1) - offset).toISOString();
4067
4790
  }
4068
4791
  /**
4069
- * The objects whose properties the properties API lists by name. A config key outside this set names a custom object
4070
- * schema. The set is the 2026-09 `crm.schemas.<object>.read` scope list plus the objects that carry their own scopes
4071
- * (products, leads, goals, marketing events, feedback submissions, users).
4072
- */
4073
- const STANDARD_OBJECTS = /* @__PURE__ */ new Set([
4074
- "appointments",
4075
- "calls",
4076
- "carts",
4077
- "commerce_payments",
4078
- "communications",
4079
- "companies",
4080
- "contacts",
4081
- "courses",
4082
- "deals",
4083
- "emails",
4084
- "feedback_submissions",
4085
- "goals",
4086
- "invoices",
4087
- "leads",
4088
- "line_items",
4089
- "listings",
4090
- "marketing_events",
4091
- "meetings",
4092
- "notes",
4093
- "orders",
4094
- "postal_mail",
4095
- "products",
4096
- "projects",
4097
- "quotes",
4098
- "services",
4099
- "subscriptions",
4100
- "tasks",
4101
- "tickets",
4102
- "users"
4103
- ]);
4104
- /**
4105
- * The object type ID of each standard object, by config key. Limits Tracking keys its per-object entries by these.
4106
- * Source: "Object type ID values", retrieved 2026-09-24:
4107
- * https://developers.hubspot.com/docs/api-reference/latest/crm/understanding-the-crm
4108
- * A plain record: look a key up with `Object.hasOwn`, so `constructor` finds nothing.
4792
+ * A portal resource a name override shadows belongs to no address. Where another resource refers to it (a property's
4793
+ * group, a name in a custom object schema), the read records `shadowed:<name>`, which no HubSpot internal name and so
4794
+ * no config name equals.
4109
4795
  */
4110
- const STANDARD_OBJECT_TYPE_IDS = {
4111
- appointments: "0-421",
4112
- calls: "0-48",
4113
- carts: "0-142",
4114
- commerce_payments: "0-101",
4115
- communications: "0-18",
4116
- companies: "0-2",
4117
- contacts: "0-1",
4118
- courses: "0-410",
4119
- deals: "0-3",
4120
- emails: "0-49",
4121
- feedback_submissions: "0-19",
4122
- goals: "0-74",
4123
- invoices: "0-53",
4124
- leads: "0-136",
4125
- line_items: "0-8",
4126
- listings: "0-420",
4127
- marketing_events: "0-54",
4128
- meetings: "0-47",
4129
- notes: "0-46",
4130
- orders: "0-123",
4131
- postal_mail: "0-116",
4132
- products: "0-7",
4133
- projects: "0-970",
4134
- quotes: "0-14",
4135
- services: "0-162",
4136
- subscriptions: "0-69",
4137
- tasks: "0-27",
4138
- tickets: "0-5",
4139
- users: "0-115"
4796
+ const SHADOWED = "shadowed:";
4797
+ const KINDS$1 = {
4798
+ string: "string",
4799
+ number: "number",
4800
+ bool: "boolean",
4801
+ date: "date",
4802
+ datetime: "datetime",
4803
+ phone_number: "phoneNumber"
4140
4804
  };
4141
- /** One object's pull scope from its settings under `objects` and the property names its object file defines. */
4142
- function scopeOf(scope = {}, defined = []) {
4143
- return {
4144
- custom: scope.custom ?? true,
4145
- defined: new Set(defined),
4146
- exclude: excluder(scope.exclude),
4147
- include: new Set(scope.include ?? [])
4148
- };
4805
+ function kindOf$4(type, fieldType) {
4806
+ if (type === "enumeration") return fieldType === "checkbox" ? "multiEnum" : "enum";
4807
+ return Object.hasOwn(KINDS$1, type) ? KINDS$1[type] : void 0;
4149
4808
  }
4150
- /** The names of the properties the object files define on `object`, references included. */
4151
- function definedOn(ir, object) {
4152
- const prefix = `property:${object}/`;
4153
- return Object.keys(ir.resources).filter((address) => address.startsWith(prefix)).map((address) => address.slice(prefix.length));
4809
+ function builderOf(p, external) {
4810
+ if (isOwner(p)) return "owner";
4811
+ return external ? "string" : kindOf$4(p.type, p.fieldType);
4154
4812
  }
4155
- /**
4156
- * A portal property is in scope when the object files define it or `include` names it, whatever `custom` and
4157
- * `exclude` say, or when it is custom, `custom` is on and `exclude` does not name it. validate refuses a name both
4158
- * `include` and `exclude` hold.
4159
- */
4160
- function inScope(scope, property) {
4161
- const { name } = property;
4162
- return scope.defined.has(name) || scope.include.has(name) || scope.custom && !property.hubspotDefined && !scope.exclude(name);
4813
+ function writable(p, kind, external) {
4814
+ return (kind === "owner" || !external) && FIELD_TYPES[kind].includes(p.fieldType);
4163
4815
  }
4164
- /** The `exclude` patterns of one object as a predicate over internal names. */
4165
- function excluder(patterns = []) {
4166
- const matchers = patterns.map(addressMatcher);
4167
- return (name) => matchers.some((matches) => matches(name));
4816
+ /** A HubSpot user property p.owner writes: an enumeration HubSpot fills with owners, as a select or radio. */
4817
+ function isOwner(p) {
4818
+ return p.referencedObjectType === "OWNER" && p.externalOptions === true && p.type === "enumeration" && FIELD_TYPES.owner.includes(p.fieldType);
4168
4819
  }
4169
- /** The --only glob as a predicate over addresses. `*` matches any run of characters, `/` included. */
4170
- function addressMatcher(glob) {
4171
- if (glob === void 0) return () => true;
4172
- const literals = glob.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
4173
- const pattern = new RegExp(`^${literals.join(".*")}$`);
4174
- return (address) => pattern.test(address);
4820
+ /** Why Kalup does not write a property: its options come from HubSpot, or its type and fieldType fit no builder. */
4821
+ function unsupportedReason(p) {
4822
+ if (p.referencedObjectType === "OWNER") return `is a HubSpot user property with fieldType ${sanitize(p.fieldType)}`;
4823
+ if (p.externalOptions) return "takes its options from HubSpot (externalOptions)";
4824
+ return `has type ${sanitize(p.type)} and fieldType ${sanitize(p.fieldType)}`;
4825
+ }
4826
+ /**
4827
+ * Live properties in portal order. Archived properties are skipped. An owner property (a select or radio HubSpot fills
4828
+ * with users) is `p.owner`. HubSpot fills the options of any other externalOptions property, so it reads as a string: a
4829
+ * HubSpot-defined or HubSpot-calculated one is a `p.string` reference, a custom one is unsupported. So is a property
4830
+ * whose type no builder carries (object_coordinates, json, anything unknown), or a custom one whose fieldType no builder
4831
+ * accepts (a calculation_rollup), each with one warning when `warn` says the project cares about it: a property outside
4832
+ * the pull scope and the files is noise. A custom calculation_equation property is managed: HubSpot marks it
4833
+ * `calculated`, but its formula is Kalup's to write.
4834
+ */
4835
+ function normalizeProperties(object, raw, issues, warn = () => true) {
4836
+ const properties = [];
4837
+ const unsupported = [];
4838
+ for (const p of raw) {
4839
+ if (p.archived) continue;
4840
+ const hubspotDefined = Boolean(p.hubspotDefined);
4841
+ const reference = Boolean(p.hubspotDefined || p.calculated && p.fieldType !== "calculation_equation" || RESERVED_PREFIX.test(p.name));
4842
+ const external = p.externalOptions === true || p.referencedObjectType === "OWNER";
4843
+ const kind = builderOf(p, external);
4844
+ if (kind === void 0 || !(reference || writable(p, kind, external))) {
4845
+ const u = unsupportedOf(p);
4846
+ warnUnsupported(issues, object, u, warn(p));
4847
+ unsupported.push(u);
4848
+ continue;
4849
+ }
4850
+ const options = kind === "enum" || kind === "multiEnum" ? normalizeOptions(p.options ?? []) : void 0;
4851
+ properties.push({
4852
+ name: p.name,
4853
+ ...external ? { external: true } : {},
4854
+ hubspotDefined,
4855
+ type: p.type,
4856
+ fieldType: p.fieldType,
4857
+ kind,
4858
+ reference,
4859
+ calculated: Boolean(p.calculated),
4860
+ definition: definitionOf$1(p, reference, options)
4861
+ });
4862
+ }
4863
+ return {
4864
+ properties,
4865
+ unsupported
4866
+ };
4867
+ }
4868
+ function warnUnsupported(issues, object, u, wanted) {
4869
+ if (wanted) issues.push({
4870
+ code: "W_UNSUPPORTED_TYPE",
4871
+ message: `property:${object}/${sanitize(u.name)} ${unsupportedReason(u)}, which Kalup does not write; read as a p.string reference`
4872
+ });
4873
+ }
4874
+ function unsupportedOf(p) {
4875
+ return compact$4({
4876
+ name: p.name,
4877
+ label: p.label,
4878
+ group: p.groupName,
4879
+ description: p.description || void 0,
4880
+ type: p.type,
4881
+ fieldType: p.fieldType,
4882
+ options: p.options?.length ? normalizeOptions(p.options) : void 0,
4883
+ hubspotDefined: Boolean(p.hubspotDefined),
4884
+ externalOptions: p.externalOptions === true || void 0,
4885
+ referencedObjectType: p.referencedObjectType
4886
+ });
4887
+ }
4888
+ /** The portal names of the unarchived properties in each portal group, sorted. */
4889
+ function groupMembers(raw) {
4890
+ const members = /* @__PURE__ */ new Map();
4891
+ for (const p of raw.filter((property) => !property.archived)) members.set(p.groupName, [...members.get(p.groupName) ?? [], p.name]);
4892
+ return new Map([...members].map(([group, names]) => [group, names.sort(byCodeUnit)]));
4893
+ }
4894
+ /** The meta of each unarchived property, by name. */
4895
+ function propertyMeta(raw) {
4896
+ return new Map(raw.filter((p) => !p.archived).map((p) => [p.name, metaOf(p)]));
4897
+ }
4898
+ function metaOf(p) {
4899
+ const text = (value) => typeof value === "string" ? value : void 0;
4900
+ const flag = (value) => typeof value === "boolean" ? value : void 0;
4901
+ const flags = p.modificationMetadata;
4902
+ const modificationMetadata = flags === void 0 ? void 0 : compact$4({
4903
+ archivable: flag(flags.archivable),
4904
+ readOnlyDefinition: flag(flags.readOnlyDefinition),
4905
+ readOnlyOptions: flag(flags.readOnlyOptions),
4906
+ readOnlyValue: flag(flags.readOnlyValue)
4907
+ });
4908
+ const options = p.options?.map((o) => compact$4({
4909
+ value: o.value,
4910
+ displayOrder: typeof o.displayOrder === "number" ? o.displayOrder : void 0
4911
+ }));
4912
+ return compact$4({
4913
+ sensitivity: p.sensitivity,
4914
+ hubspotDefined: flag(p.hubspotDefined),
4915
+ modificationMetadata,
4916
+ createdAt: text(p.createdAt),
4917
+ updatedAt: text(p.updatedAt),
4918
+ options: options?.length ? options : void 0,
4919
+ currencyPropertyName: text(p.currencyPropertyName)
4920
+ });
4921
+ }
4922
+ function definitionOf$1(p, reference, options) {
4923
+ if (!reference) return compact$4({
4924
+ label: p.label,
4925
+ group: p.groupName,
4926
+ fieldType: p.fieldType,
4927
+ description: p.description || void 0,
4928
+ options: options?.length ? options : void 0,
4929
+ hasUniqueValue: p.hasUniqueValue || void 0,
4930
+ formField: p.formField || void 0,
4931
+ hidden: p.hidden || void 0,
4932
+ displayOrder: typeof p.displayOrder === "number" && p.displayOrder !== -1 ? p.displayOrder : void 0,
4933
+ ...displayOf(p),
4934
+ calculationFormula: p.fieldType === "calculation_equation" ? filled(p.calculationFormula) : void 0,
4935
+ dataSensitivity: unlessDefault("dataSensitivity", p.dataSensitivity)
4936
+ });
4937
+ return options?.length ? { options } : void 0;
4938
+ }
4939
+ function displayOf(p) {
4940
+ const shown = (field) => TYPE_FIELDS[field]?.includes(p.type) === true;
4941
+ return {
4942
+ numberDisplayHint: shown("numberDisplayHint") ? unlessDefault("numberDisplayHint", p.numberDisplayHint) : void 0,
4943
+ showCurrencySymbol: shown("showCurrencySymbol") ? p.showCurrencySymbol || void 0 : void 0,
4944
+ currencyPropertyName: shown("currencyPropertyName") ? filled(p.currencyPropertyName) : void 0,
4945
+ textDisplayHint: shown("textDisplayHint") ? filled(p.textDisplayHint) : void 0
4946
+ };
4947
+ }
4948
+ function filled(value) {
4949
+ return typeof value === "string" && value !== "" ? value : void 0;
4950
+ }
4951
+ function unlessDefault(field, value) {
4952
+ return typeof value === "string" && value !== "" && value !== DEFAULTS.definition[field] ? value : void 0;
4953
+ }
4954
+ function normalizeOptions(raw) {
4955
+ const order = (o) => o.displayOrder === void 0 || o.displayOrder < 0 ? Number.MAX_SAFE_INTEGER : o.displayOrder;
4956
+ return [...raw].sort((a, b) => order(a) - order(b)).map((o) => compact$4({
4957
+ value: o.value,
4958
+ label: o.label,
4959
+ hidden: o.hidden || void 0,
4960
+ description: o.description || void 0
4961
+ }));
4962
+ }
4963
+ const STATES = /* @__PURE__ */ new Set(["OPEN", "CLOSED"]);
4964
+ /**
4965
+ * The unarchived pipelines of `object`, by displayOrder then ID, since pipelines may share a number and the list is not
4966
+ * sorted; each one's stages by displayOrder, which HubSpot never stores tied (observed 2026-10-05). A stage keeps the
4967
+ * one metadata field its object takes: a deal's probability as a number, a ticket's ticketState, a custom object's
4968
+ * state. `isClosed` is HubSpot's own, derived from that field, and is dropped; so is the metadata of a stage on an
4969
+ * object whose pipelines Kalup does not write.
4970
+ */
4971
+ function normalizePipelines(object, raw, custom) {
4972
+ const field = stageField(object, custom);
4973
+ const ordered = (list) => [...list].sort((a, b) => a.displayOrder - b.displayOrder || byCodeUnit(a.id, b.id));
4974
+ return ordered(raw.filter((p) => !p.archived)).map((p) => ({
4975
+ id: p.id,
4976
+ label: p.label,
4977
+ displayOrder: p.displayOrder,
4978
+ stages: ordered(p.stages.filter((st) => !st.archived)).map((st) => ({
4979
+ id: st.id,
4980
+ label: st.label,
4981
+ ...stageMetadata$1(field, st.metadata ?? {})
4982
+ }))
4983
+ }));
4984
+ }
4985
+ function stageMetadata$1(field, metadata) {
4986
+ const value = field === void 0 ? void 0 : metadata[field];
4987
+ if (field === "probability") {
4988
+ const probability = Number(value);
4989
+ return value !== void 0 && Number.isFinite(probability) ? { probability } : {};
4990
+ }
4991
+ return field !== void 0 && value !== void 0 && STATES.has(value) ? { [field]: value } : {};
4992
+ }
4993
+ function normalizeGroups(raw) {
4994
+ return { groups: new Map(raw.filter((g) => !g.archived).map((g) => [g.name, g.label])) };
4995
+ }
4996
+ /** The schema fields the object file carries, the three lists as HubSpot returned them. */
4997
+ function normalizeSchema(schema) {
4998
+ return compact$4({
4999
+ labels: compact$4({
5000
+ singular: schema.labels?.singular,
5001
+ plural: schema.labels?.plural
5002
+ }),
5003
+ primaryDisplayProperty: schema.primaryDisplayProperty,
5004
+ requiredProperties: schema.requiredProperties,
5005
+ searchableProperties: schema.searchableProperties,
5006
+ secondaryDisplayProperties: schema.secondaryDisplayProperties
5007
+ });
5008
+ }
5009
+ function compact$4(value) {
5010
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== void 0));
4175
5011
  }
4176
5012
  const SETS$1 = /* @__PURE__ */ new Set(["requiredProperties", "searchableProperties"]);
5013
+ /**
5014
+ * Fields that are an order of members: a pipeline's stage IDs. Like `options.order`, one compares only the members
5015
+ * both sides hold, since a stage is a resource of its own and one only one side holds is that stage's step.
5016
+ */
5017
+ const ORDERS = /* @__PURE__ */ new Set(["stages"]);
4177
5018
  /** Every unit desired owns that observed holds, sorted by unit. */
4178
5019
  function classify(base, desired, observed, rules) {
4179
5020
  const ignored = new Set(rules.ignoreChanges);
4180
5021
  const units = [];
4181
- for (const [field, value] of Object.entries(desired.fields)) if (!ignored.has(field) && Object.hasOwn(observed.fields, field)) units.push(compare$1(field, value, observed.fields[field], valueIn(base, field), SETS$1.has(field)));
5022
+ for (const [field, value] of Object.entries(desired.fields)) {
5023
+ if (ignored.has(field) || !Object.hasOwn(observed.fields, field)) continue;
5024
+ if (ORDERS.has(field)) units.push(orderUnit(field, value, observed.fields[field] ?? [], base));
5025
+ else units.push(compare$1(field, value, observed.fields[field], valueIn(base, field), SETS$1.has(field)));
5026
+ }
4182
5027
  if (desired.options && observed.options && !ignored.has("options")) units.push(...options(desired.options, observed.options, base, rules));
4183
5028
  return units.sort((a, b) => byCodeUnit(a.unit, b.unit));
4184
5029
  }
@@ -4195,15 +5040,24 @@ function advanceBase(previous, approved, live, units) {
4195
5040
  const named = (unit) => units === void 0 || units.some((u) => unit === u || unit.startsWith(`${u}.`) || unit.startsWith(`${u}[`));
4196
5041
  const next = new Map(Object.entries(previous ?? {}));
4197
5042
  let agreed = false;
4198
- for (const [field, value] of Object.entries(approved.fields)) if (named(field) && Object.hasOwn(live.fields, field) && same$3(value, live.fields[field], SETS$1.has(field))) {
4199
- next.set(field, value);
4200
- agreed = true;
5043
+ for (const [field, value] of Object.entries(approved.fields)) {
5044
+ if (!(named(field) && Object.hasOwn(live.fields, field))) continue;
5045
+ const both = agreedField(field, value, live.fields[field]);
5046
+ if (both !== void 0) {
5047
+ next.set(field, both);
5048
+ agreed = true;
5049
+ }
4201
5050
  }
4202
5051
  if (approved.options && live.options) agreed = advanceOptions(next, approved.options, live.options, named) || agreed;
4203
5052
  if (!agreed && previous === void 0) return;
4204
5053
  for (const [field, omitted] of Object.entries(DEFAULTS.definition)) if (field !== "options" && !Object.hasOwn(approved.fields, field) && !next.has(field) && Object.hasOwn(live.fields, field) && same$3(live.fields[field], omitted)) next.set(field, omitted);
4205
5054
  return sortedRecord(next);
4206
5055
  }
5056
+ function agreedField(field, approved, live) {
5057
+ if (!ORDERS.has(field)) return same$3(approved, live, SETS$1.has(field)) ? approved : void 0;
5058
+ const [mine, theirs] = commonOrders(approved, live ?? []);
5059
+ return mine.length > 0 && same$3(mine, theirs) ? mine : void 0;
5060
+ }
4207
5061
  function advanceOptions(next, approved, live, named) {
4208
5062
  const wanted = new Map(approved.map((o) => [o.value, o]));
4209
5063
  const held = new Map(live.map((o) => [o.value, o]));
@@ -4277,6 +5131,16 @@ function options(desired, observed, base, rules) {
4277
5131
  units.push(compare$1("options.order", order, common(observed, wanted), orderIn(base, order)));
4278
5132
  return units;
4279
5133
  }
5134
+ function orderUnit(field, desired, observed, base) {
5135
+ const [mine, theirs] = commonOrders(desired, observed);
5136
+ const stored = valueIn(base, field);
5137
+ const order = (stored?.value ?? []).filter((member) => mine.includes(member));
5138
+ return compare$1(field, mine, theirs, stored && order.length === mine.length ? { value: order } : void 0);
5139
+ }
5140
+ /** The members two orders share, each in its own order. */
5141
+ function commonOrders(a, b) {
5142
+ return [a.filter((member) => b.includes(member)), b.filter((member) => a.includes(member))];
5143
+ }
4280
5144
  function orderIn(base, members) {
4281
5145
  const stored = valueIn(base, "optionsOrder");
4282
5146
  if (!stored) return;
@@ -4337,6 +5201,10 @@ function equal(a, b) {
4337
5201
  function sortedRecord(entries) {
4338
5202
  return Object.fromEntries([...entries].sort(([a], [b]) => byCodeUnit(a, b)));
4339
5203
  }
5204
+ /** A count and its noun: `1 write`, `2 writes`. The plural is the noun plus `s` unless `many` gives another. */
5205
+ function plural(count, one, many = `${one}s`) {
5206
+ return `${count} ${count === 1 ? one : many}`;
5207
+ }
4340
5208
  const PLAN_SCHEMA = {
4341
5209
  $schema: "https://json-schema.org/draft/2020-12/schema",
4342
5210
  $id: "https://kalup.dev/schemas/plan-1.schema.json",
@@ -4739,6 +5607,24 @@ const PLAN_SCHEMA = {
4739
5607
  "description": "Logical form, $ref included, resolved at apply. A create holds the full definition.",
4740
5608
  "type": "object"
4741
5609
  },
5610
+ "stages": {
5611
+ "description": "A pipeline create only: the stages it carries, in display order, each with its full definition. HubSpot refuses a pipeline without a stage.",
5612
+ "type": "array",
5613
+ "items": {
5614
+ "type": "object",
5615
+ "required": ["address", "desired"],
5616
+ "properties": {
5617
+ "address": { "$ref": "#/$defs/address" },
5618
+ "desired": { "type": "object" }
5619
+ },
5620
+ "additionalProperties": false
5621
+ }
5622
+ },
5623
+ "stageLabels": {
5624
+ "description": "Display only, never approved: the label of each stage a pipeline step's stage order names, config's else the portal's, so the plan text shows labels and not IDs.",
5625
+ "type": "object",
5626
+ "additionalProperties": { "type": "string" }
5627
+ },
4742
5628
  "ignoreChanges": {
4743
5629
  "description": "Create only: set on create, released after.",
4744
5630
  "type": "array",
@@ -5044,197 +5930,6 @@ function validatePlan(document) {
5044
5930
  ...path ? { configPath: path } : {}
5045
5931
  }));
5046
5932
  }
5047
- /**
5048
- * A portal resource a name override shadows belongs to no address. Where another resource refers to it (a property's
5049
- * group, a name in a custom object schema), the read records `shadowed:<name>`, which no HubSpot internal name and so
5050
- * no config name equals.
5051
- */
5052
- const SHADOWED = "shadowed:";
5053
- const KINDS$1 = {
5054
- string: "string",
5055
- number: "number",
5056
- bool: "boolean",
5057
- date: "date",
5058
- datetime: "datetime",
5059
- phone_number: "phoneNumber"
5060
- };
5061
- function kindOf$4(type, fieldType) {
5062
- if (type === "enumeration") return fieldType === "checkbox" ? "multiEnum" : "enum";
5063
- return Object.hasOwn(KINDS$1, type) ? KINDS$1[type] : void 0;
5064
- }
5065
- function builderOf(p, external) {
5066
- if (isOwner(p)) return "owner";
5067
- return external ? "string" : kindOf$4(p.type, p.fieldType);
5068
- }
5069
- function writable(p, kind, external) {
5070
- return (kind === "owner" || !external) && FIELD_TYPES[kind].includes(p.fieldType);
5071
- }
5072
- /** A HubSpot user property p.owner writes: an enumeration HubSpot fills with owners, as a select or radio. */
5073
- function isOwner(p) {
5074
- return p.referencedObjectType === "OWNER" && p.externalOptions === true && p.type === "enumeration" && FIELD_TYPES.owner.includes(p.fieldType);
5075
- }
5076
- /** Why Kalup does not write a property: its options come from HubSpot, or its type and fieldType fit no builder. */
5077
- function unsupportedReason(p) {
5078
- if (p.referencedObjectType === "OWNER") return `is a HubSpot user property with fieldType ${sanitize(p.fieldType)}`;
5079
- if (p.externalOptions) return "takes its options from HubSpot (externalOptions)";
5080
- return `has type ${sanitize(p.type)} and fieldType ${sanitize(p.fieldType)}`;
5081
- }
5082
- /**
5083
- * Live properties in portal order. Archived properties are skipped. An owner property (a select or radio HubSpot fills
5084
- * with users) is `p.owner`. HubSpot fills the options of any other externalOptions property, so it reads as a string: a
5085
- * HubSpot-defined or HubSpot-calculated one is a `p.string` reference, a custom one is unsupported. So is a property
5086
- * whose type no builder carries (object_coordinates, json, anything unknown), or a custom one whose fieldType no builder
5087
- * accepts (a calculation_rollup), each with one warning when `warn` says the project cares about it: a property outside
5088
- * the pull scope and the files is noise. A custom calculation_equation property is managed: HubSpot marks it
5089
- * `calculated`, but its formula is Kalup's to write.
5090
- */
5091
- function normalizeProperties(object, raw, issues, warn = () => true) {
5092
- const properties = [];
5093
- const unsupported = [];
5094
- for (const p of raw) {
5095
- if (p.archived) continue;
5096
- const hubspotDefined = Boolean(p.hubspotDefined);
5097
- const reference = Boolean(p.hubspotDefined || p.calculated && p.fieldType !== "calculation_equation" || RESERVED_PREFIX.test(p.name));
5098
- const external = p.externalOptions === true || p.referencedObjectType === "OWNER";
5099
- const kind = builderOf(p, external);
5100
- if (kind === void 0 || !(reference || writable(p, kind, external))) {
5101
- const u = unsupportedOf(p);
5102
- warnUnsupported(issues, object, u, warn(p));
5103
- unsupported.push(u);
5104
- continue;
5105
- }
5106
- const options = kind === "enum" || kind === "multiEnum" ? normalizeOptions(p.options ?? []) : void 0;
5107
- properties.push({
5108
- name: p.name,
5109
- ...external ? { external: true } : {},
5110
- hubspotDefined,
5111
- type: p.type,
5112
- fieldType: p.fieldType,
5113
- kind,
5114
- reference,
5115
- calculated: Boolean(p.calculated),
5116
- definition: definitionOf$1(p, reference, options)
5117
- });
5118
- }
5119
- return {
5120
- properties,
5121
- unsupported
5122
- };
5123
- }
5124
- function warnUnsupported(issues, object, u, wanted) {
5125
- if (wanted) issues.push({
5126
- code: "W_UNSUPPORTED_TYPE",
5127
- message: `property:${object}/${sanitize(u.name)} ${unsupportedReason(u)}, which Kalup does not write; read as a p.string reference`
5128
- });
5129
- }
5130
- function unsupportedOf(p) {
5131
- return compact$4({
5132
- name: p.name,
5133
- label: p.label,
5134
- group: p.groupName,
5135
- description: p.description || void 0,
5136
- type: p.type,
5137
- fieldType: p.fieldType,
5138
- options: p.options?.length ? normalizeOptions(p.options) : void 0,
5139
- hubspotDefined: Boolean(p.hubspotDefined),
5140
- externalOptions: p.externalOptions === true || void 0,
5141
- referencedObjectType: p.referencedObjectType
5142
- });
5143
- }
5144
- /** The portal names of the unarchived properties in each portal group, sorted. */
5145
- function groupMembers(raw) {
5146
- const members = /* @__PURE__ */ new Map();
5147
- for (const p of raw.filter((property) => !property.archived)) members.set(p.groupName, [...members.get(p.groupName) ?? [], p.name]);
5148
- return new Map([...members].map(([group, names]) => [group, names.sort(byCodeUnit)]));
5149
- }
5150
- /** The meta of each unarchived property, by name. */
5151
- function propertyMeta(raw) {
5152
- return new Map(raw.filter((p) => !p.archived).map((p) => [p.name, metaOf(p)]));
5153
- }
5154
- function metaOf(p) {
5155
- const text = (value) => typeof value === "string" ? value : void 0;
5156
- const flag = (value) => typeof value === "boolean" ? value : void 0;
5157
- const flags = p.modificationMetadata;
5158
- const modificationMetadata = flags === void 0 ? void 0 : compact$4({
5159
- archivable: flag(flags.archivable),
5160
- readOnlyDefinition: flag(flags.readOnlyDefinition),
5161
- readOnlyOptions: flag(flags.readOnlyOptions),
5162
- readOnlyValue: flag(flags.readOnlyValue)
5163
- });
5164
- const options = p.options?.map((o) => compact$4({
5165
- value: o.value,
5166
- displayOrder: typeof o.displayOrder === "number" ? o.displayOrder : void 0
5167
- }));
5168
- return compact$4({
5169
- sensitivity: p.sensitivity,
5170
- hubspotDefined: flag(p.hubspotDefined),
5171
- modificationMetadata,
5172
- createdAt: text(p.createdAt),
5173
- updatedAt: text(p.updatedAt),
5174
- options: options?.length ? options : void 0,
5175
- currencyPropertyName: text(p.currencyPropertyName)
5176
- });
5177
- }
5178
- function definitionOf$1(p, reference, options) {
5179
- if (!reference) return compact$4({
5180
- label: p.label,
5181
- group: p.groupName,
5182
- fieldType: p.fieldType,
5183
- description: p.description || void 0,
5184
- options: options?.length ? options : void 0,
5185
- hasUniqueValue: p.hasUniqueValue || void 0,
5186
- formField: p.formField || void 0,
5187
- hidden: p.hidden || void 0,
5188
- displayOrder: typeof p.displayOrder === "number" && p.displayOrder !== -1 ? p.displayOrder : void 0,
5189
- ...displayOf(p),
5190
- calculationFormula: p.fieldType === "calculation_equation" ? filled(p.calculationFormula) : void 0,
5191
- dataSensitivity: unlessDefault("dataSensitivity", p.dataSensitivity)
5192
- });
5193
- return options?.length ? { options } : void 0;
5194
- }
5195
- function displayOf(p) {
5196
- const shown = (field) => TYPE_FIELDS[field]?.includes(p.type) === true;
5197
- return {
5198
- numberDisplayHint: shown("numberDisplayHint") ? unlessDefault("numberDisplayHint", p.numberDisplayHint) : void 0,
5199
- showCurrencySymbol: shown("showCurrencySymbol") ? p.showCurrencySymbol || void 0 : void 0,
5200
- currencyPropertyName: shown("currencyPropertyName") ? filled(p.currencyPropertyName) : void 0,
5201
- textDisplayHint: shown("textDisplayHint") ? filled(p.textDisplayHint) : void 0
5202
- };
5203
- }
5204
- function filled(value) {
5205
- return typeof value === "string" && value !== "" ? value : void 0;
5206
- }
5207
- function unlessDefault(field, value) {
5208
- return typeof value === "string" && value !== "" && value !== DEFAULTS.definition[field] ? value : void 0;
5209
- }
5210
- function normalizeOptions(raw) {
5211
- const order = (o) => o.displayOrder === void 0 || o.displayOrder < 0 ? Number.MAX_SAFE_INTEGER : o.displayOrder;
5212
- return [...raw].sort((a, b) => order(a) - order(b)).map((o) => compact$4({
5213
- value: o.value,
5214
- label: o.label,
5215
- hidden: o.hidden || void 0,
5216
- description: o.description || void 0
5217
- }));
5218
- }
5219
- function normalizeGroups(raw) {
5220
- return { groups: new Map(raw.filter((g) => !g.archived).map((g) => [g.name, g.label])) };
5221
- }
5222
- /** The schema fields the object file carries, the three lists as HubSpot returned them. */
5223
- function normalizeSchema(schema) {
5224
- return compact$4({
5225
- labels: compact$4({
5226
- singular: schema.labels?.singular,
5227
- plural: schema.labels?.plural
5228
- }),
5229
- primaryDisplayProperty: schema.primaryDisplayProperty,
5230
- requiredProperties: schema.requiredProperties,
5231
- searchableProperties: schema.searchableProperties,
5232
- secondaryDisplayProperties: schema.secondaryDisplayProperties
5233
- });
5234
- }
5235
- function compact$4(value) {
5236
- return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== void 0));
5237
- }
5238
5933
  const CONFIG$1$1 = "kalup.config.ts";
5239
5934
  /** HubSpot lists only non-sensitive properties unless asked, and takes one sensitivity per request. */
5240
5935
  const SENSITIVITIES$1 = [
@@ -5301,9 +5996,23 @@ async function readPortal(http, loaded, target, issues, options = {}) {
5301
5996
  name: p.name,
5302
5997
  hubspotDefined: Boolean(p.hubspotDefined)
5303
5998
  });
5999
+ const pipelines = await objectPipelines(http, {
6000
+ key,
6001
+ schema,
6002
+ loaded,
6003
+ options
6004
+ }, {
6005
+ renames,
6006
+ excluded,
6007
+ shadowed
6008
+ }, {
6009
+ issues,
6010
+ gaps
6011
+ });
5304
6012
  objects.push({
5305
6013
  object: key,
5306
6014
  objectTypeId: schema?.objectTypeId,
6015
+ ...pipelines ? { pipelines } : {},
5307
6016
  ...normalizeGroups(localize(lists.groups, groupNames).filter(kept("group"))),
5308
6017
  ...normalizeProperties(key, properties, issues, wanted),
5309
6018
  meta: propertyMeta(properties),
@@ -5323,6 +6032,46 @@ async function readPortal(http, loaded, target, issues, options = {}) {
5323
6032
  unknownIncludes
5324
6033
  };
5325
6034
  }
6035
+ async function objectPipelines(http, { key, schema, loaded, options }, { renames, excluded, shadowed }, { issues, gaps }) {
6036
+ const custom = schema !== void 0;
6037
+ if (!(options.pipelines === true && hasPipelines(key, custom) || pipelinesInScope(loaded.config.objects[key], loaded.ir, key))) return;
6038
+ return await readPipelines(http, key, schema ? schema.objectTypeId : key, issues, gaps, (listed) => localPipelines$1(key, normalizePipelines(key, listed, custom), renames, excluded, shadowed));
6039
+ }
6040
+ async function readPipelines(http, object, objectType, issues, gaps, local) {
6041
+ const listed = await gap(() => http.request({
6042
+ type: "pipeline",
6043
+ path: "list",
6044
+ params: { objectType }
6045
+ }), issues, gaps, {
6046
+ list: "pipelines",
6047
+ object,
6048
+ scope: readScope(registry.pipeline, objectType)
6049
+ });
6050
+ return listed && local(listed.results);
6051
+ }
6052
+ function localPipelines$1(object, live, renames, excluded, shadowed) {
6053
+ const where = ` on ${object}`;
6054
+ const pipelines = localNames(renames, `pipeline:${object}/`, live.map((p) => p.id), where);
6055
+ shadowed.push(...pipelines.shadowed);
6056
+ const out = [];
6057
+ for (const p of live) {
6058
+ const id = pipelines.local(p.id);
6059
+ if (pipelines.shadows(p.id) || excluded.has(`pipeline:${object}/${id}`)) continue;
6060
+ const prefix = `stage:${object}/${id}/`;
6061
+ const stages = localNames(renames, prefix, p.stages.map((st) => st.id), where);
6062
+ shadowed.push(...stages.shadowed);
6063
+ const kept = p.stages.filter((st) => !(stages.shadows(st.id) || excluded.has(`${prefix}${stages.local(st.id)}`)));
6064
+ out.push({
6065
+ ...p,
6066
+ id,
6067
+ stages: kept.map((st) => ({
6068
+ ...st,
6069
+ id: stages.local(st.id)
6070
+ }))
6071
+ });
6072
+ }
6073
+ return out;
6074
+ }
5326
6075
  /**
5327
6076
  * One object's archived properties, sorted by name, from the three archived lists merged by name (the first list
5328
6077
  * wins). Portal names: no override applies. Any error propagates.
@@ -5415,6 +6164,10 @@ async function gap(read, issues, gaps, missed) {
5415
6164
  }
5416
6165
  function excludedAddresses(ir, overrides) {
5417
6166
  const out = new Set(Object.keys(overrides).filter((address) => overrides[address]?.skip === true));
6167
+ for (const address of Object.keys(ir.resources)) {
6168
+ const pipeline = address.startsWith("stage:") ? `pipeline:${address.slice(6, address.lastIndexOf("/"))}` : void 0;
6169
+ if (pipeline !== void 0 && out.has(pipeline)) out.add(address);
6170
+ }
5418
6171
  for (const [address, resource] of Object.entries(ir.resources)) {
5419
6172
  const override = Object.hasOwn(overrides, address) ? overrides[address] : void 0;
5420
6173
  const moved = resource.managed ? override?.definition?.group : void 0;
@@ -5526,7 +6279,7 @@ function sha256$1(text) {
5526
6279
  return `sha256:${createHash("sha256").update(text).digest("hex")}`;
5527
6280
  }
5528
6281
  function approvalStep(step) {
5529
- const { address, action, transport, api, labels, baseUnits, desired, ignoreChanges, changes, expect } = step;
6282
+ const { address, action, transport, api, labels, baseUnits, desired, ignoreChanges, changes, expect, stages } = step;
5530
6283
  return {
5531
6284
  address,
5532
6285
  action,
@@ -5535,6 +6288,7 @@ function approvalStep(step) {
5535
6288
  labels,
5536
6289
  baseUnits,
5537
6290
  desired,
6291
+ ...stages ? { stages } : {},
5538
6292
  ignoreChanges,
5539
6293
  changes: changes?.map(({ unit, op, after }) => ({
5540
6294
  unit,
@@ -5544,28 +6298,70 @@ function approvalStep(step) {
5544
6298
  expect
5545
6299
  };
5546
6300
  }
5547
- const pinWarningDays = 90;
5548
- /** One warning per API family whose pin expires within 90 days. `expires` is a month; its first day counts. */
5549
- function pinWarnings(rows, now = Date.now()) {
5550
- const out = [];
5551
- const seen = /* @__PURE__ */ new Set();
5552
- for (const row of rows) {
5553
- if (row.expires === void 0) continue;
5554
- const pin = `${row.family} ${row.version}`;
5555
- const expires = /* @__PURE__ */ new Date(`${row.expires}-01T00:00:00Z`);
5556
- if (seen.has(pin) || expires.getTime() - now > pinWarningDays * 864e5) continue;
5557
- seen.add(pin);
5558
- out.push({
5559
- code: "W_PIN_EXPIRES",
5560
- message: `the ${row.family} API pin ${row.version} expires ${row.expires}`,
5561
- fix: `upgrade ${bin} to a release that pins a newer version`
5562
- });
5563
- }
5564
- return out;
6301
+ /**
6302
+ * The exact create body for a managed property (POST /crm/properties/{version}/{objectType}) or group. Enum aliases
6303
+ * live in the binding, so they never reach HubSpot. An unmanaged resource is never created, so it throws. Object
6304
+ * schemas wait for milestone 4.
6305
+ */
6306
+ function toCreatePayload(address, resource) {
6307
+ const { type, path } = parseAddress(address);
6308
+ const name = lastSegment(path);
6309
+ if (!resource.managed) throw new Error(`${address} is not managed and is never created`);
6310
+ const { definition } = resource;
6311
+ if (!definition) throw new Error(`${address} has no definition to create from`);
6312
+ if (type === "group") return {
6313
+ name,
6314
+ label: definition.label
6315
+ };
6316
+ if (type === "object") throw new Error(`${address}: the create payload for an object waits for milestone 4`);
6317
+ if (type !== "property") throw new Error(`${address}: no create payload for type "${type}"`);
6318
+ const group = definition.group;
6319
+ if (!group?.$ref) throw new Error(`${address}: definition.group is not a $ref`);
6320
+ const payload = {
6321
+ name,
6322
+ label: definition.label,
6323
+ type: definition.type,
6324
+ fieldType: definition.fieldType,
6325
+ groupName: lastSegment(parseAddress(group.$ref).path)
6326
+ };
6327
+ if ("description" in definition) payload.description = definition.description;
6328
+ if (Array.isArray(definition.options)) payload.options = definition.options.map((option, index) => ({
6329
+ ...option,
6330
+ displayOrder: index
6331
+ }));
6332
+ else if (definition.type === "bool" && definition.fieldType === "booleancheckbox") payload.options = BOOLEAN_OPTIONS.map((option) => ({ ...option }));
6333
+ for (const field of CREATE_FIELDS) if (field in definition) payload[field] = definition[field];
6334
+ return payload;
5565
6335
  }
5566
- /** A count and its noun: `1 write`, `2 writes`. The plural is the noun plus `s` unless `many` gives another. */
5567
- function plural(count, one, many = `${one}s`) {
5568
- return `${count} ${count === 1 ? one : many}`;
6336
+ /** The fields a create sends as the definition states them, after name, label, type, fieldType, group and options. */
6337
+ const CREATE_FIELDS = [
6338
+ "hasUniqueValue",
6339
+ "formField",
6340
+ "hidden",
6341
+ "displayOrder",
6342
+ "numberDisplayHint",
6343
+ "showCurrencySymbol",
6344
+ "currencyPropertyName",
6345
+ "textDisplayHint",
6346
+ "calculationFormula",
6347
+ "dataSensitivity",
6348
+ "externalOptions",
6349
+ "referencedObjectType"
6350
+ ];
6351
+ /** The two options HubSpot requires on a `bool` property, labelled as HubSpot's UI labels them. */
6352
+ const BOOLEAN_OPTIONS = [{
6353
+ label: "Yes",
6354
+ value: "true",
6355
+ displayOrder: 0,
6356
+ hidden: false
6357
+ }, {
6358
+ label: "No",
6359
+ value: "false",
6360
+ displayOrder: 1,
6361
+ hidden: false
6362
+ }];
6363
+ function lastSegment(path) {
6364
+ return path.slice(path.lastIndexOf("/") + 1);
5569
6365
  }
5570
6366
  /** The classes a person settles: config and HubSpot moved apart, or never agreed. */
5571
6367
  const REVERTING = /* @__PURE__ */ new Set([
@@ -5584,12 +6380,24 @@ const FIELD_END = /[.[]/;
5584
6380
  /**
5585
6381
  * What an update may write, by the unit's field, from HubSpot's documented update schema as live runs confirmed it
5586
6382
  * (docs/hubspot.md): a property its label, description, group (sent as groupName), formField, fieldType, options,
5587
- * hidden, displayOrder, number and text display fields and calculation formula; a group its label. A custom object
5588
- * schema is compared and never written in this release.
6383
+ * hidden, displayOrder, number and text display fields and calculation formula; a group its label; a pipeline its
6384
+ * label, displayOrder and the order of its stages; a stage its label and its metadata field. A custom object schema is
6385
+ * compared and never written in this release.
5589
6386
  */
5590
6387
  const WRITABLE = {
5591
6388
  object: /* @__PURE__ */ new Set(),
5592
6389
  group: /* @__PURE__ */ new Set(["label"]),
6390
+ pipeline: /* @__PURE__ */ new Set([
6391
+ "label",
6392
+ "displayOrder",
6393
+ "stages"
6394
+ ]),
6395
+ stage: /* @__PURE__ */ new Set([
6396
+ "label",
6397
+ "probability",
6398
+ "ticketState",
6399
+ "state"
6400
+ ]),
5593
6401
  property: /* @__PURE__ */ new Set([
5594
6402
  "label",
5595
6403
  "description",
@@ -5606,8 +6414,24 @@ const WRITABLE = {
5606
6414
  "calculationFormula"
5607
6415
  ])
5608
6416
  };
5609
- /** Units whose change rewrites values HubSpot holds on records, which plan does not check: a step that sets one is risky. */
5610
- const REVALUES = /* @__PURE__ */ new Set(["fieldType", "calculationFormula"]);
6417
+ /**
6418
+ * Units whose change rewrites values HubSpot holds on records, which plan does not check, or changes how existing
6419
+ * records count (a stage's probability or closed state moves forecasts and open and closed reports): a step that sets
6420
+ * one is risky.
6421
+ */
6422
+ const REVALUES = /* @__PURE__ */ new Set([
6423
+ "fieldType",
6424
+ "calculationFormula",
6425
+ "probability",
6426
+ "ticketState",
6427
+ "state"
6428
+ ]);
6429
+ /** An ID made of digits alone: one HubSpot assigned, as to a pipeline or stage made in the HubSpot UI. */
6430
+ const ASSIGNED = /^\d+$/;
6431
+ /** Whether Kalup writes the pipelines of an object: deals, tickets and custom objects. */
6432
+ function writesPipelines(object) {
6433
+ return stageField(object, !STANDARD_OBJECTS.has(object)) !== void 0;
6434
+ }
5611
6435
  /**
5612
6436
  * What a classified unit becomes. `converged` agrees; `config-change`, `add` and `remove` are written; `keep` is kept
5613
6437
  * with a note. `drift` and `conflict` are held unless `--take config` selected the unit (`taken`) or the target
@@ -5638,7 +6462,7 @@ function deriveChange(unit, policy, taken) {
5638
6462
  function stepRisk(step, context) {
5639
6463
  if (step.risk === "blocked" || step.action === "unknown") return "blocked";
5640
6464
  switch (step.action) {
5641
- case "create": return context.owner ? "risky" : "safe";
6465
+ case "create": return context.owner || assignedId(step) ? "risky" : "safe";
5642
6466
  case "delete": return "destructive";
5643
6467
  case "manual": return "manual";
5644
6468
  case "release": return "safe";
@@ -5663,6 +6487,15 @@ function stepLabels(step, context) {
5663
6487
  if ((step.changes ?? []).some((c) => c.op === "remove" && context.takeoverUnits?.has(c.unit))) labels.push("takeover");
5664
6488
  return labels;
5665
6489
  }
6490
+ /**
6491
+ * Whether a pipeline or stage create sends an ID HubSpot assigned in another portal, all digits, for itself or a stage
6492
+ * it carries: on a portal that holds the same pipeline under other IDs it makes a copy, so it is risky.
6493
+ */
6494
+ function assignedId(step) {
6495
+ const type = step.address.slice(0, step.address.indexOf(":"));
6496
+ const ids = [step.address, ...(step.stages ?? []).map((st) => st.address)].map((a) => a.slice(a.lastIndexOf("/") + 1));
6497
+ return step.action === "create" && (type === "pipeline" || type === "stage") && ids.some((id) => ASSIGNED.test(id));
6498
+ }
5666
6499
  /** The field a unit belongs to: `options` for an option member, a member's field and `options.order`. */
5667
6500
  function fieldOf(unit) {
5668
6501
  const at = unit.search(FIELD_END);
@@ -5731,77 +6564,79 @@ function deleteBlock(meta, members) {
5731
6564
  fix: "move them to another group or delete them first; HubSpot archives a group only once every property in it is archived"
5732
6565
  };
5733
6566
  }
5734
- function classOf(change, context) {
5735
- return context.classes?.[change.unit] ?? change.class;
6567
+ /**
6568
+ * Whether a stage's values leave it closed: a ticket or custom object stage set or created CLOSED. Apply runs such a
6569
+ * stage step before the pipeline's other stage steps, so a ticket pipeline always keeps a closed stage.
6570
+ */
6571
+ function closesStage(values) {
6572
+ return values?.ticketState === "CLOSED" || values?.state === "CLOSED";
5736
6573
  }
5737
- function revertsByTake(change, context) {
5738
- const cls = classOf(change, context);
5739
- return REVERTING.has(cls) && !(context.drift === "overwrite" && cls !== "diverged");
6574
+ /**
6575
+ * `resources` as the steps `before` a stage delete leave them: each stage those steps create added to its pipeline's
6576
+ * order, and each stage field they write set. A delete runs only once every step before it is done, so the stage delete
6577
+ * rule counts what they leave.
6578
+ */
6579
+ function afterSteps(resources, before) {
6580
+ const out = { ...resources };
6581
+ const at = (address) => Object.hasOwn(out, address) ? out[address] : void 0;
6582
+ for (const step of before) {
6583
+ if (!((step.action === "create" || step.action === "update" || step.action === "adopt") && step.address.startsWith("stage:"))) continue;
6584
+ const pipeline = `pipeline:${step.address.slice(6, step.address.lastIndexOf("/"))}`;
6585
+ const id = step.address.slice(step.address.lastIndexOf("/") + 1);
6586
+ const held = at(pipeline);
6587
+ const stages = held?.definition?.stages ?? [];
6588
+ if (held && !stages.includes(id)) out[pipeline] = {
6589
+ ...held,
6590
+ definition: {
6591
+ ...held.definition,
6592
+ stages: [...stages, id]
6593
+ }
6594
+ };
6595
+ const written = step.action === "create" ? step.desired ?? {} : Object.fromEntries((step.changes ?? []).map((c) => [c.unit, c.after]));
6596
+ const current = at(step.address);
6597
+ out[step.address] = {
6598
+ type: "stage",
6599
+ managed: true,
6600
+ ...current,
6601
+ definition: {
6602
+ ...current?.definition,
6603
+ ...written
6604
+ }
6605
+ };
6606
+ }
6607
+ return out;
5740
6608
  }
5741
6609
  /**
5742
- * The exact create body for a managed property (POST /crm/properties/{version}/{objectType}) or group. Enum aliases
5743
- * live in the binding, so they never reach HubSpot. An unmanaged resource is never created, so it throws. Object
5744
- * schemas wait for milestone 4.
6610
+ * Why a stage cannot be deleted, or undefined when it can: HubSpot refuses to leave a pipeline with no stage, or a
6611
+ * ticket pipeline with no closed stage (observed 2026-10-05). `resources` holds the pipeline and its stages as a read
6612
+ * found them and the steps before the delete leave them (afterSteps); `gone` the IDs of the stages of that pipeline
6613
+ * deleted before this one. `cli` names the command in the fix.
5745
6614
  */
5746
- function toCreatePayload(address, resource) {
5747
- const { type, path } = parseAddress(address);
5748
- const name = lastSegment(path);
5749
- if (!resource.managed) throw new Error(`${address} is not managed and is never created`);
5750
- const { definition } = resource;
5751
- if (!definition) throw new Error(`${address} has no definition to create from`);
5752
- if (type === "group") return {
5753
- name,
5754
- label: definition.label
6615
+ function stageDeleteRule(resources, stage, gone, cli) {
6616
+ const pipeline = `pipeline:${stage.slice(6, stage.lastIndexOf("/"))}`;
6617
+ const id = stage.slice(stage.lastIndexOf("/") + 1);
6618
+ const at = (address) => Object.hasOwn(resources, address) ? resources[address] : void 0;
6619
+ const ids = (at(pipeline)?.definition?.stages ?? []).filter((other) => other !== id && !gone.has(other));
6620
+ const fix = `run ${cli} rm on ${pipeline} to delete the whole pipeline, or keep the stage: set its tombstone's action to release`;
6621
+ if (ids.length === 0) return {
6622
+ short: "last stage",
6623
+ detail: "it is the last stage of its pipeline, and HubSpot keeps one",
6624
+ fix
5755
6625
  };
5756
- if (type === "object") throw new Error(`${address}: the create payload for an object waits for milestone 4`);
5757
- if (type !== "property") throw new Error(`${address}: no create payload for type "${type}"`);
5758
- const group = definition.group;
5759
- if (!group?.$ref) throw new Error(`${address}: definition.group is not a $ref`);
5760
- const payload = {
5761
- name,
5762
- label: definition.label,
5763
- type: definition.type,
5764
- fieldType: definition.fieldType,
5765
- groupName: lastSegment(parseAddress(group.$ref).path)
6626
+ const prefix = `${pipeline.replace("pipeline:", "stage:")}/`;
6627
+ const closed = (other) => at(`${prefix}${other}`)?.definition?.ticketState === "CLOSED";
6628
+ if (at(stage)?.definition?.ticketState === "CLOSED" && !ids.some(closed)) return {
6629
+ short: "last closed stage",
6630
+ detail: "it is the last closed stage of its ticket pipeline, and HubSpot keeps one",
6631
+ fix: "mark another stage ticketState: 'CLOSED' first"
5766
6632
  };
5767
- if ("description" in definition) payload.description = definition.description;
5768
- if (Array.isArray(definition.options)) payload.options = definition.options.map((option, index) => ({
5769
- ...option,
5770
- displayOrder: index
5771
- }));
5772
- else if (definition.type === "bool" && definition.fieldType === "booleancheckbox") payload.options = BOOLEAN_OPTIONS.map((option) => ({ ...option }));
5773
- for (const field of CREATE_FIELDS) if (field in definition) payload[field] = definition[field];
5774
- return payload;
5775
6633
  }
5776
- /** The fields a create sends as the definition states them, after name, label, type, fieldType, group and options. */
5777
- const CREATE_FIELDS = [
5778
- "hasUniqueValue",
5779
- "formField",
5780
- "hidden",
5781
- "displayOrder",
5782
- "numberDisplayHint",
5783
- "showCurrencySymbol",
5784
- "currencyPropertyName",
5785
- "textDisplayHint",
5786
- "calculationFormula",
5787
- "dataSensitivity",
5788
- "externalOptions",
5789
- "referencedObjectType"
5790
- ];
5791
- /** The two options HubSpot requires on a `bool` property, labelled as HubSpot's UI labels them. */
5792
- const BOOLEAN_OPTIONS = [{
5793
- label: "Yes",
5794
- value: "true",
5795
- displayOrder: 0,
5796
- hidden: false
5797
- }, {
5798
- label: "No",
5799
- value: "false",
5800
- displayOrder: 1,
5801
- hidden: false
5802
- }];
5803
- function lastSegment(path) {
5804
- return path.slice(path.lastIndexOf("/") + 1);
6634
+ function classOf(change, context) {
6635
+ return context.classes?.[change.unit] ?? change.class;
6636
+ }
6637
+ function revertsByTake(change, context) {
6638
+ const cls = classOf(change, context);
6639
+ return REVERTING.has(cls) && !(context.drift === "overwrite" && cls !== "diverged");
5805
6640
  }
5806
6641
  const MEMBER$1 = /^options\[(.*)\](?:\.(label|hidden|description))?$/s;
5807
6642
  /**
@@ -5889,6 +6724,67 @@ function optionsPatch(changes, live, desired = []) {
5889
6724
  displayOrder
5890
6725
  }));
5891
6726
  }
6727
+ /** A stage's metadata as HubSpot takes it: the field config states, as a string. */
6728
+ function stageMetadata(fields) {
6729
+ const out = {};
6730
+ for (const field of STAGE_FIELDS) if (fields[field] !== void 0) out[field] = String(fields[field]);
6731
+ return out;
6732
+ }
6733
+ /**
6734
+ * The create body of a pipeline step: its ID, label and displayOrder, and every stage it carries, in display order,
6735
+ * each with its ID, its index as displayOrder and its metadata. HubSpot refuses a pipeline without a stage.
6736
+ */
6737
+ function pipelineCreateBody(step, pipelineId) {
6738
+ const desired = step.desired ?? {};
6739
+ return {
6740
+ pipelineId,
6741
+ label: desired.label,
6742
+ displayOrder: desired.displayOrder,
6743
+ stages: (step.stages ?? []).map((st, displayOrder) => ({
6744
+ stageId: st.address.slice(st.address.lastIndexOf("/") + 1),
6745
+ label: st.desired.label,
6746
+ displayOrder,
6747
+ metadata: stageMetadata(st.desired)
6748
+ }))
6749
+ };
6750
+ }
6751
+ /** The create body of a stage step: its ID, label and metadata, at `displayOrder`, a free slot after every stage. */
6752
+ function stageCreateBody(desired, stageId, displayOrder) {
6753
+ return {
6754
+ stageId,
6755
+ label: desired.label,
6756
+ displayOrder,
6757
+ metadata: stageMetadata(desired)
6758
+ };
6759
+ }
6760
+ /** The PATCH body of a pipeline step: its approved label and displayOrder. Stage order is written stage by stage. */
6761
+ function pipelinePatch(changes) {
6762
+ const body = {};
6763
+ for (const change of changes) if (change.unit === "label" || change.unit === "displayOrder") body[change.unit] = change.after;
6764
+ return body;
6765
+ }
6766
+ /** The PATCH body of a stage step: its approved label, and its metadata field, which HubSpot merges into the rest. */
6767
+ function stagePatch(changes) {
6768
+ const body = {};
6769
+ const fields = {};
6770
+ for (const change of changes) if (change.unit === "label") body.label = change.after;
6771
+ else fields[change.unit] = change.after;
6772
+ const metadata = stageMetadata(fields);
6773
+ return Object.keys(metadata).length > 0 ? {
6774
+ ...body,
6775
+ metadata
6776
+ } : body;
6777
+ }
6778
+ /**
6779
+ * The stage order a write leaves: every live stage, the approved order's stages in that order, each in a slot one of
6780
+ * them holds, so a stage the order leaves out keeps its place among its neighbours. Stages the approved order names and
6781
+ * HubSpot does not hold are left out.
6782
+ */
6783
+ function stageOrder(live, approved) {
6784
+ const queue = approved.filter((id) => live.includes(id));
6785
+ const placed = new Set(queue);
6786
+ return live.map((id) => placed.has(id) ? queue.shift() : id);
6787
+ }
5892
6788
  /** The option values a step's approved changes remove. */
5893
6789
  function removedValues(changes = []) {
5894
6790
  return changes.filter((c) => c.op === "remove").flatMap((c) => {
@@ -5951,6 +6847,17 @@ const CAPTURED = {
5951
6847
  "secondaryDisplayProperties"
5952
6848
  ],
5953
6849
  group: ["label"],
6850
+ pipeline: [
6851
+ "label",
6852
+ "displayOrder",
6853
+ "stages"
6854
+ ],
6855
+ stage: [
6856
+ "label",
6857
+ "probability",
6858
+ "ticketState",
6859
+ "state"
6860
+ ],
5954
6861
  property: PROPERTY_FIELDS.filter((field) => field !== "options"),
5955
6862
  unsupported: [
5956
6863
  "label",
@@ -5999,10 +6906,36 @@ function objectOf(address) {
5999
6906
  const { type, path } = parseAddress(address);
6000
6907
  return type === "object" ? path : path.slice(0, path.indexOf("/"));
6001
6908
  }
6909
+ /** The path after the object: a stage's is `<pipeline>/<stage>`; see stageIdOf for its own ID. */
6002
6910
  function nameOf(address) {
6003
6911
  const { type, path } = parseAddress(address);
6004
6912
  return type === "object" ? path : path.slice(path.indexOf("/") + 1);
6005
6913
  }
6914
+ /** The pipeline a stage address is under, `pipeline:<k>/<p>`. */
6915
+ function pipelineOf(stage) {
6916
+ return `pipeline:${stage.slice(6, stage.lastIndexOf("/"))}`;
6917
+ }
6918
+ /** A pipeline's or stage's own ID: the last segment of its address. */
6919
+ function ownId(address) {
6920
+ return address.slice(address.lastIndexOf("/") + 1);
6921
+ }
6922
+ /** The name a title gives a resource: a stage's own ID, otherwise the name in its address. */
6923
+ function shownName(address) {
6924
+ return parseAddress(address).type === "stage" ? ownId(address) : nameOf(address);
6925
+ }
6926
+ /** Where a title places a resource after its name: its pipeline and object for a stage, its object for the rest. */
6927
+ function placeOf(address) {
6928
+ const { type } = parseAddress(address);
6929
+ if (type === "object") return "";
6930
+ return `${type === "stage" ? ` of pipeline ${ownId(pipelineOf(address))}` : ""} on ${objectOf(address)}`;
6931
+ }
6932
+ /** How a title words a field a step sets: a field whose change rewrites or recounts record values says so. */
6933
+ function fieldWords(unit) {
6934
+ if (unit === "stages") return "the stage order";
6935
+ return REVALUES.has(unit) ? `${unit} (the effect on existing values is not checked)` : unit;
6936
+ }
6937
+ /** What a title adds to the delete of a pipeline or stage: HubSpot purges both (observed 2026-10-01). */
6938
+ const PURGED = "; it cannot be restored";
6006
6939
  /**
6007
6940
  * What an update or adopt step writes, in words, as the end of its title: `, set label, relabel option "north", add
6008
6941
  * option "Paused"`. `option` gives an added or removed option's words, `field` a set field's. The units stay in the
@@ -6129,9 +7062,21 @@ const NOT_CAPTURED = {
6129
7062
  "modificationMetadata",
6130
7063
  "sensitiveDataCategories",
6131
7064
  "updatedAt",
6132
- "updatedUserId"
7065
+ "updatedUserId"
7066
+ ],
7067
+ group: ["displayOrder"],
7068
+ pipeline: [
7069
+ "archived",
7070
+ "createdAt",
7071
+ "updatedAt"
7072
+ ],
7073
+ stage: [
7074
+ "archived",
7075
+ "createdAt",
7076
+ "isClosed",
7077
+ "updatedAt",
7078
+ "writePermissions"
6133
7079
  ],
6134
- group: ["displayOrder"],
6135
7080
  object: [
6136
7081
  "allowsSensitiveProperties",
6137
7082
  "associations",
@@ -6148,8 +7093,12 @@ const NOT_CAPTURED = {
6148
7093
  const OBSERVED_TYPES = /* @__PURE__ */ new Set([
6149
7094
  "object",
6150
7095
  "group",
6151
- "property"
7096
+ "property",
7097
+ "pipeline",
7098
+ "stage"
6152
7099
  ]);
7100
+ /** The resource types read from an object's pipelines list. */
7101
+ const PIPELINE_TYPES = /* @__PURE__ */ new Set(["pipeline", "stage"]);
6153
7102
  const NAME_FIX = "rename it in HubSpot to a name without spaces";
6154
7103
  /**
6155
7104
  * The config IR as the side of a comparison, as target `target` sees it when one is given: its definition overrides
@@ -6189,13 +7138,14 @@ function observePortal(portal, loaded, targetName, issues) {
6189
7138
  resources,
6190
7139
  meta
6191
7140
  }, issues) : unread$2(key, portal),
7141
+ pipelines: live ? pipelineCoverage(live, portal, resources, issues) : void 0,
6192
7142
  shadowed: nonEmpty([...new Set(portal.shadowed.filter(under).map(nameOf))].sort(byCodeUnit)),
6193
7143
  excluded: nonEmpty(portal.excluded.filter(under)),
6194
7144
  renamed: renamed(target.overrides ?? {}, under)
6195
7145
  });
6196
7146
  }
6197
7147
  const coverage = {
6198
- complete: Object.values(objects).every((o) => o.status !== "unreadable" && o.unaddressable === void 0),
7148
+ complete: Object.values(objects).every((o) => o.status !== "unreadable" && o.unaddressable === void 0 && o.pipelines?.status !== "unreadable"),
6199
7149
  objects,
6200
7150
  otherObjects: portal.customObjects === void 0 ? "unknown" : [...portal.otherObjects].sort(byCodeUnit),
6201
7151
  notCaptured: NOT_CAPTURED
@@ -6230,18 +7180,27 @@ function statusOf(observation, address) {
6230
7180
  const object = Object.hasOwn(coverage.objects, key) ? coverage.objects[key] : void 0;
6231
7181
  if (!(object && OBSERVED_TYPES.has(type))) return "not-observed";
6232
7182
  if (object.status !== "read") return object.status;
7183
+ if (PIPELINE_TYPES.has(type)) return pipelineStatus(object, address, held);
6233
7184
  if (type === "object") {
6234
7185
  if (object.unsupportedSchema) return "unsupported";
6235
7186
  return held ? "present" : "absent";
6236
7187
  }
6237
7188
  if (object.excluded?.includes(address)) return "excluded";
6238
7189
  if (held) return "present";
7190
+ return type === "property" ? propertyStatus(object, address) : "absent";
7191
+ }
7192
+ function propertyStatus(object, address) {
6239
7193
  const name = nameOf(address);
6240
- if (type !== "property") return "absent";
6241
7194
  if (object.unsupported?.some((u) => u.name === name)) return "unsupported";
6242
7195
  if (object.unaddressable?.includes(name)) return "unreadable";
6243
7196
  return !(object.renamed && Object.hasOwn(object.renamed, address)) && object.outOfScope?.includes(name) ? "excluded" : "absent";
6244
7197
  }
7198
+ function pipelineStatus(object, address, held) {
7199
+ if (object.pipelines === void 0) return "not-observed";
7200
+ if (object.pipelines.status !== "read") return "unreadable";
7201
+ if (object.excluded?.includes(address)) return "excluded";
7202
+ return held ? "present" : "absent";
7203
+ }
6245
7204
  /** The type ID of each custom object the observation saw exist, by config key. */
6246
7205
  function objectTypeIds(observation) {
6247
7206
  const out = {};
@@ -6323,6 +7282,51 @@ function unsupportedProperty(object, u) {
6323
7282
  group: { $ref: `group:${object}/${u.group}` }
6324
7283
  };
6325
7284
  }
7285
+ function pipelineCoverage(live, portal, resources, issues) {
7286
+ const { object } = live;
7287
+ if (live.pipelines === void 0) {
7288
+ const gap = portal.gaps.find((g) => g.object === object && g.list === "pipelines");
7289
+ return gap ? {
7290
+ status: "unreadable",
7291
+ missingScope: gap.scope
7292
+ } : void 0;
7293
+ }
7294
+ const held = (id) => isAddress(`pipeline:${object}/${id}`) && !id.includes("/");
7295
+ const leave = (what, id) => issues.push({
7296
+ code: "W_UNADDRESSABLE_NAME",
7297
+ message: `${what} '${sanitize(id)}' on ${object} has an ID no address can hold, so it is not captured`,
7298
+ fix: "leave it out of config; HubSpot never changes a pipeline or stage ID"
7299
+ });
7300
+ for (const p of live.pipelines) {
7301
+ if (!held(p.id)) {
7302
+ leave("pipeline", p.id);
7303
+ continue;
7304
+ }
7305
+ const stages = p.stages.filter((st) => {
7306
+ if (!held(st.id)) leave("stage", st.id);
7307
+ return held(st.id);
7308
+ });
7309
+ const definition = {
7310
+ label: p.label,
7311
+ displayOrder: p.displayOrder,
7312
+ stages: stages.map((st) => st.id)
7313
+ };
7314
+ resources.push([`pipeline:${object}/${p.id}`, {
7315
+ type: "pipeline",
7316
+ managed: true,
7317
+ definition
7318
+ }]);
7319
+ for (const st of stages) {
7320
+ const { id, ...fields } = st;
7321
+ resources.push([`stage:${object}/${p.id}/${id}`, {
7322
+ type: "stage",
7323
+ managed: true,
7324
+ definition: fields
7325
+ }]);
7326
+ }
7327
+ }
7328
+ return { status: "read" };
7329
+ }
6326
7330
  function unread$2(key, portal) {
6327
7331
  if (portal.excluded.includes(`object:${key}`)) return { status: "excluded" };
6328
7332
  if (portal.absent.includes(key)) return { status: "absent" };
@@ -6342,6 +7346,25 @@ function nonEmpty(list) {
6342
7346
  function compact$3(value) {
6343
7347
  return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== void 0));
6344
7348
  }
7349
+ const pinWarningDays = 90;
7350
+ /** One warning per API family whose pin expires within 90 days. `expires` is a month; its first day counts. */
7351
+ function pinWarnings(rows, now = Date.now()) {
7352
+ const out = [];
7353
+ const seen = /* @__PURE__ */ new Set();
7354
+ for (const row of rows) {
7355
+ if (row.expires === void 0) continue;
7356
+ const pin = `${row.family} ${row.version}`;
7357
+ const expires = /* @__PURE__ */ new Date(`${row.expires}-01T00:00:00Z`);
7358
+ if (seen.has(pin) || expires.getTime() - now > pinWarningDays * 864e5) continue;
7359
+ seen.add(pin);
7360
+ out.push({
7361
+ code: "W_PIN_EXPIRES",
7362
+ message: `the ${row.family} API pin ${row.version} expires ${row.expires}`,
7363
+ fix: `upgrade ${bin} to a release that pins a newer version`
7364
+ });
7365
+ }
7366
+ return out;
7367
+ }
6345
7368
  /** The account types a target is not protected on unless config says so: test portals, sandboxes, app developers. */
6346
7369
  const UNPROTECTED = /* @__PURE__ */ new Set([
6347
7370
  "DEVELOPER_TEST",
@@ -6399,6 +7422,30 @@ async function preflight(http, request) {
6399
7422
  } : overall);
6400
7423
  }));
6401
7424
  }
7425
+ if (request.pipelines) {
7426
+ const observed = new Set(Object.values(request.objectTypeIds));
7427
+ limits.push(await reading(registry.pipeline.limitKey, async () => {
7428
+ const body = await http.request({
7429
+ type: "limits",
7430
+ path: "pipelines"
7431
+ });
7432
+ const custom = body?.customObjectTypes;
7433
+ const overall = figures(custom?.overallLimit, custom?.overallUsage);
7434
+ const listed = body?.hubspotDefinedObjectTypes;
7435
+ const byObjectType = (Array.isArray(listed) ? listed : []).flatMap((item) => {
7436
+ const { objectTypeId, limit, usage } = item ?? {};
7437
+ const entry = typeof objectTypeId === "string" && observed.has(objectTypeId) && figures(limit, usage);
7438
+ return entry ? [{
7439
+ objectTypeId,
7440
+ ...entry
7441
+ }] : [];
7442
+ }).sort((a, b) => byCodeUnit(a.objectTypeId, b.objectTypeId));
7443
+ return overall && (byObjectType.length > 0 ? {
7444
+ ...overall,
7445
+ byObjectType
7446
+ } : overall);
7447
+ }));
7448
+ }
6402
7449
  return { limits };
6403
7450
  }
6404
7451
  /**
@@ -6414,6 +7461,7 @@ function headroom(limits, creates, objectTypeIds, target) {
6414
7461
  blocked: {},
6415
7462
  issues: []
6416
7463
  };
7464
+ pipelineRoom(out, limits, creates, objectTypeIds, target);
6417
7465
  const ofType = (type) => creates.filter((address) => parseAddress(address).type === type);
6418
7466
  const objectTypes = readOf(limits, registry.object.limitKey);
6419
7467
  if (objectTypes) check$2(out, ofType("object"), objectTypes, "custom objects", target);
@@ -6429,6 +7477,24 @@ function headroom(limits, creates, objectTypeIds, target) {
6429
7477
  }
6430
7478
  return out;
6431
7479
  }
7480
+ /**
7481
+ * The room the pipelines reading leaves for pipeline creates: a standard object's against its own entry, a custom
7482
+ * object's against the overall custom object figures. Called by headroom.
7483
+ */
7484
+ function pipelineRoom(out, limits, creates, ids, target) {
7485
+ const pipelines = readOf(limits, registry.pipeline.limitKey);
7486
+ if (!pipelines) return;
7487
+ const byObject = /* @__PURE__ */ new Map();
7488
+ for (const address of creates.filter((a) => parseAddress(a).type === "pipeline")) byObject.set(objectOf(address), [...byObject.get(objectOf(address)) ?? [], address]);
7489
+ const custom = [];
7490
+ for (const [key, list] of byObject) {
7491
+ const id = Object.hasOwn(ids, key) ? ids[key] : void 0;
7492
+ const entry = pipelines.byObjectType?.find((e) => e.objectTypeId === id);
7493
+ if (entry) check$2(out, list, entry, `pipelines on ${key}`, target);
7494
+ else if (!(id ?? "").startsWith("0-")) custom.push(...list);
7495
+ }
7496
+ check$2(out, custom, pipelines, "custom object pipelines", target);
7497
+ }
6432
7498
  function check$2(out, creates, { limit, usage }, what, target) {
6433
7499
  const room = limit - usage;
6434
7500
  if (creates.length === 0 || room >= creates.length) return;
@@ -6620,16 +7686,37 @@ function schemaNames(definition) {
6620
7686
  function own$3(record, key) {
6621
7687
  return Object.hasOwn(record, key) ? record[key] : void 0;
6622
7688
  }
7689
+ function indexPipelines(observation) {
7690
+ const out = {
7691
+ pipelines: /* @__PURE__ */ new Map(),
7692
+ stages: /* @__PURE__ */ new Map()
7693
+ };
7694
+ for (const address of Object.keys(observation.resources)) {
7695
+ const kind = kindOf$3(address);
7696
+ if (kind === "pipeline" || kind === "stage") {
7697
+ const by = out[kind === "pipeline" ? "pipelines" : "stages"];
7698
+ by.set(objectOf(address), [...by.get(objectOf(address)) ?? [], address]);
7699
+ }
7700
+ }
7701
+ return out;
7702
+ }
6623
7703
  const CREATED_FIRST = [
6624
7704
  "label",
6625
7705
  "group",
6626
7706
  "fieldType"
6627
7707
  ];
6628
7708
  const CREATE_ONLY = /* @__PURE__ */ new Set(["hasUniqueValue", "dataSensitivity"]);
7709
+ const HIDDEN_CREATED = /* @__PURE__ */ new Set([
7710
+ "type",
7711
+ "options",
7712
+ "stages"
7713
+ ]);
6629
7714
  const KINDS = [
6630
7715
  "object",
6631
7716
  "group",
6632
- "property"
7717
+ "property",
7718
+ "pipeline",
7719
+ "stage"
6633
7720
  ];
6634
7721
  const TRANSPORT = "public-api";
6635
7722
  const SETS = /* @__PURE__ */ new Set(["requiredProperties", "searchableProperties"]);
@@ -6641,9 +7728,13 @@ const PROVENANCE_ACTIONS = /* @__PURE__ */ new Set([
6641
7728
  ]);
6642
7729
  const NOT_COVERED = {
6643
7730
  object: "Not copied, HubSpot has no API: record page layouts, saved views.",
6644
- property: "Not copied, HubSpot has no API: conditional property logic, field-level permissions."
7731
+ property: "Not copied, HubSpot has no API: conditional property logic, field-level permissions.",
7732
+ pipeline: "Not copied, HubSpot has no API: stage required properties, conditional stage properties, pipeline automation, pipeline permissions."
6645
7733
  };
6646
7734
  const NO_SCHEMA_WRITES = "custom object schema writes are not supported in this release";
7735
+ function noPipelineWrites(object) {
7736
+ return `Kalup reads and compares the pipelines of ${object} and does not write them in this release`;
7737
+ }
6647
7738
  const INCOMPLETE_FIX = "give the key the scopes the plan warns about, then plan again";
6648
7739
  const TITLE_MAX$1 = 160;
6649
7740
  const TEXT_MAX$2 = 400;
@@ -6756,6 +7847,7 @@ function planReads(input) {
6756
7847
  limits: {
6757
7848
  objectTypes: decided.steps.some((s) => s.action === "create" && s.risk !== "blocked" && kindOf$3(s.address) === "object"),
6758
7849
  properties: creates.length > 0 || gone.some((g) => g.taken),
7850
+ pipelines: decided.steps.some((s) => s.action === "create" && s.risk !== "blocked" && kindOf$3(s.address) === "pipeline"),
6759
7851
  objectTypeIds: typeIdsOf(coverage)
6760
7852
  }
6761
7853
  };
@@ -6816,11 +7908,13 @@ function stepLines(doc, step, project) {
6816
7908
  const labels = step.labels?.length ? ` [${step.labels.join(", ")}]` : "";
6817
7909
  const block = step.blocked ? [` ${step.blocked.detail}`, ...step.blocked.fix === void 0 ? [] : [` fix: ${step.blocked.fix}`]] : [];
6818
7910
  const created = step.action === "create" && step.risk !== "blocked" ? createdLine(step.desired ?? {}) : [];
7911
+ const carriedLines = (step.risk === "blocked" ? [] : step.stages ?? []).map((st) => ` + stage ${show$2(st.desired.label)} (${show$2(ownId(st.address))})${stageFields(st.desired)}`);
6819
7912
  return [
6820
7913
  `${step.id} ${step.risk}${labels} ${step.title}`,
6821
7914
  ...created,
6822
- ...(step.changes ?? []).map((c) => ` ${changeLine(c)}`),
6823
- ...(step.held ?? []).map((h) => ` held ${h.unit} ${h.class}: ${heldValues(h)}. ${exits(doc, step, h)}`),
7915
+ ...carriedLines,
7916
+ ...(step.changes ?? []).map((c) => ` ${c.unit === "stages" ? orderLine(step, c) : changeLine(c)}`),
7917
+ ...(step.held ?? []).map((h) => h.unit === "stages" ? ` held stage order ${h.class}: ${heldOrder(step, h)}. ${exits(doc, step, h)}` : ` held ${h.unit} ${h.class}: ${heldValues(h)}. ${exits(doc, step, h)}`),
6824
7918
  ...sharedLine(doc, step, project),
6825
7919
  ...(step.notes ?? []).map((n) => ` note ${n.unit}: ${n.note}`),
6826
7920
  ...block
@@ -6833,6 +7927,26 @@ function changeLine(c) {
6833
7927
  }
6834
7928
  return `${c.unit}: ${shown$1(c.before)} -> ${shown$1(c.after)}`;
6835
7929
  }
7930
+ function orderLine(step, c) {
7931
+ return `stage order: ${byLabel(step, c.before)} -> ${byLabel(step, c.after)}`;
7932
+ }
7933
+ function heldOrder(step, h) {
7934
+ const base = Object.hasOwn(h, "base") ? `, last agreed ${byLabel(step, h.base)}` : "";
7935
+ return `config ${byLabel(step, h.config)}, portal ${byLabel(step, h.live)}${base}`;
7936
+ }
7937
+ function byLabel(step, list) {
7938
+ const labels = step.stageLabels ?? {};
7939
+ const ids = Array.isArray(list) ? list : [];
7940
+ const shared = (label) => Object.values(labels).filter((l) => l === label).length > 1;
7941
+ return ids.map((id) => {
7942
+ const label = own$2(labels, id);
7943
+ if (label === void 0) return show$2(id);
7944
+ return shared(label) ? `${show$2(label)} (${id})` : show$2(label);
7945
+ }).join(", ");
7946
+ }
7947
+ function stageFields(desired) {
7948
+ return Object.entries(desired).filter(([field]) => field !== "label").map(([field, value]) => `, ${field} ${shown$1(value)}`).join("");
7949
+ }
6836
7950
  function heldValues(h) {
6837
7951
  const base = Object.hasOwn(h, "base") ? `, last agreed ${shown$1(h.base)}` : "";
6838
7952
  return `config ${shown$1(h.config)}, portal ${shown$1(h.live)}${base}`;
@@ -6840,7 +7954,7 @@ function heldValues(h) {
6840
7954
  function createdLine(desired) {
6841
7955
  const options = desired.options;
6842
7956
  const given = (field) => Object.hasOwn(desired, field) && desired[field] !== void 0;
6843
- const rest = Object.keys(desired).filter((field) => !(CREATED_FIRST.includes(field) || field === "type" || field === "options") && given(field)).sort(byCodeUnit);
7957
+ const rest = Object.keys(desired).filter((field) => !(CREATED_FIRST.includes(field) || HIDDEN_CREATED.has(field)) && given(field)).sort(byCodeUnit);
6844
7958
  const fields = [...[...CREATED_FIRST.filter(given), ...rest].map((field) => `${field} ${shown$1(desired[field])}${CREATE_ONLY.has(field) ? " (create only)" : ""}`), ...options && options.length > 0 ? [`options ${options.map((o) => show$2(o.label)).join(", ")}`] : []];
6845
7959
  return fields.length > 0 ? [` ${fields.join(", ")}`] : [];
6846
7960
  }
@@ -6887,6 +8001,7 @@ function decide$1(input, coverage) {
6887
8001
  matched: /* @__PURE__ */ new Set(),
6888
8002
  missing: [],
6889
8003
  overrides: settings.overrides ?? {},
8004
+ pipelines: indexPipelines(observation),
6890
8005
  policy: {
6891
8006
  drift,
6892
8007
  allowDestroy,
@@ -6954,6 +8069,7 @@ function stepFor(context, address, resource, status) {
6954
8069
  return blocked(address, actionOf(status), "dependency-blocked", detail, detail);
6955
8070
  }
6956
8071
  if (status === "unsupported") return unsupported(context, address);
8072
+ if (status === "absent" && kindOf$3(address) === "stage" && carried(context, address)) return;
6957
8073
  const owner = ownerOf(context, address);
6958
8074
  if (status === "absent" && owner.entry && kindOf$3(address) !== "object") return ownedAbsent(context, address, resource, override, owner.entry);
6959
8075
  const group = missingGroup(context, resource);
@@ -6973,6 +8089,14 @@ function stepFor(context, address, resource, status) {
6973
8089
  function unread$1(context, address, status) {
6974
8090
  const key = objectOf(address);
6975
8091
  const object = own$2(context.coverage.objects, key);
8092
+ const kind = kindOf$3(address);
8093
+ if ((kind === "pipeline" || kind === "stage") && object?.status === "read") {
8094
+ const scope = object.pipelines?.missingScope;
8095
+ if (object.pipelines === void 0) return blocked(address, "unknown", "scope", "pipelines not read", `the pipelines of ${key} are not in scope, so the plan did not read them`, `set objects.${key}.pipelines: true`);
8096
+ const detail = `the pipelines list of ${key} answered 403, so what the portal holds there is unknown`;
8097
+ const fix = scope === void 0 ? "check the scopes of the key" : `add the scope ${scope} to the key`;
8098
+ return blocked(address, "unknown", "scope", `the key cannot read the pipelines of ${key}`, detail, fix);
8099
+ }
6976
8100
  if (object?.status === "read") return blocked(address, "unknown", "unsupported", "its portal group has no address", "the portal has it in a group whose name holds whitespace, which no address can hold, so what it holds is unknown", "rename the group in HubSpot to a name without spaces");
6977
8101
  if (status === "not-observed") {
6978
8102
  const detail = `${key} is not under objects in kalup.config.ts, so the plan did not read it`;
@@ -6985,7 +8109,11 @@ function unread$1(context, address, status) {
6985
8109
  }
6986
8110
  function blockedParents(context, address, resource) {
6987
8111
  if (kindOf$3(address) === "object") return [];
6988
- return [(resource.definition?.group)?.$ref, `object:${objectOf(address)}`].flatMap((parent) => {
8112
+ return [
8113
+ (resource.definition?.group)?.$ref,
8114
+ kindOf$3(address) === "stage" ? pipelineOf(address) : void 0,
8115
+ `object:${objectOf(address)}`
8116
+ ].flatMap((parent) => {
6989
8117
  const found = parent === void 0 ? void 0 : context.decided.get(parent);
6990
8118
  return found?.risk === "blocked" ? [found] : [];
6991
8119
  });
@@ -7010,6 +8138,7 @@ function absent(context, address, resource, override) {
7010
8138
  const kind = kindOf$3(address);
7011
8139
  const name = nameOf(address);
7012
8140
  if (kind === "object") return blocked(address, "create", "unsupported", "schema writes not supported", `the portal has no custom object ${name}, and ${NO_SCHEMA_WRITES}`, `create it in HubSpot, or ${skipFix(address, input.target)}`);
8141
+ if (kind === "pipeline" || kind === "stage") return pipelineCreate(context, address, resource);
7013
8142
  if (kind === "property" && own$2(input.archivedProperties, objectOf(address))?.some((p) => p.name === name)) {
7014
8143
  const [detail, fix] = archivedName$1(name);
7015
8144
  return blocked(address, "create", "unsupported", "archived name", detail, fix);
@@ -7022,6 +8151,100 @@ function absent(context, address, resource, override) {
7022
8151
  expect: { exists: false }
7023
8152
  };
7024
8153
  }
8154
+ function pipelineCreate(context, address, resource) {
8155
+ const { target } = context.input;
8156
+ const object = objectOf(address);
8157
+ if (!writesPipelines(object)) {
8158
+ const fix = `create it in HubSpot and run ${bin} pull, or ${skipFix(address, target)}`;
8159
+ return blocked(address, "create", "unsupported", "pipelines not written", noPipelineWrites(object), fix);
8160
+ }
8161
+ const stages = kindOf$3(address) === "pipeline" ? carriedStages(context, address) : [];
8162
+ if (kindOf$3(address) === "pipeline" && stages.length === 0) return blocked(address, "create", "override", "every stage skipped", `every stage of ${address} is skipped on target ${target}, and HubSpot refuses a pipeline without one`, `skip ${address} too, or keep one of its stages on target ${target}`);
8163
+ for (const at of [address, ...stages.map((st) => st.address)]) {
8164
+ const holder = idHolder(context, at);
8165
+ if (holder) return blocked(address, "create", "unsupported", "ID taken", `${holder} holds the ID ${ownId(at)} in this portal, and HubSpot keeps ${kindOf$3(at)} IDs unique ${kindOf$3(at) === "pipeline" ? "across objects" : `across the pipelines of ${object}`}`, `give ${at} another ID in config`);
8166
+ if (at !== address && own$2(context.overrides, at)?.name) {
8167
+ const detail = `the pipeline is absent, and ${at} carries a name override, so no stage of that name can exist`;
8168
+ return blocked(address, "create", "override", detail, detail, `correct or remove the name override for ${at} under targets.${target}.overrides`);
8169
+ }
8170
+ }
8171
+ const notes = [
8172
+ ...assignedNotes(context, address, stages),
8173
+ ...unreadClash(context, address),
8174
+ ...firstPipelineNote(context, address)
8175
+ ];
8176
+ const what = stages.length > 0 ? ` with ${plural(stages.length, "stage")}` : "";
8177
+ return {
8178
+ ...head(address, "create", "safe", `Create ${described(address, resource)}${what}`),
8179
+ desired: resource.definition,
8180
+ ...stages.length > 0 ? { stages } : {},
8181
+ ...notes.length > 0 ? { notes } : {},
8182
+ expect: { exists: false }
8183
+ };
8184
+ }
8185
+ /** Whether Kalup writes the pipelines of an object: deals, tickets and custom objects. */
8186
+ function carriedStages(context, pipeline) {
8187
+ const { resources } = context.input.loaded.ir;
8188
+ return (own$2(resources, pipeline)?.definition?.stages ?? []).flatMap((id) => {
8189
+ const address = `stage:${pipeline.slice(9)}/${id}`;
8190
+ const stage = own$2(resources, address);
8191
+ return stage?.managed && own$2(context.overrides, address)?.skip !== true ? [{
8192
+ address,
8193
+ desired: stage.definition ?? {}
8194
+ }] : [];
8195
+ });
8196
+ }
8197
+ function carried(context, stage) {
8198
+ const pipeline = pipelineOf(stage);
8199
+ const step = context.decided.get(pipeline);
8200
+ return step?.action === "create" && step.risk !== "blocked" || context.missing.some((m) => m.address === pipeline);
8201
+ }
8202
+ function idHolder(context, address) {
8203
+ const id = ownId(address);
8204
+ const object = objectOf(address);
8205
+ const { pipelines, stages } = context.pipelines;
8206
+ if (kindOf$3(address) === "pipeline") return [...pipelines].flatMap(([key, held]) => key === object ? [] : held).find((a) => ownId(a) === id);
8207
+ const pipeline = pipelineOf(address);
8208
+ return (stages.get(object) ?? []).find((a) => pipelineOf(a) !== pipeline && ownId(a) === id);
8209
+ }
8210
+ function unreadClash(context, address) {
8211
+ const other = {
8212
+ deals: "tickets",
8213
+ tickets: "deals"
8214
+ }[objectOf(address)];
8215
+ if (kindOf$3(address) !== "pipeline" || other === void 0) return [];
8216
+ if (own$2(context.coverage.objects, other)?.pipelines?.status === "read") return [];
8217
+ return [{
8218
+ unit: "id",
8219
+ live: null,
8220
+ note: `HubSpot keeps pipeline IDs unique across deals and tickets, and this plan did not read the pipelines of ${other}: HubSpot refuses the create if ${other === "tickets" ? "a ticket pipeline" : "a deal pipeline"} holds ${ownId(address)}`
8221
+ }];
8222
+ }
8223
+ function assignedNotes(context, address, stages) {
8224
+ const ids = [address, ...stages.map((st) => st.address)].filter((a) => ASSIGNED.test(ownId(a)));
8225
+ if (ids.length === 0) return [];
8226
+ const { observation, target } = context.input;
8227
+ const pipeline = kindOf$3(address) === "stage" ? pipelineOf(address) : address;
8228
+ const label = String(own$2(observation.resources, pipeline)?.definition?.label ?? own$2(context.input.loaded.ir.resources, pipeline)?.definition?.label);
8229
+ const matching = (context.pipelines.pipelines.get(objectOf(address)) ?? []).filter((a) => String(own$2(observation.resources, a)?.definition?.label).toLowerCase() === label.toLowerCase());
8230
+ const [only] = matching;
8231
+ const match = matching.length === 1 && only ? `; the portal holds a pipeline "${label}" as ${ownId(only)}` : "";
8232
+ return [{
8233
+ unit: "id",
8234
+ live: null,
8235
+ note: sanitize(`HubSpot assigned the ID ${ids.map(ownId).join(", ")} in another portal${match}. If this portal holds the same pipeline under other IDs, add a name override for the pipeline and each stage under targets.${target}.overrides instead of creating a copy`, TEXT_MAX$2)
8236
+ }];
8237
+ }
8238
+ function firstPipelineNote(context, address) {
8239
+ const object = objectOf(address);
8240
+ const first = kindOf$3(address) === "pipeline" && !STANDARD_OBJECTS.has(object) && !context.pipelines.pipelines.has(object);
8241
+ const note = `the first pipeline on ${object}: HubSpot adds its own properties hs_pipeline and hs_pipeline_stage to the object, and keeps them after the pipeline is gone`;
8242
+ return first ? [{
8243
+ unit: "object",
8244
+ live: null,
8245
+ note
8246
+ }] : [];
8247
+ }
7025
8248
  function ownedAbsent(context, address, resource, override, entry) {
7026
8249
  const { input } = context;
7027
8250
  const kind = kindOf$3(address);
@@ -7032,7 +8255,7 @@ function ownedAbsent(context, address, resource, override, entry) {
7032
8255
  });
7033
8256
  const found = kind === "property" ? archivedOf(input.archivedProperties, objectOf(address), portalName$1(context, address)) : null;
7034
8257
  const archived = found === null ? null : found !== void 0;
7035
- const recreatable = kind === "group" || archived === false;
8258
+ const recreatable = kind !== "property" || archived === false;
7036
8259
  if (!taken) {
7037
8260
  const flag = targetFlag(input.target);
7038
8261
  context.missing.push({
@@ -7115,9 +8338,13 @@ function settle$1(context, r) {
7115
8338
  };
7116
8339
  for (const u of units) place$1(context, r, u, bins);
7117
8340
  const { baseUnits, changes, held, notes } = bins;
7118
- if (bins.refused) return blocked(address, action, "unsupported", "schema writes not supported", `${NO_SCHEMA_WRITES}, so --take config cannot write its units`, "leave it out of --take");
8341
+ if (bins.refused) {
8342
+ const detail = `${readOnlyOf(address)}, so --take config cannot write its units`;
8343
+ return blocked(address, action, "unsupported", kind === "object" ? "schema writes not supported" : "pipelines not written", detail, "leave it out of --take");
8344
+ }
7119
8345
  const meta = context.input.observation.meta?.[address];
7120
8346
  reorder(r, changes, meta);
8347
+ if (kind === "pipeline") reorderStages(context, r, changes);
7121
8348
  const block = writeBlock(kind, units, changes.map((c) => c.unit), meta);
7122
8349
  if (block) return blocked(address, action, "unsupported", block.short, block.detail, block.fix);
7123
8350
  const takeover = takeoverUnits(context, r, changes);
@@ -7135,11 +8362,26 @@ function settle$1(context, r) {
7135
8362
  ...held.length > 0 ? { held } : {},
7136
8363
  ...notes.length > 0 ? { notes } : {},
7137
8364
  ...baseUnits.length > 0 ? { baseUnits } : {},
8365
+ ...stageLabelsOf(context, address, changes, held),
7138
8366
  expect: expectOf(kind, changes, observed)
7139
8367
  }, context.policy, owner.entry, { takeoverUnits: takeover });
7140
8368
  }
8369
+ function stageLabelsOf(context, address, changes, held) {
8370
+ const lists = [...changes.filter((c) => c.unit === "stages").flatMap((c) => [c.before, c.after]), ...held.filter((h) => h.unit === "stages").flatMap((h) => [
8371
+ h.config,
8372
+ h.live,
8373
+ h.base
8374
+ ])];
8375
+ const ids = [...new Set(lists.flatMap((list) => Array.isArray(list) ? list : []))].sort(byCodeUnit);
8376
+ if (ids.length === 0) return {};
8377
+ const prefix = `${address.replace("pipeline:", "stage:")}/`;
8378
+ const { loaded, observation } = context.input;
8379
+ const label = (id) => own$2(loaded.ir.resources, `${prefix}${id}`)?.definition?.label ?? own$2(observation.resources, `${prefix}${id}`)?.definition?.label;
8380
+ return { stageLabels: Object.fromEntries(ids.map((id) => [id, String(label(id) ?? id)])) };
8381
+ }
7141
8382
  function place$1(context, r, u, bins) {
7142
- const { action, address, kind, owner } = r;
8383
+ const { action, address, owner } = r;
8384
+ const readOnly = readOnlyOf(address);
7143
8385
  const rewrite = owner.entry?.rewrites && own$2(owner.entry.rewrites, u.unit);
7144
8386
  if (rewrite && u.class !== "converged" && same$2(u.desired, rewrite.sent) && same$2(u.observed, rewrite.stored)) {
7145
8387
  const note = `HubSpot stores ${show$2(rewrite.stored)} when sent ${show$2(rewrite.sent)}; change config to match`;
@@ -7155,9 +8397,9 @@ function place$1(context, r, u, bins) {
7155
8397
  const unwritten = {
7156
8398
  unit: u.unit,
7157
8399
  live: u.observed,
7158
- note: `not written: ${NO_SCHEMA_WRITES}`
8400
+ note: `not written: ${readOnly}`
7159
8401
  };
7160
- if (kind === "object" && disposition === "write") {
8402
+ if (readOnly !== void 0 && disposition === "write") {
7161
8403
  bins.refused ||= taken;
7162
8404
  if (REVERTING.has(u.class)) {
7163
8405
  bins.held.push(heldOf(context, address, u, unpulledOf$1(context, r, u)));
@@ -7167,15 +8409,15 @@ function place$1(context, r, u, bins) {
7167
8409
  disposition = "note";
7168
8410
  }
7169
8411
  if (disposition === "none") {
7170
- if (!(u.unit === "options.order" && u.desired.length === 0) && (action === "adopt" || !(Object.hasOwn(u, "base") && same$2(u.base, u.desired, SETS.has(u.unit))))) bins.baseUnits.push(u.unit);
8412
+ if (!((u.unit === "options.order" || ORDERS.has(u.unit)) && u.desired.length === 0) && (action === "adopt" || !(Object.hasOwn(u, "base") && same$2(u.base, u.desired, SETS.has(u.unit))))) bins.baseUnits.push(u.unit);
7171
8413
  } else if (disposition === "write") bins.changes.push(changeOf(u));
7172
8414
  else if (disposition === "hold") hold(context, r, u, bins);
7173
- else if (kind === "object") bins.notes.push(unwritten);
7174
- else bins.notes.push({
8415
+ else if (readOnly === void 0) bins.notes.push({
7175
8416
  unit: u.unit,
7176
8417
  live: u.observed,
7177
8418
  note: keepNote(context, address, u, unpulledOf$1(context, r, u))
7178
8419
  });
8420
+ else bins.notes.push(unwritten);
7179
8421
  }
7180
8422
  function hold(context, r, u, bins) {
7181
8423
  const unpulled = unpulledOf$1(context, r, u);
@@ -7253,6 +8495,42 @@ function keepNote(context, address, u, unpulled) {
7253
8495
  if (unpulled === void 0) return keptNote(target, address);
7254
8496
  return sanitize(`kept; ${unpulled === SHADOWED_NOTE ? shadowedNote(target) : unpulled}`, TEXT_MAX$2);
7255
8497
  }
8498
+ function reorderStages(context, r, changes) {
8499
+ const desired = r.owned.stages ?? [];
8500
+ const live = r.observed.definition?.stages ?? [];
8501
+ const prefix = `stage:${r.address.slice(9)}/`;
8502
+ const added = new Set(desired.filter((id) => !live.includes(id) && creatable(context, `${prefix}${id}`)));
8503
+ const after = desired.filter((id) => live.includes(id) || added.has(id));
8504
+ const written = changes.find((c) => c.unit === "stages");
8505
+ if (written) {
8506
+ written.after = after;
8507
+ return;
8508
+ }
8509
+ const order = r.units.find((u) => u.unit === "stages");
8510
+ if (added.size === 0 || order?.class !== "converged") return;
8511
+ const closing = (id) => closesStage(own$2(context.input.loaded.ir.resources, `${prefix}${id}`)?.definition);
8512
+ const created = [...added].sort((a, b) => Number(closing(b)) - Number(closing(a)) || byCodeUnit(a, b));
8513
+ if (!same$2([...after.filter((id) => !added.has(id)), ...created], after)) {
8514
+ changes.push({
8515
+ unit: "stages",
8516
+ class: "add",
8517
+ op: "set",
8518
+ before: order.observed,
8519
+ after
8520
+ });
8521
+ changes.sort((a, b) => byCodeUnit(a.unit, b.unit));
8522
+ }
8523
+ }
8524
+ function creatable(context, stage) {
8525
+ const resource = own$2(context.input.loaded.ir.resources, stage);
8526
+ const override = own$2(context.overrides, stage);
8527
+ return resource?.managed === true && override?.skip !== true && override?.name === void 0 && statusOf(context.input.observation, stage) === "absent" && writesPipelines(objectOf(stage)) && idHolder(context, stage) === void 0;
8528
+ }
8529
+ function readOnlyOf(address) {
8530
+ const kind = kindOf$3(address);
8531
+ if (kind === "object") return NO_SCHEMA_WRITES;
8532
+ return (kind === "pipeline" || kind === "stage") && !writesPipelines(objectOf(address)) ? noPipelineWrites(objectOf(address)) : void 0;
8533
+ }
7256
8534
  function reorder(r, changes, meta) {
7257
8535
  const desired = r.owned.options;
7258
8536
  if (desired === void 0) return;
@@ -7287,7 +8565,11 @@ function expectOf(kind, changes, observed) {
7287
8565
  if (changes.length === 0) return { exists: true };
7288
8566
  const live = capturedSpec(observed).fields;
7289
8567
  const values = {};
7290
- for (const field of new Set(changes.map((c) => fieldOf(c.unit)))) values[field] = field === "options" ? observed.definition?.options ?? [] : live[field];
8568
+ for (const change of changes) {
8569
+ const field = fieldOf(change.unit);
8570
+ if (field === "options") values.options = observed.definition?.options ?? [];
8571
+ else values[field] = ORDERS.has(field) ? change.before : live[field];
8572
+ }
7291
8573
  if (kind === "property") {
7292
8574
  values.type = live.type;
7293
8575
  values.fieldType = live.fieldType;
@@ -7304,21 +8586,28 @@ function titleOf(r, changes, baseUnits) {
7304
8586
  return baseUnits.length > 0 ? `Record the agreed values of ${what}` : `No change to ${what}`;
7305
8587
  }
7306
8588
  function writesTitle(changes) {
7307
- return writesTail(changes, (c) => `"${(c.op === "add" ? c.after : c.before).label}"`, (unit) => unit === "fieldType" || unit === "calculationFormula" ? `${unit} (the effect on existing values is not checked)` : unit);
8589
+ return writesTail(changes, (c) => `"${(c.op === "add" ? c.after : c.before).label}"`, fieldWords);
7308
8590
  }
7309
8591
  function removals(context) {
7310
8592
  const entries = Object.entries(context.input.loaded.ir.tombstones).sort(([a], [b]) => byCodeUnit(a, b));
7311
8593
  const releases = [];
7312
8594
  const deletes = [];
7313
8595
  const deleted = /* @__PURE__ */ new Map();
7314
- for (const kind of ["property", "group"]) {
8596
+ for (const kind of [
8597
+ "property",
8598
+ "group",
8599
+ "stage",
8600
+ "pipeline"
8601
+ ]) {
7315
8602
  for (const [address, tombstone] of entries.filter(([a]) => kindOf$3(a) === kind)) {
7316
- const step = removal(context, address, tombstone, deleted);
8603
+ const covering = kind === "stage" ? own$2(context.input.loaded.ir.tombstones, pipelineOf(address)) : void 0;
8604
+ if (covering?.action === tombstone.action) continue;
8605
+ const step = covering ? uncovered(address, tombstone, pipelineOf(address)) : removal(context, address, tombstone, deleted);
7317
8606
  if (step?.action === "release") releases.push(step);
7318
8607
  else if (step) deletes.push(step);
7319
8608
  countDeleted(context, step, deleted);
7320
8609
  }
7321
- for (const address of kind === "property" ? context.takeover.properties : context.takeover.groups) {
8610
+ for (const address of takeoverOf$1(context, kind)) {
7322
8611
  const step = takeoverDelete(context, address, deleted);
7323
8612
  deletes.push(step);
7324
8613
  countDeleted(context, step, deleted);
@@ -7326,9 +8615,17 @@ function removals(context) {
7326
8615
  }
7327
8616
  return [...releases, ...deletes];
7328
8617
  }
8618
+ function uncovered(address, tombstone, pipeline) {
8619
+ if (tombstone.action === "destroy") return blocked(address, "delete", "not-owned", "pipeline released", `the tombstone of ${pipeline} releases the pipeline with its stages, so Kalup no longer owns this stage to delete it`, `set the action of ${pipeline} to destroy to delete it with its stages, or this stage's to release`);
8620
+ return blocked(address, "release", "unsupported", "pipeline deleted", `the tombstone of ${pipeline} deletes the pipeline with its stages, so this stage cannot be kept`, `set the action of ${pipeline} to release to keep it with its stages, or this stage's to destroy`);
8621
+ }
8622
+ function takeoverOf$1(context, kind) {
8623
+ if (kind === "property") return context.takeover.properties;
8624
+ return kind === "group" ? context.takeover.groups : [];
8625
+ }
7329
8626
  function countDeleted(context, step, deleted) {
7330
8627
  if (step?.action === "delete" && (step.risk !== "blocked" || step.blocked?.reason === "policy")) {
7331
- const key = objectOf(step.address);
8628
+ const key = kindOf$3(step.address) === "stage" ? pipelineOf(step.address) : objectOf(step.address);
7332
8629
  deleted.set(key, /* @__PURE__ */ new Set([...deleted.get(key) ?? [], portalName$1(context, step.address)]));
7333
8630
  }
7334
8631
  }
@@ -7413,7 +8710,7 @@ function removal(context, address, tombstone, deleted) {
7413
8710
  return destroy(context, address, entry, deleted);
7414
8711
  }
7415
8712
  function releaseOf(address, tombstone, owner, status) {
7416
- const plain = `${kindOf$3(address) === "group" ? "property group" : "property"} ${nameOf(address)} on ${objectOf(address)}`;
8713
+ const plain = `${nounOf(address)} ${shownName(address)}${placeOf(address)}`;
7417
8714
  if (owner.entry && tombstone.action === "release") {
7418
8715
  let where = "";
7419
8716
  if (status === "present") where = "; it stays in HubSpot";
@@ -7433,23 +8730,33 @@ function destroy(context, address, entry, deleted) {
7433
8730
  const observed = input.observation.resources[address];
7434
8731
  const key = objectOf(address);
7435
8732
  if (!observed.managed) return blocked(address, "delete", "unsupported", "HubSpot-defined or calculated", "HubSpot-defined or calculated in this portal, so Kalup does not delete it");
8733
+ const readOnly = readOnlyOf(address);
8734
+ if (readOnly !== void 0) return blocked(address, "delete", "unsupported", "not written in this release", readOnly, "change the tombstone's action to release, which stops managing it and leaves it in HubSpot");
7436
8735
  const name = portalName$1(context, address);
7437
8736
  const members = kindOf$3(address) === "group" ? {
7438
8737
  active: own$2(input.observation.members?.[key] ?? {}, name) ?? [],
7439
8738
  deleted: deleted.get(key) ?? /* @__PURE__ */ new Set()
7440
8739
  } : void 0;
7441
- const block = deleteBlock(input.observation.meta?.[address], members);
8740
+ const block = deleteBlock(input.observation.meta?.[address], members) ?? stageDeleteBlock(context, address, deleted);
7442
8741
  if (block) return blocked(address, "delete", "unsupported", block.short, block.detail, block.fix);
7443
8742
  if (!policy.allowDestroy) return blocked(address, "delete", "policy", "deletes not allowed", `target ${input.target} does not allow deletes`, `set allowDestroy: true under targets.${input.target} in kalup.config.ts, or change the tombstone's action to release`);
7444
8743
  const values = baseValues(entry, observed);
8744
+ if (kindOf$3(address) === "pipeline") values.stages = observed.definition?.stages ?? [];
8745
+ const purged = kindOf$3(address) === "pipeline" || kindOf$3(address) === "stage";
7445
8746
  return finish$1({
7446
- ...head(address, "delete", "destructive", `Archive ${described(address, observed)}`),
8747
+ ...head(address, "delete", "destructive", `${purged ? "Delete" : "Archive"} ${described(address, observed)}${purged ? PURGED : ""}`),
7447
8748
  expect: Object.keys(values).length > 0 ? {
7448
8749
  exists: true,
7449
8750
  values
7450
8751
  } : { exists: true }
7451
8752
  }, context.policy, entry);
7452
8753
  }
8754
+ function stageDeleteBlock(context, address, deleted) {
8755
+ if (kindOf$3(address) !== "stage") return;
8756
+ const gone = deleted.get(pipelineOf(address)) ?? /* @__PURE__ */ new Set();
8757
+ const before = [...context.decided.values()].filter(hasEffect);
8758
+ return stageDeleteRule(afterSteps(context.input.observation.resources, before), address, gone, bin);
8759
+ }
7453
8760
  function baseValues(entry, observed) {
7454
8761
  const live = capturedSpec(observed).fields;
7455
8762
  const values = {};
@@ -7471,7 +8778,7 @@ function release(address, title, expect) {
7471
8778
  function orphansOf(input, steps) {
7472
8779
  const { loaded, state, target } = input;
7473
8780
  const overrides = loaded.config.targets[target]?.overrides ?? {};
7474
- return Object.entries(state?.resources ?? {}).filter(([address, entry]) => (entry.origin === "created" || entry.origin === "adopted") && !Object.hasOwn(loaded.ir.resources, address) && !Object.hasOwn(loaded.ir.tombstones, address) && !steps.some((s) => s.address === address)).sort(([a], [b]) => byCodeUnit(a, b)).map(([address, entry]) => {
8781
+ return Object.entries(state?.resources ?? {}).filter(([address, entry]) => (entry.origin === "created" || entry.origin === "adopted") && !Object.hasOwn(loaded.ir.resources, address) && !Object.hasOwn(loaded.ir.tombstones, address) && !(kindOf$3(address) === "stage" && Object.hasOwn(loaded.ir.tombstones, pipelineOf(address))) && !steps.some((s) => s.address === address)).sort(([a], [b]) => byCodeUnit(a, b)).map(([address, entry]) => {
7475
8782
  const name = resolvedName(overrides, address);
7476
8783
  const rm = `${bin} rm ${shellWord(address)}`;
7477
8784
  let note = `no longer in config: run ${rm} to delete it in HubSpot, or ${rm} --release to stop managing it`;
@@ -7502,10 +8809,14 @@ function staleNote(context, address, stale) {
7502
8809
  function portalName$1(context, address) {
7503
8810
  return resolvedName(context.overrides, address);
7504
8811
  }
7505
- /** The portal name an address resolves to on a target: its name override, else its own name. */
8812
+ /**
8813
+ * The portal name an address resolves to on a target: its name override, else its own name. A stage's is its stage ID;
8814
+ * its pipeline's ID is the pipeline address's.
8815
+ */
7506
8816
  function resolvedName(overrides, address) {
7507
8817
  const override = own$2(overrides, address);
7508
- return (override?.skip === true ? void 0 : override?.name) ?? nameOf(address);
8818
+ const mine = kindOf$3(address) === "stage" ? ownId(address) : nameOf(address);
8819
+ return (override?.skip === true ? void 0 : override?.name) ?? mine;
7509
8820
  }
7510
8821
  function archivedOf(lists, key, name) {
7511
8822
  const list = own$2(lists, key);
@@ -7573,10 +8884,8 @@ function head(address, action, risk, title) {
7573
8884
  };
7574
8885
  }
7575
8886
  function blocked(address, action, reason, short, detail, fix) {
7576
- const name = nameOf(address);
7577
- const kind = kindOf$3(address);
7578
8887
  return {
7579
- ...head(address, action, "blocked", kind === "object" ? `Cannot plan object ${name}: ${short}` : `Cannot plan ${kind} ${name} on ${objectOf(address)}: ${short}`),
8888
+ ...head(address, action, "blocked", `Cannot plan ${kindOf$3(address)} ${shownName(address)}${placeOf(address)}: ${short}`),
7580
8889
  expect: blockedExpect(action),
7581
8890
  blocked: {
7582
8891
  reason,
@@ -7597,7 +8906,14 @@ function actionOf(status) {
7597
8906
  function described(address, resource) {
7598
8907
  const d = resource.definition ?? {};
7599
8908
  if (kindOf$3(address) === "object") return `custom object "${d.labels.singular}" (${nameOf(address)})`;
7600
- return `${kindOf$3(address) === "group" ? "property group" : "property"} "${String(d.label)}" (${nameOf(address)}) on ${objectOf(address)}`;
8909
+ return `${nounOf(address)} "${String(d.label)}" (${shownName(address)})${placeOf(address)}`;
8910
+ }
8911
+ function nounOf(address) {
8912
+ return {
8913
+ group: "property group",
8914
+ property: "property",
8915
+ pipeline: "pipeline"
8916
+ }[kindOf$3(address)] ?? kindOf$3(address);
7601
8917
  }
7602
8918
  function skipFix(address, target) {
7603
8919
  return `leave it out on this target: add { '${address}': { skip: true } } under targets.${target}.overrides`;
@@ -7626,7 +8942,11 @@ function bindingsFor(open, overrides, idOf) {
7626
8942
  }
7627
8943
  return Object.fromEntries([...found].sort(([a], [b]) => byCodeUnit(a, b)));
7628
8944
  }
7629
- /** A step's own address, every $ref it carries, and the object a group or property is on. */
8945
+ /**
8946
+ * A step's own address, every $ref it carries, the pipeline a stage is under, the stages a pipeline create carries or
8947
+ * its stage order names (live and approved), and the object a group, property, pipeline or stage is on. A stage's name
8948
+ * override is so a binding of each pipeline step that moves or names the stage: apply moves it by its portal ID.
8949
+ */
7630
8950
  function dependencies(step) {
7631
8951
  const out = [step.address];
7632
8952
  collectRefs([
@@ -7634,9 +8954,18 @@ function dependencies(step) {
7634
8954
  step.changes,
7635
8955
  step.expect
7636
8956
  ], out);
8957
+ if (kindOf$3(step.address) === "stage") out.push(pipelineOf(step.address));
8958
+ out.push(...(step.stages ?? []).map((st) => st.address));
8959
+ if (kindOf$3(step.address) === "pipeline") out.push(...orderedStages(step));
7637
8960
  if (kindOf$3(step.address) !== "object") out.push(`object:${objectOf(step.address)}`);
7638
8961
  return out;
7639
8962
  }
8963
+ function orderedStages(step) {
8964
+ const lists = [step.expect.values?.stages, ...(step.changes ?? []).filter((c) => c.unit === "stages").map((c) => c.after)];
8965
+ const prefix = `${step.address.replace("pipeline:", "stage:")}/`;
8966
+ const ids = lists.flatMap((list) => Array.isArray(list) ? list.filter((id) => typeof id === "string") : []);
8967
+ return [...new Set(ids)].map((id) => `${prefix}${id}`);
8968
+ }
7640
8969
  function collectRefs(value, out) {
7641
8970
  if (Array.isArray(value)) {
7642
8971
  for (const item of value) collectRefs(item, out);
@@ -7666,7 +8995,9 @@ function callsOf(steps, missing, bindings) {
7666
8995
  const objects = new Set(effects.map((s) => objectOf(s.address)));
7667
8996
  const archived = /* @__PURE__ */ new Set([...effects.filter((s) => s.action === "create" || s.action === "delete").map((s) => objectOf(s.address)), ...missing.map((m) => objectOf(m.address))]);
7668
8997
  const schemas = Object.values(bindings).some((b) => b.id !== void 0) ? 1 : 0;
7669
- return 3 * effects.filter(writes$1).length + 4 * objects.size + 3 * archived.size + schemas + 1;
8998
+ const reorders = effects.flatMap((s) => s.changes ?? []).filter((c) => c.unit === "stages").reduce((sum, c) => sum + 2 * (c.after?.length ?? 0), 0);
8999
+ const pipelines = new Set(effects.filter((s) => kindOf$3(s.address) === "pipeline" || kindOf$3(s.address) === "stage").map((s) => objectOf(s.address)));
9000
+ return 3 * effects.filter(writes$1).length + 4 * objects.size + 3 * archived.size + schemas + 1 + reorders + pipelines.size;
7670
9001
  }
7671
9002
  function countsOf$1(steps) {
7672
9003
  const counts = {
@@ -7685,11 +9016,12 @@ function countsOf$1(steps) {
7685
9016
  }
7686
9017
  function planCoverage(coverage, steps, excluded, unobserved) {
7687
9018
  const objects = Object.entries(coverage.objects).sort(([a], [b]) => byCodeUnit(a, b));
9019
+ const scopeOf = (o) => o.status === "unreadable" ? o.missingScope : o.pipelines?.missingScope;
7688
9020
  return {
7689
9021
  complete: coverage.complete && !steps.some((s) => s.action === "unknown"),
7690
- unreadable: [...objects.filter(([, o]) => o.status === "unreadable").map(([object, o]) => o.missingScope === void 0 ? { object } : {
9022
+ unreadable: [...objects.filter(([, o]) => o.status === "unreadable" || o.pipelines?.status === "unreadable").map(([object, o]) => scopeOf(o) === void 0 ? { object } : {
7691
9023
  object,
7692
- scope: o.missingScope
9024
+ scope: scopeOf(o)
7693
9025
  }), ...unobserved.map((object) => ({ object }))].sort((a, b) => byCodeUnit(a.object, b.object)),
7694
9026
  unsupported: objects.flatMap(([key, o]) => [...(o.unsupported ?? []).map((u) => `property:${key}/${u.name}`), ...o.unsupportedSchema ? [`object:${key}`] : []]).sort(byCodeUnit),
7695
9027
  excluded
@@ -7775,11 +9107,15 @@ function namesOf(plan, overrides) {
7775
9107
  const bound = [...renamed].find(([address, name]) => address.startsWith(prefix) && name === portalName);
7776
9108
  return bound ? bound[0].slice(prefix.length) : portalName;
7777
9109
  };
9110
+ const portalOf = (address) => renamed.get(address) ?? (kindOf$2(address) === "stage" ? ownId(address) : nameOf(address));
7778
9111
  return {
7779
- portalName: (address) => renamed.get(address) ?? nameOf(address),
9112
+ portalName: portalOf,
7780
9113
  objectType: (key) => own(`object:${key}`)?.id ?? key,
7781
- localGroup: (key, portalName) => local(`group:${key}/`, portalName),
7782
- localProperty: (key, portalName) => local(`property:${key}/`, portalName)
9114
+ localGroup: (key, name) => local(`group:${key}/`, name),
9115
+ localProperty: (key, name) => local(`property:${key}/`, name),
9116
+ localPipeline: (key, id) => local(`pipeline:${key}/`, id),
9117
+ localStage: (key, pipeline, id) => local(`stage:${key}/${pipeline}/`, id),
9118
+ pipelineId: (address) => portalOf(kindOf$2(address) === "stage" ? pipelineOf(address) : address)
7783
9119
  };
7784
9120
  }
7785
9121
  const SENSITIVITIES = [
@@ -7823,8 +9159,60 @@ async function observeForApply(http, plan, overrides) {
7823
9159
  return out;
7824
9160
  }
7825
9161
  async function observeObject(http, read, observing, key, out) {
9162
+ const effects = observing.plan.steps.filter((s) => hasEffect(s) && objectOf(s.address) === key);
9163
+ const pipelines = effects.filter((s) => PIPELINE_TYPES.has(kindOf$2(s.address)));
9164
+ if (pipelines.length > 0) await observePipelines(read, observing, key, pipelines, out);
9165
+ if (pipelines.length < effects.length) await observeProperties(http, read, observing, key, out);
9166
+ }
9167
+ async function observePipelines(read, { names }, key, steps, out) {
9168
+ const live = localPipelines(key, normalizePipelines(key, (await read({
9169
+ type: "pipeline",
9170
+ path: "list",
9171
+ params: { objectType: names.objectType(key) }
9172
+ }, `the pipelines list of ${key}`)).results, !STANDARD_OBJECTS.has(key)), names);
9173
+ for (const step of steps) {
9174
+ const pipeline = kindOf$2(step.address) === "stage" ? pipelineOf(step.address) : step.address;
9175
+ Object.assign(out.resources, Object.fromEntries(pipelineResources(live, pipeline)));
9176
+ }
9177
+ }
9178
+ /** The pipelines of one object under the plan's local pipeline and stage IDs. */
9179
+ function localPipelines(key, live, names) {
9180
+ return live.map((p) => {
9181
+ const id = names.localPipeline(key, p.id);
9182
+ return {
9183
+ ...p,
9184
+ id,
9185
+ stages: p.stages.map((st) => ({
9186
+ ...st,
9187
+ id: names.localStage(key, id, st.id)
9188
+ }))
9189
+ };
9190
+ });
9191
+ }
9192
+ /** One pipeline, `pipeline:<key>/<id>`, and its stages as resources, as observe captures them. None when absent. */
9193
+ function pipelineResources(live, pipeline) {
9194
+ const id = pipeline.slice(pipeline.lastIndexOf("/") + 1);
9195
+ const found = live.find((p) => p.id === id);
9196
+ if (found === void 0) return [];
9197
+ const definition = {
9198
+ label: found.label,
9199
+ displayOrder: found.displayOrder,
9200
+ stages: found.stages.map((st) => st.id)
9201
+ };
9202
+ const prefix = `${pipeline.replace("pipeline:", "stage:")}/`;
9203
+ return [[pipeline, {
9204
+ type: "pipeline",
9205
+ managed: true,
9206
+ definition
9207
+ }], ...found.stages.map(({ id: stage, ...fields }) => [`${prefix}${stage}`, {
9208
+ type: "stage",
9209
+ managed: true,
9210
+ definition: fields
9211
+ }])];
9212
+ }
9213
+ async function observeProperties(http, read, observing, key, out) {
7826
9214
  const { plan, names, schemas } = observing;
7827
- const effects = plan.steps.filter((s) => hasEffect(s) && objectOf(s.address) === key);
9215
+ const effects = plan.steps.filter((s) => hasEffect(s) && objectOf(s.address) === key && !PIPELINE_TYPES.has(kindOf$2(s.address)));
7828
9216
  const objectType = names.objectType(key);
7829
9217
  const properties = await listProperties(read, key, objectType);
7830
9218
  const { groups: live } = normalizeGroups((await read({
@@ -8435,38 +9823,48 @@ function staleUnits(step, observed, observation, portalName = nameOf(step.addres
8435
9823
  if (expect.values === void 0 || observed === void 0) return [];
8436
9824
  const live = capturedSpec(observed).fields;
8437
9825
  return Object.entries(expect.values).filter(([field, value]) => {
8438
- return stableStringify(field === "options" ? observed.definition?.options ?? [] : live[field]) !== stableStringify(value);
9826
+ let now = field === "options" ? observed.definition?.options ?? [] : live[field];
9827
+ if (step.action !== "delete" && ORDERS.has(field) && Array.isArray(now) && Array.isArray(value)) now = now.filter((member) => value.includes(member));
9828
+ return stableStringify(now) !== stableStringify(value);
8439
9829
  }).map(([field]) => field).sort(byCodeUnit);
8440
9830
  }
8441
9831
  /**
8442
9832
  * A step's title from its own data, never the plan's: the kind, the label config gives (for a delete, the label it
8443
9833
  * expects to find), the name and the object. With `names`, the portal name follows the name wherever the two differ.
9834
+ * `warned` adds the warnings the plan's title carries, for a person about to confirm the step: that a pipeline or stage
9835
+ * delete cannot be restored, and that a field change's effect on existing values is not checked.
8444
9836
  */
8445
- function stepTitle(step, names) {
9837
+ function stepTitle(step, names, warned = false) {
8446
9838
  const { address, action } = step;
8447
9839
  const kind = kindOf$1(address);
8448
- const own = nameOf(address);
9840
+ const own = shownName(address);
8449
9841
  const portal = names?.portalName(address);
8450
9842
  const name = portal === void 0 || portal === own ? own : `${own}, portal name ${portal}`;
8451
- const noun = {
8452
- object: "custom object",
8453
- group: "property group",
8454
- property: "property"
8455
- }[kind];
8456
- const where = kind === "object" ? "" : ` on ${objectOf(address)}`;
9843
+ const noun = NOUNS[kind];
9844
+ const where = placeOf(address);
9845
+ const purged = kind === "pipeline" || kind === "stage";
9846
+ const removes = purged ? "Delete" : "Archive";
9847
+ const carried = (step.stages ?? []).length > 0 ? ` with ${plural((step.stages ?? []).length, "stage")}` : "";
8457
9848
  const values = step.action === "delete" ? step.expect.values : step.desired;
8458
9849
  const shown = kind === "object" ? (values?.labels)?.singular : values?.label;
8459
9850
  const label = typeof shown === "string" ? ` "${shown}"` : "";
8460
9851
  const what = `${noun}${label} (${name})${where}`;
8461
9852
  const title = {
8462
- create: () => `${step.labels?.includes("reverts-ui-edit") ? "Recreate" : "Create"} ${what}`,
8463
- adopt: () => `Adopt ${what}${writes(step)}`,
8464
- update: () => (step.changes ?? []).length > 0 ? `Update ${what}${writes(step)}` : `Record the agreed values of ${what}`,
8465
- delete: () => label ? `Archive ${what}` : `Archive ${noun} ${portal === void 0 || portal === own ? own : `${own} (portal name ${portal})`}${where}`,
9853
+ create: () => `${step.labels?.includes("reverts-ui-edit") ? "Recreate" : "Create"} ${what}${carried}`,
9854
+ adopt: () => `Adopt ${what}${writes(step, warned)}`,
9855
+ update: () => (step.changes ?? []).length > 0 ? `Update ${what}${writes(step, warned)}` : `Record the agreed values of ${what}`,
9856
+ delete: () => `${label ? `${removes} ${what}` : `${removes} ${noun} ${portal === void 0 || portal === own ? own : `${own} (portal name ${portal})`}${where}`}${purged && warned ? PURGED : ""}`,
8466
9857
  release: () => `Stop managing ${noun} ${own}${where}; nothing changes in HubSpot`
8467
9858
  }[action];
8468
9859
  return sanitize(title ? title() : `${action} ${address}`, TITLE_MAX);
8469
9860
  }
9861
+ const NOUNS = {
9862
+ object: "custom object",
9863
+ group: "property group",
9864
+ property: "property",
9865
+ pipeline: "pipeline",
9866
+ stage: "stage"
9867
+ };
8470
9868
  /** The value config gives a unit in a step's desired values, or undefined when it gives none. */
8471
9869
  function desiredValue(desired, unit) {
8472
9870
  const member = memberOf(unit);
@@ -8478,6 +9876,8 @@ function desiredValue(desired, unit) {
8478
9876
  function blockOf(plan, step, held, observation) {
8479
9877
  const { entry, owned } = held;
8480
9878
  if (kindOf$1(step.address) === "object" && step.action !== "adopt" && step.action !== "update") return "custom object schema writes are not supported in this release";
9879
+ const readOnly = readOnlyPipeline(step);
9880
+ if (readOnly !== void 0) return readOnly;
8481
9881
  const observed = observation.resources[step.address];
8482
9882
  const names = namesOf(plan);
8483
9883
  switch (step.action) {
@@ -8494,6 +9894,11 @@ function blockOf(plan, step, held, observation) {
8494
9894
  default: return `${sanitize(step.action)} is not a step apply runs`;
8495
9895
  }
8496
9896
  }
9897
+ function readOnlyPipeline(step) {
9898
+ const kind = kindOf$1(step.address);
9899
+ if (!(kind === "pipeline" || kind === "stage") || writesPipelines(objectOf(step.address))) return;
9900
+ return (step.action === "adopt" || step.action === "update" ? (step.changes ?? []).length : 1) > 0 && step.action !== "release" ? `Kalup does not write the pipelines of ${objectOf(step.address)} in this release` : void 0;
9901
+ }
8497
9902
  function writeRefusal(step, trusted, observation) {
8498
9903
  const { owned } = trusted;
8499
9904
  if (step.action === "adopt" && owned) return "state owns it already";
@@ -8514,12 +9919,20 @@ function deleteRefusal(plan, step, { owner, overrides }, observation) {
8514
9919
  const observed = observation.resources[step.address];
8515
9920
  if (observation.unsupported.includes(step.address) || observed?.managed === false) return "its values cannot be checked before the delete";
8516
9921
  if (takeover && observation.meta[step.address]?.hubspotDefined === true) return "HubSpot defines it, and takeover never archives what HubSpot defines";
9922
+ switch (kindOf$1(step.address)) {
9923
+ case "stage": return stageDeleteRefusal(plan, step, observation);
9924
+ case "pipeline": return;
9925
+ case "group": return groupDeleteRefusal(plan, step, takeover, observation);
9926
+ default: {
9927
+ const named = observation.schemaNamed[objectOf(step.address)] ?? [];
9928
+ const kept = takeover ? keptByRead(overrides, plan.target.name, step.address, observed, named) : void 0;
9929
+ return kept === void 0 ? deleteBlock(observation.meta[step.address])?.detail : `takeover never archives it: ${kept}`;
9930
+ }
9931
+ }
9932
+ }
9933
+ function groupDeleteRefusal(plan, step, takeover, observation) {
8517
9934
  const names = namesOf(plan);
8518
9935
  const key = objectOf(step.address);
8519
- if (kindOf$1(step.address) !== "group") {
8520
- const kept = takeover ? keptByRead(overrides, plan.target.name, step.address, observed, observation.schemaNamed[key] ?? []) : void 0;
8521
- return kept === void 0 ? deleteBlock(observation.meta[step.address])?.detail : `takeover never archives it: ${kept}`;
8522
- }
8523
9936
  const name = names.portalName(step.address);
8524
9937
  if (takeover && (observation.members[key]?.[name] ?? []).length === 0) return "it held no property when apply read it, and HubSpot marks no group as its own, so takeover never archives an empty group";
8525
9938
  const deleted = new Set(runOrder(plan).filter((s) => s.action === "delete" && kindOf$1(s.address) === "property" && objectOf(s.address) === key).map((s) => names.portalName(s.address)));
@@ -8528,24 +9941,32 @@ function deleteRefusal(plan, step, { owner, overrides }, observation) {
8528
9941
  deleted
8529
9942
  })?.detail;
8530
9943
  }
9944
+ function stageDeleteRefusal(plan, step, observation) {
9945
+ const order = runOrder(plan);
9946
+ const before = order.slice(0, order.indexOf(step));
9947
+ const prefix = step.address.slice(0, step.address.lastIndexOf("/") + 1);
9948
+ const gone = new Set(before.filter((s) => s.action === "delete" && s.address.startsWith(prefix)).map((s) => s.address.slice(prefix.length)));
9949
+ return stageDeleteRule(afterSteps(observation.resources, before), step.address, gone, bin)?.detail;
9950
+ }
8531
9951
  function uncheckedFields(step, owner) {
8532
9952
  const captured = CAPTURED[kindOf$1(step.address)];
8533
9953
  const needed = new Set(Object.keys(owner.base ?? {}).flatMap((unit) => {
8534
9954
  if (unit === "options" || unit === "optionsOrder") return ["options"];
8535
9955
  return captured.includes(unit) ? [unit] : [];
8536
9956
  }));
9957
+ if (kindOf$1(step.address) === "pipeline") needed.add("stages");
8537
9958
  const values = step.expect.values ?? {};
8538
9959
  const missing = [...needed].filter((field) => !Object.hasOwn(values, field)).sort(byCodeUnit);
8539
9960
  return step.expect.exists === true ? missing : ["exists", ...missing];
8540
9961
  }
8541
9962
  function recreates(step, observed, observation, portalName) {
8542
9963
  if (!step.labels?.includes("reverts-ui-edit") || observed !== void 0) return false;
8543
- if (kindOf$1(step.address) === "group") return true;
9964
+ if (kindOf$1(step.address) !== "property") return true;
8544
9965
  const archived = observation.archived[objectOf(step.address)];
8545
9966
  return archived !== void 0 && !archived.some((p) => p.name === portalName);
8546
9967
  }
8547
9968
  function archivedName(step, observation, portalName) {
8548
- if (kindOf$1(step.address) === "group") return false;
9969
+ if (kindOf$1(step.address) !== "property") return false;
8549
9970
  return observation?.archived[objectOf(step.address)]?.some((p) => p.name === portalName) === true;
8550
9971
  }
8551
9972
  function classesOf(step, trusted, observation) {
@@ -8616,16 +10037,24 @@ function checkNames(plan, config) {
8616
10037
  }
8617
10038
  function portalResource(address, names) {
8618
10039
  const kind = kindOf$1(address);
8619
- return `${kind}:${kind === "object" ? "" : `${names.objectType(objectOf(address))}/`}${names.portalName(address)}`;
10040
+ return `${kind}:${kind === "object" ? "" : `${names.objectType(objectOf(address))}/`}${kind === "stage" ? `${names.pipelineId(address)}/` : ""}${names.portalName(address)}`;
8620
10041
  }
8621
10042
  /**
8622
10043
  * The effect steps in the order apply runs them, from their actions and addresses alone, never the file's order:
8623
- * groups, then properties (a property may name a group the run creates), then releases, then property deletes, then
8624
- * group deletes, so a group is deleted only after the deletes of its properties. Plan order within each phase.
10044
+ * groups, then properties (a property may name a group the run creates), then pipeline creates, then the other stage
10045
+ * steps (those that close a stage first, since a ticket pipeline keeps a closed stage), then the other pipeline steps
10046
+ * (a stage order is written once the pipeline's new stages exist), then releases, then property deletes, then group
10047
+ * deletes, so a group is deleted only after the deletes of its properties, then stage deletes, then pipeline deletes.
10048
+ * Plan order within each phase.
8625
10049
  */
8626
10050
  function runOrder(plan) {
8627
- return formulasLast(plan.steps.filter(hasEffect).sort((a, b) => phase(a) - phase(b)));
10051
+ const known = ORDERED.get(plan.steps);
10052
+ if (known) return known;
10053
+ const order = formulasLast(plan.steps.filter(hasEffect).sort((a, b) => phase(a) - phase(b)));
10054
+ ORDERED.set(plan.steps, order);
10055
+ return order;
8628
10056
  }
10057
+ const ORDERED = /* @__PURE__ */ new WeakMap();
8629
10058
  const FORMULA_WORDS = /[A-Za-z0-9_]+/g;
8630
10059
  /**
8631
10060
  * A property step that writes a calculation formula runs after every other property step, and after each formula step
@@ -8653,8 +10082,16 @@ function formulasLast(steps) {
8653
10082
  }
8654
10083
  function phase(step) {
8655
10084
  const kind = kindOf$1(step.address);
8656
- if (step.action === "release") return 3;
8657
- if (step.action === "delete") return kind === "group" ? 5 : 4;
10085
+ if (step.action === "release") return 6;
10086
+ if (step.action === "delete") return {
10087
+ object: 7,
10088
+ property: 7,
10089
+ group: 8,
10090
+ stage: 9,
10091
+ pipeline: 10
10092
+ }[kind];
10093
+ if (kind === "pipeline") return step.action === "create" ? 3 : 5;
10094
+ if (kind === "stage") return closesStage(step.desired) ? 4 : 4.5;
8658
10095
  return {
8659
10096
  object: 0,
8660
10097
  group: 1,
@@ -8662,8 +10099,9 @@ function phase(step) {
8662
10099
  }[kind];
8663
10100
  }
8664
10101
  function disagreement(step) {
8665
- return (step.changes ?? []).flatMap((change) => {
10102
+ const changed = (step.changes ?? []).flatMap((change) => {
8666
10103
  if (change.unit === "options.order") return [];
10104
+ if (ORDERS.has(change.unit)) return orderDisagreement(step, change);
8667
10105
  const member = memberOf(change.unit);
8668
10106
  const desired = desiredValue(step.desired, change.unit);
8669
10107
  let fits;
@@ -8672,6 +10110,19 @@ function disagreement(step) {
8672
10110
  else fits = member?.field !== void 0 || fieldOf(change.unit) === change.unit ? sameValue(change.after, desired) : false;
8673
10111
  return fits ? [] : [`step ${step.id} changes ${sanitize(change.unit)} to a value its desired values do not hold`];
8674
10112
  });
10113
+ return [...carriedDisagreement(step), ...changed];
10114
+ }
10115
+ function orderDisagreement(step, change) {
10116
+ const desired = step.desired?.[change.unit] ?? [];
10117
+ const { after } = change;
10118
+ return Array.isArray(after) && stableStringify(desired.filter((id) => after.includes(id))) === stableStringify(after) ? [] : [`step ${step.id} writes a ${sanitize(change.unit)} order its desired values do not hold`];
10119
+ }
10120
+ function carriedDisagreement(step) {
10121
+ if (step.stages === void 0) return [];
10122
+ const prefix = `${step.address.replace("pipeline:", "stage:")}/`;
10123
+ const ids = step.stages.map((st) => st.address.slice(prefix.length));
10124
+ const desired = (step.desired?.stages ?? []).filter((id) => ids.includes(id));
10125
+ return step.action === "create" && kindOf$1(step.address) === "pipeline" && step.stages.every((st) => st.address.startsWith(prefix) && !st.address.slice(prefix.length).includes("/")) && stableStringify(desired) === stableStringify(ids) ? [] : [`step ${step.id} carries stages that are not its own pipeline's, in its order`];
8675
10126
  }
8676
10127
  function sameValue(a, b) {
8677
10128
  return b !== void 0 && stableStringify(a) === stableStringify(b);
@@ -8693,11 +10144,11 @@ function destination(message, fix) {
8693
10144
  function kindOf$1(address) {
8694
10145
  return parseAddress(address).type;
8695
10146
  }
8696
- function writes(step) {
10147
+ function writes(step, warned) {
8697
10148
  const changes = step.changes ?? [];
8698
10149
  const live = step.expect.values?.options ?? [];
8699
10150
  const label = (option, unit) => typeof option?.label === "string" ? `"${option.label}"` : String(memberOf(unit)?.value);
8700
- return writesTail(changes, (c) => c.op === "add" ? label(c.after, c.unit) : label(Array.isArray(live) ? live.find((o) => o.value === memberOf(c.unit)?.value) : void 0, c.unit));
10151
+ return writesTail(changes, (c) => c.op === "add" ? label(c.after, c.unit) : label(Array.isArray(live) ? live.find((o) => o.value === memberOf(c.unit)?.value) : void 0, c.unit), warned ? fieldWords : (unit) => unit === "stages" ? fieldWords(unit) : unit);
8701
10152
  }
8702
10153
  /** How long a read-back waits before it tells a person watching that it is still waiting, once per step. */
8703
10154
  const PROGRESS_MS = 3e3;
@@ -8874,12 +10325,16 @@ async function runSteps(run) {
8874
10325
  reports.set(step.id, done.report);
8875
10326
  run.issues.push(...done.issues ?? []);
8876
10327
  stop ||= done.stop === true;
8877
- if (done.entry !== void 0 && !sends) recorded.push([step, done.entry]);
8878
- else if (done.entry !== void 0 && !saveEntries(run, [[step, done.entry]])) stop = true;
10328
+ const entries = entriesOf(step, done);
10329
+ if (entries.length > 0 && !sends) recorded.push(...entries);
10330
+ else if (entries.length > 0 && !saveEntries(run, entries)) stop = true;
8879
10331
  }
8880
10332
  saveEntries(run, recorded);
8881
10333
  return reports;
8882
10334
  }
10335
+ function entriesOf(step, done) {
10336
+ return [...done.entry === void 0 ? [] : [[step.address, done.entry]], ...done.also ?? []];
10337
+ }
8883
10338
  function recordsOnly(step) {
8884
10339
  return step.action === "release" || (step.action === "adopt" || step.action === "update") && (step.changes ?? []).length === 0;
8885
10340
  }
@@ -8894,7 +10349,8 @@ function heldBack(run, step, reports) {
8894
10349
  async function runStep(run, step) {
8895
10350
  if (step.action === "release") return {
8896
10351
  report: report$1(step, "done"),
8897
- entry: null
10352
+ entry: null,
10353
+ also: stagesGone(run, step)
8898
10354
  };
8899
10355
  if (recordsOnly(step)) return record$2(run, step);
8900
10356
  return await write$1(run, step);
@@ -8931,6 +10387,7 @@ async function attemptWrite(run, step, tries) {
8931
10387
  const moved = before.present && before.resource === void 0 ? ["type"] : staleUnits(step, before.resource);
8932
10388
  if (moved.length > 0) return stale(run, step, moved);
8933
10389
  if (halted(run)) return stopped(run, step);
10390
+ if (kindOf(step.address) === "pipeline" && (step.changes ?? []).some((c) => c.unit === "stages")) return await reorderWrite(run, step, before, tries);
8934
10391
  const sent = await send(run, payload(run, step, before));
8935
10392
  if (sent.kind === "wait") return waited(run, step, sent, tries);
8936
10393
  if (sent.kind !== "rejected") return await settle(run, step, sent);
@@ -8997,7 +10454,7 @@ async function settle(run, step, sent) {
8997
10454
  function proven(step, seen, acknowledged, before) {
8998
10455
  if (step.action === "delete") return seen.archived === true;
8999
10456
  if (!(seen.present && seen.resource)) return false;
9000
- if (unverifiedUnits(step, seen.resource).length === 0) return true;
10457
+ if (unverifiedUnits(step, seen.resource).length === 0 && carriedUnverified(step, seen).length === 0) return true;
9001
10458
  if (!acknowledged) return false;
9002
10459
  if (step.action === "create" || before === void 0) return true;
9003
10460
  const fields = (step.changes ?? []).map((c) => fieldOf(c.unit));
@@ -9008,20 +10465,24 @@ function proven(step, seen, acknowledged, before) {
9008
10465
  function verified(run, step, seen) {
9009
10466
  if (step.action === "delete") return {
9010
10467
  report: report$1(step, "done"),
9011
- entry: null
10468
+ entry: null,
10469
+ also: stagesGone(run, step)
9012
10470
  };
9013
10471
  const readBack = seen.resource;
9014
- const bad = unverifiedUnits(step, readBack);
10472
+ const own = unverifiedUnits(step, readBack);
10473
+ const bad = [...own, ...carriedUnverified(step, seen)];
9015
10474
  if (step.action === "create") run.created.add(step.address);
9016
10475
  const { rewrites: before, ...entry } = entryOf$1(run, step, verifiedBase(run, step, readBack));
9017
- const rewrites = rewritesAfter(before, writtenUnits(step, readBack), bad);
10476
+ const rewrites = rewritesAfter(before, writtenUnits(step, readBack), own);
9018
10477
  const saved = rewrites === void 0 ? entry : {
9019
10478
  ...entry,
9020
10479
  rewrites
9021
10480
  };
10481
+ const also = carriedEntries(step, seen);
9022
10482
  if (bad.length === 0) return {
9023
10483
  report: report$1(step, "done"),
9024
- entry: saved
10484
+ entry: saved,
10485
+ also
9025
10486
  };
9026
10487
  const units = bad.map((u) => u.unit);
9027
10488
  const stored = bad.map((u) => `${u.unit} as ${show$1$1(u.observed)}, not ${show$1$1(u.desired)} as sent`).join("; ");
@@ -9036,9 +10497,41 @@ function verified(run, step, seen) {
9036
10497
  issue: "W_UNVERIFIED"
9037
10498
  }),
9038
10499
  entry: saved,
10500
+ also,
9039
10501
  issues: [issue]
9040
10502
  };
9041
10503
  }
10504
+ function carriedEntries(step, seen) {
10505
+ return (step.stages ?? []).flatMap((st) => {
10506
+ const live = seen.stages?.[st.address];
10507
+ if (live === void 0) return [];
10508
+ const units = classify(void 0, specOf(st.desired), capturedSpec(live), { options: "additive" });
10509
+ const base = advanceBase(void 0, specOf(st.desired), capturedSpec(live));
10510
+ const rewrites = rewritesAfter(void 0, units.map((u) => u.unit), units.filter((u) => u.class !== "converged"));
10511
+ const entry = {
10512
+ origin: "created",
10513
+ id: ownId(st.address),
10514
+ normVersion: NORM_VERSIONS.stage,
10515
+ ...base === void 0 ? {} : { base },
10516
+ ...rewrites === void 0 ? {} : { rewrites }
10517
+ };
10518
+ return [[st.address, entry]];
10519
+ });
10520
+ }
10521
+ function carriedUnverified(step, seen) {
10522
+ return (step.stages ?? []).flatMap((st) => {
10523
+ const live = seen.stages?.[st.address];
10524
+ if (live === void 0) return [{
10525
+ unit: `${st.address}`,
10526
+ class: "diverged",
10527
+ desired: st.desired
10528
+ }];
10529
+ return classify(void 0, specOf(st.desired), capturedSpec(live), { options: "additive" }).filter((u) => u.class !== "converged").map((u) => ({
10530
+ ...u,
10531
+ unit: `${ownId(st.address)}.${u.unit}`
10532
+ }));
10533
+ });
10534
+ }
9042
10535
  function verifiedBase(run, step, readBack) {
9043
10536
  const live = capturedSpec(readBack);
9044
10537
  if (step.action === "create") {
@@ -9146,12 +10639,38 @@ function refusal(run, step, sent, plan, otherwise) {
9146
10639
  why: "HubSpot refuses to archive a group that still holds properties",
9147
10640
  fix: `run ${plan}: it names the properties the group holds`
9148
10641
  };
10642
+ const pipelineRefused = pipelineRefusal(step, sent, plan);
10643
+ if (pipelineRefused !== void 0) return pipelineRefused;
9149
10644
  if (reason === "PROPERTY_WITH_NAME_EXISTS") return {
9150
10645
  why: `HubSpot refuses the create because a property named ${portalName(run, step)} already exists`,
9151
10646
  fix: `run ${plan}: it reads the portal again`
9152
10647
  };
9153
10648
  return otherwise;
9154
10649
  }
10650
+ function pipelineRefusal(step, sent, plan) {
10651
+ const reason = reasonOf(sent);
10652
+ if (reason === "STAGE_ID_IN_USE") {
10653
+ const stages = sent.context?.stageIds?.[0];
10654
+ const records = sent.context?.objectIds?.[0];
10655
+ const held = [stages && `stages ${stages}`, records && `records ${records}`].filter(Boolean).join(", ");
10656
+ return {
10657
+ why: `HubSpot never deletes a stage a record sits in${held ? ` (${held})` : ""}`,
10658
+ fix: `move those records to another stage in HubSpot, or delete them, then run ${plan}`
10659
+ };
10660
+ }
10661
+ if (reason === "MISSING_CLOSED_STAGE") return {
10662
+ why: "HubSpot keeps a closed stage in every ticket pipeline",
10663
+ fix: `mark another stage ticketState: 'CLOSED' first, then run ${plan}`
10664
+ };
10665
+ if (reason === "STAGE_LABEL_EXISTS") return {
10666
+ why: "another stage of the pipeline has that label, ignoring case",
10667
+ fix: `run ${plan}: it reads the portal again`
10668
+ };
10669
+ return (reason === "STAGE_ID_EXISTS_IN_ANOTHER_PIPELINE" || sent.message.includes("already using stage id") || sent.message.includes("There's another pipeline")) && (kindOf(step.address) === "pipeline" || kindOf(step.address) === "stage") ? {
10670
+ why: "HubSpot refuses the ID because another pipeline or stage holds it; deals and tickets share pipeline IDs",
10671
+ fix: `give the pipeline or stage another ID in config, then run ${plan}`
10672
+ } : void 0;
10673
+ }
9155
10674
  function usesOf(sent) {
9156
10675
  const uses = (sent.errors ?? []).filter((e) => e.subCategory?.endsWith("PROPERTY_USAGE")).map((e) => {
9157
10676
  return sanitize(`${(e.context?.parentDisplayType?.[0] ?? e.context?.parentType?.[0] ?? "use").toLowerCase().replaceAll("_", " ")} ${e.context?.parentName?.[0] ?? ""}`.trim(), USE_MAX);
@@ -9200,6 +10719,7 @@ async function find(run, step, archived) {
9200
10719
  const key = objectOf(step.address);
9201
10720
  const objectType = run.names.objectType(key);
9202
10721
  const name = portalName(run, step);
10722
+ if (kind === "pipeline" || kind === "stage") return await findPipeline(run, step, archived);
9203
10723
  if (kind === "group") {
9204
10724
  const group = (await read$1(run, {
9205
10725
  type: "group",
@@ -9253,10 +10773,51 @@ async function find(run, step, archived) {
9253
10773
  raw
9254
10774
  };
9255
10775
  }
10776
+ async function findPipeline(run, step, archived) {
10777
+ const key = objectOf(step.address);
10778
+ const pipeline = kindOf(step.address) === "stage" ? pipelineOf(step.address) : step.address;
10779
+ let raw;
10780
+ try {
10781
+ raw = await read$1(run, {
10782
+ type: "pipeline",
10783
+ path: "read",
10784
+ params: {
10785
+ objectType: run.names.objectType(key),
10786
+ pipelineId: run.names.pipelineId(step.address)
10787
+ }
10788
+ });
10789
+ } catch (error) {
10790
+ if (error instanceof HubSpotApiError && error.status === 404) return archived ? {
10791
+ present: false,
10792
+ archived: true
10793
+ } : { present: false };
10794
+ throw error;
10795
+ }
10796
+ const [live] = localPipelines(key, normalizePipelines(key, [raw], !STANDARD_OBJECTS.has(key)), run.names);
10797
+ const resources = Object.fromEntries(pipelineResources(live ? [{
10798
+ ...live,
10799
+ id: pipeline.slice(pipeline.lastIndexOf("/") + 1)
10800
+ }] : [], pipeline));
10801
+ const resource = resources[step.address];
10802
+ if (archived) return {
10803
+ present: resource !== void 0,
10804
+ archived: resource === void 0
10805
+ };
10806
+ const stages = Object.fromEntries(Object.entries(resources).filter(([address]) => address !== pipeline));
10807
+ return resource ? {
10808
+ present: true,
10809
+ raw,
10810
+ resource,
10811
+ stages
10812
+ } : {
10813
+ present: false,
10814
+ raw
10815
+ };
10816
+ }
9256
10817
  async function appeared(run, step) {
9257
10818
  try {
9258
10819
  if ((await find(run, step, false)).present) return true;
9259
- if (kindOf(step.address) === "group") return false;
10820
+ if (kindOf(step.address) !== "property") return false;
9260
10821
  const objectType = run.names.objectType(objectOf(step.address));
9261
10822
  const name = portalName(run, step);
9262
10823
  for (const dataSensitivity of [
@@ -9277,43 +10838,175 @@ async function appeared(run, step) {
9277
10838
  if (error instanceof Stopped || error instanceof KalupError) return;
9278
10839
  throw error;
9279
10840
  }
9280
- }
9281
- function payload(run, step, before) {
9282
- const kind = kindOf(step.address);
9283
- const objectType = run.names.objectType(objectOf(step.address));
9284
- const name = portalName(run, step);
10841
+ }
10842
+ function payload(run, step, before) {
10843
+ const kind = kindOf(step.address);
10844
+ const objectType = run.names.objectType(objectOf(step.address));
10845
+ const name = portalName(run, step);
10846
+ if (kind === "pipeline" || kind === "stage") return pipelinePayload(run, step, before, objectType);
10847
+ if (step.action === "create") {
10848
+ const group = (step.desired?.group)?.$ref;
10849
+ const body = createBody(step, {
10850
+ name,
10851
+ ...group === void 0 ? {} : { group: run.names.portalName(group) }
10852
+ });
10853
+ return {
10854
+ type: kind,
10855
+ path: "create",
10856
+ params: { objectType },
10857
+ body
10858
+ };
10859
+ }
10860
+ if (step.action === "delete") return {
10861
+ type: kind,
10862
+ path: "delete",
10863
+ params: {
10864
+ objectType,
10865
+ name
10866
+ }
10867
+ };
10868
+ const body = kind === "group" ? groupPatch(step.changes ?? []) : propertyPatch(step, before.raw, (ref) => run.names.portalName(ref));
10869
+ return {
10870
+ type: kind,
10871
+ path: "update",
10872
+ params: {
10873
+ objectType,
10874
+ name
10875
+ },
10876
+ body
10877
+ };
10878
+ }
10879
+ function pipelinePayload(run, step, before, objectType) {
10880
+ const pipelineId = run.names.pipelineId(step.address);
10881
+ if (kindOf(step.address) === "pipeline") {
10882
+ if (step.action === "create") return {
10883
+ type: "pipeline",
10884
+ path: "create",
10885
+ params: { objectType },
10886
+ body: pipelineCreateBody(step, pipelineId)
10887
+ };
10888
+ if (step.action === "delete") return {
10889
+ type: "pipeline",
10890
+ path: "delete",
10891
+ params: {
10892
+ objectType,
10893
+ pipelineId
10894
+ }
10895
+ };
10896
+ const body = pipelinePatch(step.changes ?? []);
10897
+ return {
10898
+ type: "pipeline",
10899
+ path: "update",
10900
+ params: {
10901
+ objectType,
10902
+ pipelineId
10903
+ },
10904
+ body
10905
+ };
10906
+ }
10907
+ const stageId = portalName(run, step);
9285
10908
  if (step.action === "create") {
9286
- const group = (step.desired?.group)?.$ref;
9287
- const body = createBody(step, {
9288
- name,
9289
- ...group === void 0 ? {} : { group: run.names.portalName(group) }
9290
- });
10909
+ const raw = before.raw;
10910
+ const highest = Math.max(-1, ...(raw?.stages ?? []).map((st) => st.displayOrder));
10911
+ const body = stageCreateBody(step.desired ?? {}, stageId, highest + 1);
9291
10912
  return {
9292
- type: kind,
10913
+ type: "stage",
9293
10914
  path: "create",
9294
- params: { objectType },
10915
+ params: {
10916
+ objectType,
10917
+ pipelineId
10918
+ },
9295
10919
  body
9296
10920
  };
9297
10921
  }
9298
10922
  if (step.action === "delete") return {
9299
- type: kind,
10923
+ type: "stage",
9300
10924
  path: "delete",
9301
10925
  params: {
9302
10926
  objectType,
9303
- name
10927
+ pipelineId,
10928
+ stageId
9304
10929
  }
9305
10930
  };
9306
- const body = kind === "group" ? groupPatch(step.changes ?? []) : propertyPatch(step, before.raw, (ref) => run.names.portalName(ref));
9307
10931
  return {
9308
- type: kind,
10932
+ type: "stage",
9309
10933
  path: "update",
9310
10934
  params: {
9311
10935
  objectType,
9312
- name
10936
+ pipelineId,
10937
+ stageId
9313
10938
  },
9314
- body
10939
+ body: stagePatch(step.changes ?? [])
9315
10940
  };
9316
10941
  }
10942
+ /**
10943
+ * A pipeline step that writes the stage order: its label and displayOrder first, in one PATCH, then the stage moves.
10944
+ * HubSpot never stores two stages at one displayOrder: a write to a taken slot places the stage right after the stage
10945
+ * that held it and renumbers the pipeline 0..n-1 (observed 2026-10-05). So with D the order the step leaves
10946
+ * (stageOrder), each stage that is not right after its predecessor in D is moved onto that predecessor's slot, one
10947
+ * PATCH each, sent once, with a read of the pipeline before each move. The one write of several requests: a pipeline
10948
+ * PUT would drop any stage it does not name. Nothing follows a request whose outcome is uncertain: the step settles on
10949
+ * it. A wait counts against the step's `tries`; once any request of the step has landed, the retry's read no longer
10950
+ * meets the step's expect (the label or the stage order moved), so the step stops stale and the report says to plan
10951
+ * again, which shows what is left.
10952
+ */
10953
+ async function reorderWrite(run, step, before, tries) {
10954
+ const objectType = run.names.objectType(objectOf(step.address));
10955
+ const pipelineId = run.names.pipelineId(step.address);
10956
+ const fields = pipelinePatch(step.changes ?? []);
10957
+ let last = {
10958
+ kind: "ok",
10959
+ status: 200,
10960
+ body: before.raw
10961
+ };
10962
+ if (Object.keys(fields).length > 0) {
10963
+ const sent = await send(run, {
10964
+ type: "pipeline",
10965
+ path: "update",
10966
+ params: {
10967
+ objectType,
10968
+ pipelineId
10969
+ },
10970
+ body: fields
10971
+ });
10972
+ if (sent.kind !== "ok") return await reorderStopped(run, step, sent, tries);
10973
+ last = sent;
10974
+ }
10975
+ const approved = step.changes?.find((c) => c.unit === "stages")?.after ?? [];
10976
+ let current = before;
10977
+ for (let move = 0;; move += 1) {
10978
+ const live = (current.resource?.definition?.stages ?? []).slice();
10979
+ const target = stageOrder(live, approved);
10980
+ const at = target.findIndex((id, i) => i > 0 && live.indexOf(id) !== live.indexOf(target[i - 1]) + 1);
10981
+ if (at === -1 || move > live.length || halted(run)) break;
10982
+ const raw = current.raw;
10983
+ const portal = (id) => run.names.portalName(`${step.address.replace("pipeline:", "stage:")}/${id}`);
10984
+ const slot = raw.stages.find((st) => st.id === portal(target[at - 1]))?.displayOrder;
10985
+ const sent = await send(run, {
10986
+ type: "stage",
10987
+ path: "update",
10988
+ params: {
10989
+ objectType,
10990
+ pipelineId,
10991
+ stageId: portal(target[at])
10992
+ },
10993
+ body: { displayOrder: slot }
10994
+ });
10995
+ if (sent.kind !== "ok") return await reorderStopped(run, step, sent, tries);
10996
+ last = sent;
10997
+ try {
10998
+ current = await find(run, step, false);
10999
+ } catch (error) {
11000
+ return failedRead(run, step, error);
11001
+ }
11002
+ }
11003
+ return await settle(run, step, last);
11004
+ }
11005
+ async function reorderStopped(run, step, sent, tries) {
11006
+ if (sent.kind === "rejected") return rejected(run, step, sent);
11007
+ if (sent.kind === "wait") return waited(run, step, sent, tries);
11008
+ return await settle(run, step, sent);
11009
+ }
9317
11010
  function dependent(run, step) {
9318
11011
  return refsOf(step).some((ref) => run.created.has(ref));
9319
11012
  }
@@ -9349,13 +11042,18 @@ function entryOf$1(run, step, base) {
9349
11042
  if (base !== void 0) entry.base = base;
9350
11043
  return entry;
9351
11044
  }
11045
+ function stagesGone(run, step) {
11046
+ if (kindOf(step.address) !== "pipeline") return [];
11047
+ const prefix = `${step.address.replace("pipeline:", "stage:")}/`;
11048
+ return Object.keys(run.state.resources).filter((address) => address.startsWith(prefix)).map((address) => [address, null]);
11049
+ }
9352
11050
  function saveEntries(run, entries) {
9353
11051
  if (entries.length === 0) return true;
9354
11052
  try {
9355
11053
  const changed = save(run, (s) => {
9356
11054
  const resources = { ...s.resources };
9357
- for (const [step, entry] of entries) if (entry === null) delete resources[step.address];
9358
- else resources[step.address] = entry;
11055
+ for (const [address, entry] of entries) if (entry === null) delete resources[address];
11056
+ else resources[address] = entry;
9359
11057
  return {
9360
11058
  ...s,
9361
11059
  resources
@@ -9652,7 +11350,8 @@ function covers(written, unit) {
9652
11350
  return unit === written || unit.startsWith(`${written}.`) || unit.startsWith(`${written}[`);
9653
11351
  }
9654
11352
  function names(body, name) {
9655
- return typeof body === "object" && body !== null && body.name === name;
11353
+ const named = body;
11354
+ return typeof body === "object" && named !== null && (named.name === name || named.id === name);
9656
11355
  }
9657
11356
  function portalName(run, step) {
9658
11357
  return run.names.portalName(step.address);
@@ -10069,7 +11768,7 @@ const OVERRIDE_COLUMNS = [
10069
11768
  "Target",
10070
11769
  "Value"
10071
11770
  ];
10072
- const OVERRIDE_FIELDS = OVERRIDABLE.property;
11771
+ const OVERRIDE_FIELDS = [...OVERRIDABLE.property, ...OVERRIDABLE.stage.filter((f) => !OVERRIDABLE.property.includes(f))];
10073
11772
  const LIFECYCLE_FIELDS = [
10074
11773
  "options",
10075
11774
  "removedOptions",
@@ -10110,6 +11809,19 @@ const OPTION_COLUMNS = {
10110
11809
  "Hidden"
10111
11810
  ]
10112
11811
  };
11812
+ const STAGE_COLUMNS = {
11813
+ config: [
11814
+ "Stage ID",
11815
+ "Key",
11816
+ "Label",
11817
+ "Closes"
11818
+ ],
11819
+ snapshot: [
11820
+ "Stage ID",
11821
+ "Label",
11822
+ "Closes"
11823
+ ]
11824
+ };
10113
11825
  /**
10114
11826
  * The page for a config IR, or for a snapshot with the coverage of its read. A config definition lists only the fields
10115
11827
  * it states; a snapshot fills HubSpot's defaults for what the portal left out.
@@ -10182,6 +11894,11 @@ function coverageLines(coverage) {
10182
11894
  objects.flatMap(([k, o]) => (o.unaddressable ?? []).map((name) => `${md(name)} on ${md(k)}`)),
10183
11895
  ", "
10184
11896
  ],
11897
+ [
11898
+ "Pipelines not read",
11899
+ objects.filter(([, o]) => o.pipelines?.status === "unreadable").map(([k, o]) => o.pipelines?.missingScope === void 0 ? md(k) : `${md(k)} (missing scope ${md(o.pipelines.missingScope)})`),
11900
+ ", "
11901
+ ],
10185
11902
  [
10186
11903
  "Shadowed by name overrides, not captured",
10187
11904
  objects.flatMap(([k, o]) => o.shadowed ? [`${plural(o.shadowed.length, "name")} on ${md(k)}`] : []),
@@ -10193,8 +11910,13 @@ function coverageLines(coverage) {
10193
11910
  const fields = [
10194
11911
  "property",
10195
11912
  "group",
10196
- "object"
10197
- ].map((type) => `${type}: ${[...notCaptured[type]].sort(byCodeUnit).map(md).join(", ")}`).join("; ");
11913
+ "object",
11914
+ "pipeline",
11915
+ "stage"
11916
+ ].flatMap((type) => {
11917
+ const listed = notCaptured[type];
11918
+ return listed === void 0 ? [] : [`${type}: ${[...listed].sort(byCodeUnit).map(md).join(", ")}`];
11919
+ }).join("; ");
10198
11920
  return [
10199
11921
  coverage.complete ? COMPLETE : INCOMPLETE,
10200
11922
  "",
@@ -10211,17 +11933,22 @@ function byObject(resources) {
10211
11933
  const key = objectOf(address);
10212
11934
  const entry = objects.get(key) ?? {
10213
11935
  groups: [],
10214
- properties: []
11936
+ properties: [],
11937
+ pipelines: [],
11938
+ stages: /* @__PURE__ */ new Map()
10215
11939
  };
10216
11940
  objects.set(key, entry);
10217
11941
  const resource = resources[address];
10218
11942
  if (type === "object") entry.object = resource;
10219
11943
  else if (type === "group") entry.groups.push([nameOf(address), resource]);
10220
11944
  else if (type === "property") entry.properties.push([nameOf(address), resource]);
11945
+ else if (type === "pipeline") entry.pipelines.push([nameOf(address), resource]);
11946
+ else if (type === "stage") entry.stages.set(address, resource);
10221
11947
  }
10222
11948
  return [...objects].sort(([a], [b]) => byCodeUnit(a, b));
10223
11949
  }
10224
- function objectLines(key, { object, groups, properties }, config) {
11950
+ function objectLines(key, resources, config) {
11951
+ const { object, groups, properties } = resources;
10225
11952
  const lines = [`## ${escapeMarkdown(key)}`];
10226
11953
  if (object?.definition) lines.push("", ...schemaLines(object.definition));
10227
11954
  if (groups.length > 0) {
@@ -10240,8 +11967,33 @@ function objectLines(key, { object, groups, properties }, config) {
10240
11967
  lines.push("", `#### Options of ${escapeMarkdown(name)}`, "", ...table(columns, rows));
10241
11968
  }
10242
11969
  }
11970
+ for (const [id, p] of resources.pipelines) lines.push("", ...pipelineLines(key, id, p, resources.stages, config));
10243
11971
  return lines;
10244
11972
  }
11973
+ function pipelineLines(key, id, p, stages, config) {
11974
+ const d = p.definition ?? {};
11975
+ const rows = (d.stages ?? []).flatMap((stage) => {
11976
+ const s = stages.get(`stage:${key}/${id}/${stage}`);
11977
+ if (!s) return [];
11978
+ return [[
11979
+ escapeMarkdown(stage),
11980
+ ...config ? [escapeMarkdown(s.binding?.key ?? "")] : [],
11981
+ escapeMarkdown(stringOf(s.definition?.label)),
11982
+ closes(s.definition ?? {})
11983
+ ]];
11984
+ });
11985
+ return [
11986
+ `### Pipeline ${escapeMarkdown(stringOf(d.label))} (${escapeMarkdown(id)})`,
11987
+ "",
11988
+ `- Display order: ${typeof d.displayOrder === "number" ? d.displayOrder : ""}`,
11989
+ ...rows.length > 0 ? ["", ...table(STAGE_COLUMNS[config ? "config" : "snapshot"], rows)] : []
11990
+ ];
11991
+ }
11992
+ function closes(d) {
11993
+ if (typeof d.probability === "number") return `probability ${d.probability}`;
11994
+ const state = d.ticketState ?? d.state;
11995
+ return typeof state === "string" ? escapeMarkdown(state) : "";
11996
+ }
10245
11997
  function schemaLines(definition) {
10246
11998
  const labels = definition.labels ?? {};
10247
11999
  const names = (value, sort) => {
@@ -10552,7 +12304,8 @@ const scopeKeys = every()([
10552
12304
  "include",
10553
12305
  "exclude",
10554
12306
  "custom",
10555
- "as"
12307
+ "as",
12308
+ "pipelines"
10556
12309
  ]);
10557
12310
  const targetKeys = every()([
10558
12311
  "portalId",
@@ -10592,6 +12345,12 @@ const definitionKeys = every()([
10592
12345
  "dataSensitivity",
10593
12346
  "lifecycle"
10594
12347
  ]);
12348
+ const overrideDefinitionKeys = every()([
12349
+ ...definitionKeys,
12350
+ "probability",
12351
+ "ticketState",
12352
+ "state"
12353
+ ]);
10595
12354
  const optionKeys = every()([
10596
12355
  "value",
10597
12356
  "label",
@@ -10599,6 +12358,13 @@ const optionKeys = every()([
10599
12358
  "hidden",
10600
12359
  "description"
10601
12360
  ]);
12361
+ const stageKeys = every()([
12362
+ "id",
12363
+ "label",
12364
+ "probability",
12365
+ "ticketState",
12366
+ "state"
12367
+ ]);
10602
12368
  const lifecycleKeys = every()([
10603
12369
  "options",
10604
12370
  "removedOptions",
@@ -10629,6 +12395,7 @@ function escapeString(s, quote = "'") {
10629
12395
  }
10630
12396
  function write(kind, data) {
10631
12397
  if (kind === "object") return writeObjectFile(data);
12398
+ if (kind === "pipeline") return writePipelineFile(data);
10632
12399
  if (kind === "config") return writeConfigFile(data);
10633
12400
  if (kind === "removed") return writeRemovedFile(data);
10634
12401
  return writeBarrel(data);
@@ -10765,9 +12532,31 @@ function writeObjectFile(f) {
10765
12532
  });
10766
12533
  return `${out.join("\n")}\n`;
10767
12534
  }
12535
+ function writePipelineFile(f) {
12536
+ const out = [
12537
+ ...header(f.header),
12538
+ "import { definePipeline } from '@kalup/core'",
12539
+ ...f.imports,
12540
+ ""
12541
+ ];
12542
+ f.exports.forEach((e, i) => {
12543
+ const body = [
12544
+ ...wrap("id: ", e.id, ",", " "),
12545
+ ...wrap("label: ", e.label, ",", " "),
12546
+ ` displayOrder: ${e.displayOrder},`,
12547
+ ...block(" stages: ", e.stages.flatMap((st) => [...comment(st.comments, " "), ...wrap(`${key(st.key)}: `, pick$1(st, stageKeys), ",", " ")]), " ", ",")
12548
+ ];
12549
+ if (i) out.push("");
12550
+ out.push(...comment(e.comments, ""), ...block(`export const ${e.name} = definePipeline(${q(e.object)}, `, body, "", ")"));
12551
+ });
12552
+ return `${out.join("\n")}\n`;
12553
+ }
10768
12554
  function override(o) {
10769
12555
  const out = pick$1(o, overrideKeys);
10770
- if (o.definition) out.definition = canon(o.definition);
12556
+ if (o.definition) out.definition = {
12557
+ ...canon(o.definition),
12558
+ ...pick$1(o.definition, overrideDefinitionKeys.slice(definitionKeys.length))
12559
+ };
10771
12560
  return out;
10772
12561
  }
10773
12562
  function target(t) {
@@ -10823,9 +12612,11 @@ function writeRemovedFile(r) {
10823
12612
  function writeBarrel(entries) {
10824
12613
  const byFrom = /* @__PURE__ */ new Map();
10825
12614
  for (const e of [...entries].sort((a, b) => cmp$1(a.from, b.from) || cmp$1(a.name, b.name))) byFrom.set(e.from, [...byFrom.get(e.from) ?? [], e.name]);
12615
+ const pipelines = new Set(entries.filter((e) => e.pipeline).map((e) => `${e.from}\0${e.name}`));
10826
12616
  const out = [];
10827
12617
  for (const [from, names] of byFrom) {
10828
- out.push(`export type { ${names.map((n) => `${n}Data`).join(", ")} } from ${q(`${from}.js`)}`);
12618
+ const typed = names.filter((n) => !pipelines.has(`${from}\0${n}`));
12619
+ if (typed.length) out.push(`export type { ${typed.map((n) => `${n}Data`).join(", ")} } from ${q(`${from}.js`)}`);
10829
12620
  out.push(`export { ${names.join(", ")} } from ${q(`${from}.js`)}`);
10830
12621
  }
10831
12622
  return `${out.length ? out.join("\n") : "export {}"}\n`;
@@ -11849,7 +13640,7 @@ function createReport() {
11849
13640
  };
11850
13641
  const changes = [];
11851
13642
  const note = (change) => {
11852
- changes.push(scrub$2(change));
13643
+ changes.push(scrub$1(change));
11853
13644
  };
11854
13645
  return {
11855
13646
  counts,
@@ -12219,7 +14010,7 @@ function resolveUnits(address, mine, merged, fields, r) {
12219
14010
  const member = MEMBER.exec(u.unit);
12220
14011
  if (u.unit === "options.order") resolveOrder(at, u);
12221
14012
  else if (member) resolveMember(at, u, member[1], member[2]);
12222
- else if (u.unit !== "type" && keepsFile(at, u)) {
14013
+ else if (u.unit !== "type" && keepsFile$1(at, u)) {
12223
14014
  noteUnit(at, u, mine[u.unit], merged[u.unit]);
12224
14015
  drop(at, "changed", u.unit);
12225
14016
  put$1(merged, u.unit, mine[u.unit]);
@@ -12228,7 +14019,7 @@ function resolveUnits(address, mine, merged, fields, r) {
12228
14019
  if (merged.options?.length === 0 && mine.options === void 0) merged.options = void 0;
12229
14020
  compactInPlace(merged);
12230
14021
  }
12231
- function keepsFile(at, u) {
14022
+ function keepsFile$1(at, u) {
12232
14023
  return (u.class === "config-change" || u.class === "conflict") && !at.r.accept(u.unit);
12233
14024
  }
12234
14025
  function noteUnit(at, u, before, after) {
@@ -12246,8 +14037,8 @@ function drop(at, kind, field) {
12246
14037
  }
12247
14038
  function resolveOrder(at, u) {
12248
14039
  const { merged, mine } = at;
12249
- if (merged.options && keepsFile(at, u)) {
12250
- const ordered = inFileOrder(mine.options, merged.options);
14040
+ if (merged.options && keepsFile$1(at, u)) {
14041
+ const ordered = inFileOrder$1(mine.options, merged.options);
12251
14042
  noteUnit(at, u, commonValues(ordered, mine.options), commonValues(merged.options, mine.options));
12252
14043
  merged.options = ordered;
12253
14044
  }
@@ -12260,7 +14051,7 @@ function resolveMember(at, u, value, field) {
12260
14051
  merged.options = merged.options?.filter((o) => o.value !== value);
12261
14052
  };
12262
14053
  if (field !== void 0) {
12263
- if (theirs && ours && keepsFile(at, u)) {
14054
+ if (theirs && ours && keepsFile$1(at, u)) {
12264
14055
  noteUnit(at, u, ours[field], theirs[field]);
12265
14056
  drop(at, "changed", u.unit);
12266
14057
  put$1(theirs, field, ours[field]);
@@ -12313,7 +14104,7 @@ function resolveMember(at, u, value, field) {
12313
14104
  });
12314
14105
  }
12315
14106
  }
12316
- function inFileOrder(mine, merged) {
14107
+ function inFileOrder$1(mine, merged) {
12317
14108
  const inFile = new Set((mine ?? []).map((o) => o.value));
12318
14109
  const byValue = new Map(merged.filter((o) => inFile.has(o.value)).map((o) => [o.value, o]));
12319
14110
  const queue = (mine ?? []).flatMap((o) => {
@@ -12441,7 +14232,7 @@ function cmp(a, b) {
12441
14232
  if (a < b) return -1;
12442
14233
  return a > b ? 1 : 0;
12443
14234
  }
12444
- function scrub$2(change) {
14235
+ function scrub$1(change) {
12445
14236
  return compact({
12446
14237
  ...change,
12447
14238
  address: sanitize(change.address),
@@ -12551,6 +14342,59 @@ function targetOnly(file, overrides) {
12551
14342
  };
12552
14343
  };
12553
14344
  }
14345
+ /** A pipeline export as target T sees it: the pipeline's and each stage's overridden fields in place of the file's. */
14346
+ function pipelineAsTarget(e, overrides) {
14347
+ const at = `${e.object}/${e.id}`;
14348
+ const d = definitionOf(overrides, `pipeline:${at}`);
14349
+ return {
14350
+ ...e,
14351
+ ...d ? pickFields(d, OVERRIDABLE.pipeline) : {},
14352
+ stages: e.stages.map((st) => {
14353
+ const sd = definitionOf(overrides, `stage:${at}/${st.id}`);
14354
+ return sd ? {
14355
+ ...st,
14356
+ ...pickFields(sd, OVERRIDABLE.stage)
14357
+ } : st;
14358
+ })
14359
+ };
14360
+ }
14361
+ /**
14362
+ * Splits a pipeline export merged from pipelineAsTarget's view against `file`, the export as written: each field T
14363
+ * overrides takes the merged value into T's override and the file's own value back.
14364
+ */
14365
+ function pipelineFromTarget(merged, file, overrides) {
14366
+ const changed = {};
14367
+ const split = (address, fields, value, mine) => {
14368
+ const override = own(overrides, address);
14369
+ const d = definitionOf(overrides, address);
14370
+ if (!(override && d && mine)) return value;
14371
+ const taken = fields.filter((f) => d[f] !== void 0);
14372
+ const definition = { ...d };
14373
+ const out = { ...value };
14374
+ for (const field of taken) {
14375
+ definition[field] = value[field];
14376
+ out[field] = mine[field];
14377
+ }
14378
+ if (stableStringify(definition) !== stableStringify(d)) changed[address] = {
14379
+ ...override,
14380
+ definition
14381
+ };
14382
+ return out;
14383
+ };
14384
+ const at = `${merged.object}/${merged.id}`;
14385
+ const pipeline = split(`pipeline:${at}`, OVERRIDABLE.pipeline, merged, file);
14386
+ const stages = merged.stages.map((st) => split(`stage:${at}/${st.id}`, OVERRIDABLE.stage, st, file.stages.find((f) => f.id === st.id)));
14387
+ return {
14388
+ export: {
14389
+ ...pipeline,
14390
+ stages
14391
+ },
14392
+ overrides: changed
14393
+ };
14394
+ }
14395
+ function pickFields(d, fields) {
14396
+ return Object.fromEntries(fields.filter((f) => d[f] !== void 0).map((f) => [f, d[f]]));
14397
+ }
12554
14398
  function definitionOf(overrides, address) {
12555
14399
  const override = own(overrides, address);
12556
14400
  return override?.skip === true ? void 0 : override?.definition;
@@ -12569,6 +14413,297 @@ function pick$4(d, fields) {
12569
14413
  function own(record, key) {
12570
14414
  return Object.hasOwn(record, key) ? record[key] : void 0;
12571
14415
  }
14416
+ const SLUG = /^[a-z][a-z0-9_]*$/;
14417
+ const NOT_ALNUM = /[^A-Za-z0-9]+/;
14418
+ const LETTER = /[A-Za-z]/;
14419
+ const DIGIT_FIRST = /^[0-9]/;
14420
+ function mergePipelines(input) {
14421
+ const { live, local, object, only, removed } = input;
14422
+ const out = {
14423
+ changes: [],
14424
+ counts: {
14425
+ added: 0,
14426
+ changed: 0,
14427
+ unchanged: 0,
14428
+ missing: 0
14429
+ },
14430
+ fresh: [],
14431
+ merged: /* @__PURE__ */ new Map()
14432
+ };
14433
+ const note = (change) => out.changes.push(scrub$3(change));
14434
+ const settle = (fields) => {
14435
+ if (fields.some((c) => c.kind === "changed" || c.kind === "added")) out.counts.changed += 1;
14436
+ else out.counts.unchanged += 1;
14437
+ fields.forEach(note);
14438
+ };
14439
+ const ours = new Set(local.map((p) => p.id));
14440
+ for (const p of local) out.merged.set(p.name, mergeOne$1(input, p, {
14441
+ note,
14442
+ settle,
14443
+ counts: out.counts
14444
+ }));
14445
+ for (const l of live) {
14446
+ const address = `pipeline:${object}/${l.id}`;
14447
+ if (!input.all || ours.has(l.id) || !(only(address) || l.stages.some((st) => only(stageAt(object, l.id, st.id))))) continue;
14448
+ if (removed?.has(address)) {
14449
+ note({
14450
+ kind: "removed",
14451
+ address
14452
+ });
14453
+ continue;
14454
+ }
14455
+ const fresh = freshExport$1(input, l);
14456
+ out.fresh.push(fresh);
14457
+ out.counts.added += 1 + fresh.stages.length;
14458
+ note({
14459
+ kind: "added",
14460
+ address
14461
+ });
14462
+ for (const st of fresh.stages) note({
14463
+ kind: "added",
14464
+ address: stageAt(object, l.id, st.id)
14465
+ });
14466
+ }
14467
+ return out;
14468
+ }
14469
+ function mergeOne$1(input, p, report) {
14470
+ const { object, only, excluded } = input;
14471
+ const address = `pipeline:${object}/${p.id}`;
14472
+ const l = input.live.find((x) => x.id === p.id);
14473
+ if (!(only(address) || p.stages.some((st) => only(stageAt(object, p.id, st.id))))) return p;
14474
+ if (excluded.has(address)) {
14475
+ report.note({
14476
+ kind: "excluded",
14477
+ address
14478
+ });
14479
+ return p;
14480
+ }
14481
+ if (!l) {
14482
+ report.counts.missing += 1;
14483
+ report.note({
14484
+ kind: "missing",
14485
+ address
14486
+ });
14487
+ return p;
14488
+ }
14489
+ const next = { ...p };
14490
+ if (only(address)) {
14491
+ const fields = [];
14492
+ for (const field of ["label", "displayOrder"]) {
14493
+ if (p[field] !== l[field]) fields.push({
14494
+ kind: "changed",
14495
+ address,
14496
+ field,
14497
+ before: p[field],
14498
+ after: l[field]
14499
+ });
14500
+ Object.assign(next, { [field]: l[field] });
14501
+ }
14502
+ resolveFields(address, p, next, fields, input.resolve?.(address));
14503
+ next.stages = mergeStages(input, p, l, next, fields, report);
14504
+ report.settle(fields);
14505
+ } else next.stages = mergeStages(input, p, l, next, [], report);
14506
+ return next;
14507
+ }
14508
+ function mergeStages(input, p, l, next, fields, report) {
14509
+ const merged = portalStages(input, p, l, report);
14510
+ keepFileOnly(input, p, merged, report);
14511
+ return orderStages(input, p, merged, next, fields);
14512
+ }
14513
+ function portalStages(input, p, l, report) {
14514
+ const { object, only, removed } = input;
14515
+ const mine = new Map(p.stages.map((st) => [st.id, st]));
14516
+ const keys = new Set(p.stages.map((st) => st.key));
14517
+ const merged = [];
14518
+ for (const ls of l.stages) {
14519
+ const address = stageAt(object, p.id, ls.id);
14520
+ const st = mine.get(ls.id);
14521
+ const taken = only(address);
14522
+ if (st) merged.push(taken ? mergeStage(input, st, ls, address, report) : st);
14523
+ else if (removed?.has(address)) report.note({
14524
+ kind: "removed",
14525
+ address
14526
+ });
14527
+ else if (taken) {
14528
+ const key = stageKey(ls, keys);
14529
+ keys.add(key);
14530
+ merged.push(stageOf(ls, key, []));
14531
+ report.counts.added += 1;
14532
+ report.note({
14533
+ kind: "added",
14534
+ address
14535
+ });
14536
+ }
14537
+ }
14538
+ return merged;
14539
+ }
14540
+ function keepFileOnly(input, p, merged, report) {
14541
+ const order = p.stages.map((st) => st.id);
14542
+ for (const [index, st] of p.stages.entries()) {
14543
+ if (merged.some((m) => m.id === st.id)) continue;
14544
+ const address = stageAt(input.object, p.id, st.id);
14545
+ if (input.only(address) && input.excluded.has(address)) report.note({
14546
+ kind: "excluded",
14547
+ address
14548
+ });
14549
+ else if (input.only(address)) {
14550
+ report.counts.missing += 1;
14551
+ report.note({
14552
+ kind: "missing",
14553
+ address
14554
+ });
14555
+ }
14556
+ const before = order.slice(0, index).reverse().find((id) => merged.some((m) => m.id === id));
14557
+ merged.splice(before === void 0 ? 0 : merged.findIndex((m) => m.id === before) + 1, 0, st);
14558
+ }
14559
+ }
14560
+ function orderStages(input, p, merged, next, fields) {
14561
+ const pipeline = `pipeline:${input.object}/${p.id}`;
14562
+ const order = p.stages.map((st) => st.id);
14563
+ const resolution = input.resolve?.(pipeline);
14564
+ const unit = resolution?.units.find((u) => u.unit === "stages");
14565
+ const ids = (stages) => stages.map((st) => st.id).filter((id) => order.includes(id));
14566
+ if (!input.only(pipeline)) return inFileOrder(order, merged);
14567
+ if (unit && resolution && keepsFile(unit, resolution)) {
14568
+ const kept = inFileOrder(order, merged);
14569
+ const kind = unit.class === "conflict" ? "conflict" : "kept";
14570
+ fields.push({
14571
+ kind,
14572
+ address: pipeline,
14573
+ field: "stages",
14574
+ before: ids(kept),
14575
+ after: ids(merged)
14576
+ });
14577
+ next.stages = kept;
14578
+ return kept;
14579
+ }
14580
+ const was = order.filter((id) => merged.some((m) => m.id === id));
14581
+ const now = ids(merged);
14582
+ if (was.join("\0") !== now.join("\0")) fields.push({
14583
+ kind: "changed",
14584
+ address: pipeline,
14585
+ field: "stages",
14586
+ before: was,
14587
+ after: now
14588
+ });
14589
+ return merged;
14590
+ }
14591
+ function mergeStage(input, st, ls, address, report) {
14592
+ const fields = [];
14593
+ const next = stageOf(ls, st.key, st.comments);
14594
+ if (st.label !== ls.label) fields.push({
14595
+ kind: "changed",
14596
+ address,
14597
+ field: "label",
14598
+ before: st.label,
14599
+ after: ls.label
14600
+ });
14601
+ for (const field of STAGE_FIELDS) if (st[field] !== ls[field]) fields.push({
14602
+ kind: "changed",
14603
+ address,
14604
+ field,
14605
+ before: st[field],
14606
+ after: ls[field]
14607
+ });
14608
+ const merged = { ...next };
14609
+ resolveFields(address, st, merged, fields, input.resolve?.(address));
14610
+ report.settle(fields);
14611
+ return merged;
14612
+ }
14613
+ function resolveFields(address, mine, merged, fields, r) {
14614
+ for (const u of r?.units ?? []) {
14615
+ if (u.unit === "stages" || !keepsFile(u, r)) continue;
14616
+ const at = fields.findIndex((c) => c.kind === "changed" && c.field === u.unit);
14617
+ if (at >= 0) fields.splice(at, 1);
14618
+ fields.push({
14619
+ kind: u.class === "conflict" ? "conflict" : "kept",
14620
+ address,
14621
+ field: u.unit,
14622
+ before: mine[u.unit],
14623
+ after: merged[u.unit]
14624
+ });
14625
+ if (mine[u.unit] === void 0) delete merged[u.unit];
14626
+ else merged[u.unit] = mine[u.unit];
14627
+ }
14628
+ }
14629
+ function keepsFile(u, r) {
14630
+ return (u.class === "config-change" || u.class === "conflict") && !r.accept(u.unit);
14631
+ }
14632
+ function inFileOrder(order, merged) {
14633
+ const queue = order.flatMap((id) => merged.filter((st) => st.id === id));
14634
+ return merged.map((st) => order.includes(st.id) ? queue.shift() ?? st : st);
14635
+ }
14636
+ function stageOf(ls, key, comments) {
14637
+ const out = {
14638
+ key,
14639
+ id: ls.id,
14640
+ label: ls.label,
14641
+ comments
14642
+ };
14643
+ for (const field of STAGE_FIELDS) if (ls[field] !== void 0) Object.assign(out, { [field]: ls[field] });
14644
+ return out;
14645
+ }
14646
+ function freshExport$1(input, l) {
14647
+ const keys = /* @__PURE__ */ new Set();
14648
+ const stages = l.stages.map((ls) => {
14649
+ const key = stageKey(ls, keys);
14650
+ keys.add(key);
14651
+ return stageOf(ls, key, []);
14652
+ });
14653
+ const name = unique$1(pipelineExportName(l.label, l.id), input.taken);
14654
+ input.taken.add(name);
14655
+ return {
14656
+ name,
14657
+ object: input.object,
14658
+ id: l.id,
14659
+ label: l.label,
14660
+ displayOrder: l.displayOrder,
14661
+ stages,
14662
+ comments: []
14663
+ };
14664
+ }
14665
+ /**
14666
+ * The export name pull gives a pipeline: PascalCase of the words of its label, followed by `Pipeline` unless the label
14667
+ * ends with that word, `SalesPipeline` for "Sales Pipeline"; the ID's words when the label has no letter.
14668
+ */
14669
+ function pipelineExportName(label, id) {
14670
+ const words = (text) => text.split(NOT_ALNUM).filter(Boolean);
14671
+ const name = (words(label).some((w) => LETTER.test(w)) ? words(label) : words(id)).map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join("");
14672
+ const base = DIGIT_FIRST.test(name) || name === "" ? `Pipeline${name}` : name;
14673
+ return base.endsWith("Pipeline") ? base : `${base}Pipeline`;
14674
+ }
14675
+ /**
14676
+ * The key pull gives a stage: camelCase of its ID when the ID is a lowercase slug, else of its label, with `stage` in
14677
+ * front of one that would start with a digit, unique in the pipeline.
14678
+ */
14679
+ function stageKey(ls, taken) {
14680
+ const words = (text) => text.toLowerCase().split(NOT_ALNUM).filter(Boolean).join("_");
14681
+ const base = camelCase(SLUG.test(ls.id) ? ls.id : words(ls.label) || words(ls.id)) || "stage";
14682
+ return unique$1(DIGIT_FIRST.test(base) ? `stage${base}` : base, taken);
14683
+ }
14684
+ function unique$1(name, taken) {
14685
+ if (!taken.has(name)) return name;
14686
+ let n = 2;
14687
+ while (taken.has(`${name}${n}`)) n += 1;
14688
+ return `${name}${n}`;
14689
+ }
14690
+ function stageAt(object, pipeline, stage) {
14691
+ return `stage:${object}/${pipeline}/${stage}`;
14692
+ }
14693
+ function scrub$3(change) {
14694
+ const clean = (value) => {
14695
+ if (typeof value === "string") return sanitize(value);
14696
+ return Array.isArray(value) ? value.map(clean) : value;
14697
+ };
14698
+ const out = {
14699
+ ...change,
14700
+ address: sanitize(change.address)
14701
+ };
14702
+ if (change.field !== void 0) out.field = sanitize(change.field);
14703
+ if (change.before !== void 0) out.before = clean(change.before);
14704
+ if (change.after !== void 0) out.after = clean(change.after);
14705
+ return out;
14706
+ }
12572
14707
  /** The key's scopes, or undefined when HubSpot refused the request, gave no answer, or answered without a scope list. */
12573
14708
  async function readTokenInfo(http, key) {
12574
14709
  let answer;
@@ -13757,8 +15892,9 @@ function fmt(ctx) {
13757
15892
  }
13758
15893
  /**
13759
15894
  * The canonical text of every file of a project the loader accepted, in path order: kalup.config.ts, <dir>/removed.ts,
13760
- * each object file, and the barrel re-exporting every object. The barrel is left out when there is no object file to
13761
- * re-export. A file that is not TypeScript (the blueprints lock, a stored original) is the tool's own JSON: not here.
15895
+ * each object and pipeline file, and the barrel re-exporting every object and pipeline. The barrel is left out when
15896
+ * there is no object file to re-export. A file that is not TypeScript (the blueprints lock, a stored original) is the
15897
+ * tool's own JSON: not here.
13762
15898
  */
13763
15899
  function canonical(files, at) {
13764
15900
  const out = [];
@@ -13774,8 +15910,17 @@ function canonical(files, at) {
13774
15910
  out.push([file, write("removed", result.data)]);
13775
15911
  continue;
13776
15912
  }
13777
- out.push([file, write("object", result.data)]);
13778
15913
  const from = barrelPath(at, file);
15914
+ if (result.kind === "pipeline") {
15915
+ out.push([file, write("pipeline", result.data)]);
15916
+ entries.push(...result.data.exports.map((e) => ({
15917
+ name: e.name,
15918
+ from,
15919
+ pipeline: true
15920
+ })));
15921
+ continue;
15922
+ }
15923
+ out.push([file, write("object", result.data)]);
13779
15924
  for (const e of result.data.exports) entries.push({
13780
15925
  name: e.name,
13781
15926
  from
@@ -13953,7 +16098,11 @@ async function pull(ctx) {
13953
16098
  async function pullTarget(ctx, pulling) {
13954
16099
  const { root, loaded, http, target, targetName, via, accepting, warnings } = pulling;
13955
16100
  const portalId = target.portalId;
13956
- const portal = await readPortal(http, loaded, target, warnings, { schemas: ctx.flags.discover });
16101
+ const discovering = ctx.flags.discover === true;
16102
+ const portal = await readPortal(http, loaded, target, warnings, {
16103
+ schemas: discovering,
16104
+ pipelines: discovering
16105
+ });
13957
16106
  if (portal.absent.length > 0) throw unknownObjects(portal.absent, portal.customObjects ?? [], loaded.configLines);
13958
16107
  if (portal.unknownIncludes.length > 0) throw new KalupError(portal.unknownIncludes.map(unknownInclude(loaded.configLines)), exitCodes.invalid);
13959
16108
  const incomplete = incompleteIssue(portal.gaps, targetName);
@@ -14196,12 +16345,93 @@ function mergeFiles(files, portal, loaded, merging, warnings) {
14196
16345
  parsed.set(file, data);
14197
16346
  next[file] = write("object", data);
14198
16347
  }
16348
+ mergePipelineFiles({
16349
+ next,
16350
+ objects,
16351
+ overrides,
16352
+ parsed,
16353
+ portal,
16354
+ loaded,
16355
+ merging: {
16356
+ ...rest,
16357
+ only,
16358
+ stated,
16359
+ excluded
16360
+ }
16361
+ });
14199
16362
  return {
14200
16363
  next,
14201
16364
  objects,
14202
16365
  overrides
14203
16366
  };
14204
16367
  }
16368
+ function mergePipelineFiles(m) {
16369
+ const { next, objects, overrides, parsed, portal, loaded } = m;
16370
+ const { stated, ...rest } = m.merging;
16371
+ const files = pipelineFiles(next, loaded.layout);
16372
+ const taken = /* @__PURE__ */ new Set([...[...parsed.values()].flatMap((f) => f.exports.map((e) => e.name)), ...[...files.values()].flatMap((f) => f.exports.map((e) => e.name))]);
16373
+ for (const live of portal.objects) {
16374
+ if (live.pipelines === void 0) continue;
16375
+ const { object } = live;
16376
+ const local = [...files.values()].flatMap((f) => f.exports.filter((e) => e.object === object));
16377
+ const merged = mergePipelines({
16378
+ ...rest,
16379
+ all: loaded.config.objects[object]?.pipelines === true,
16380
+ object,
16381
+ live: live.pipelines,
16382
+ local: local.map((e) => pipelineAsTarget(e, stated)),
16383
+ taken
16384
+ });
16385
+ const report = objects[object] ?? {
16386
+ added: 0,
16387
+ changed: 0,
16388
+ unchanged: 0,
16389
+ missing: 0,
16390
+ changes: []
16391
+ };
16392
+ for (const count of [
16393
+ "added",
16394
+ "changed",
16395
+ "unchanged",
16396
+ "missing"
16397
+ ]) report[count] += merged.counts[count];
16398
+ report.changes.push(...merged.changes);
16399
+ objects[object] = report;
16400
+ for (const [file, data] of files) {
16401
+ const exports = data.exports.map((e) => {
16402
+ const into = e.object === object ? merged.merged.get(e.name) : void 0;
16403
+ if (!into) return e;
16404
+ const split = pipelineFromTarget(into, e, stated);
16405
+ Object.assign(overrides, split.overrides);
16406
+ return split.export;
16407
+ });
16408
+ files.set(file, {
16409
+ ...data,
16410
+ exports
16411
+ });
16412
+ }
16413
+ if (merged.fresh.length > 0) {
16414
+ const file = pipelinePath(loaded.layout, object);
16415
+ const data = files.get(file) ?? {
16416
+ imports: [],
16417
+ exports: []
16418
+ };
16419
+ files.set(file, {
16420
+ ...data,
16421
+ exports: [...data.exports, ...merged.fresh]
16422
+ });
16423
+ }
16424
+ }
16425
+ for (const [file, data] of files) next[file] = write("pipeline", data);
16426
+ }
16427
+ function pipelineFiles(files, at) {
16428
+ const out = /* @__PURE__ */ new Map();
16429
+ for (const [file, text] of Object.entries(files)) if (inDir(at, file) && inPipelines(at, file)) {
16430
+ const result = read(text, file, "pipeline");
16431
+ if (result.kind === "pipeline") out.set(file, result.data);
16432
+ }
16433
+ return out;
16434
+ }
14205
16435
  function writeOverrides(next, config, targetName, changed) {
14206
16436
  if (Object.keys(changed).length === 0) return;
14207
16437
  const target = config.targets[targetName];
@@ -14263,7 +16493,7 @@ function incompleteIssue(gaps, target) {
14263
16493
  function objectFiles(files, at) {
14264
16494
  const out = /* @__PURE__ */ new Map();
14265
16495
  for (const [file, text] of Object.entries(files)) {
14266
- if (!inDir(at, file) || file === at.barrel) continue;
16496
+ if (!inDir(at, file) || file === at.barrel || inPipelines(at, file)) continue;
14267
16497
  const result = read(text, file);
14268
16498
  if (result.kind === "object") out.set(file, result.data);
14269
16499
  }
@@ -14343,6 +16573,17 @@ function portalSide(c, target) {
14343
16573
  function show$1(value) {
14344
16574
  return value === void 0 ? "none" : JSON.stringify(value);
14345
16575
  }
16576
+ function outsidePipelines(loaded, portal, lines) {
16577
+ const pipelines = {};
16578
+ for (const live of portal.objects) {
16579
+ const defined = (id) => Object.hasOwn(loaded.ir.resources, `pipeline:${live.object}/${id}`);
16580
+ const outside = loaded.config.objects[live.object]?.pipelines === true ? [] : (live.pipelines ?? []).filter((p) => !defined(p.id));
16581
+ if (outside.length === 0) continue;
16582
+ pipelines[live.object] = outside.map((p) => sanitize(p.id));
16583
+ for (const p of outside) lines.push(` pipeline:${live.object}/${sanitize(p.id)} (${sanitize(`"${p.label}"`, 200)}; set objects.${live.object}.pipelines to true)`);
16584
+ }
16585
+ return pipelines;
16586
+ }
14346
16587
  function discover(target, portalId, loaded, portal, warnings) {
14347
16588
  const scopes = loaded.config.objects;
14348
16589
  const lines = [];
@@ -14362,6 +16603,7 @@ function discover(target, portalId, loaded, portal, warnings) {
14362
16603
  lines.push(` property:${live.object}/${sanitize(p.name)} (${sanitize(why, 400)})`);
14363
16604
  }
14364
16605
  }
16606
+ const pipelines = outsidePipelines(loaded, portal, lines);
14365
16607
  let head = `Everything the portal holds for target ${target} is in the pull scope.`;
14366
16608
  if (lines.length > 0) head = `Outside the pull scope of target ${target} (portal ${portalId}):`;
14367
16609
  else if (portal.gaps.length > 0) head = `Nothing outside the pull scope of target ${target} in the lists the key could read.`;
@@ -14370,7 +16612,8 @@ function discover(target, portalId, loaded, portal, warnings) {
14370
16612
  target,
14371
16613
  portalId,
14372
16614
  objects,
14373
- properties
16615
+ properties,
16616
+ pipelines
14374
16617
  },
14375
16618
  issues: warnings,
14376
16619
  text: `${[
@@ -14966,7 +17209,7 @@ async function confirm(prompt, plan, portal) {
14966
17209
  const { name, protected: guarded } = plan.target;
14967
17210
  prompt.tell([
14968
17211
  `Apply plan ${plan.planId} to target ${name}, portal ${portal.portalId} (${portal.accountType}, ${guarded ? "protected" : "not protected"}):`,
14969
- ...effects.map((s) => ` ${s.id} ${s.risk}${s.labels?.length ? ` [${s.labels.join(", ")}]` : ""} ${stepTitle(s, names)}`),
17212
+ ...effects.map((s) => ` ${s.id} ${s.risk}${s.labels?.length ? ` [${s.labels.join(", ")}]` : ""} ${stepTitle(s, names, true)}`),
14970
17213
  [...kinds.filter(([n]) => n > 0).map(([n, noun]) => plural(n, noun)), `${destructive} destructive`].join(", ")
14971
17214
  ]);
14972
17215
  if ((await prompt.ask("Type the target name to apply:"))?.trim() !== name) throw cancelled("the answer was not the target name");
@@ -15594,6 +17837,8 @@ const LINE_BREAK = /\r?\n/;
15594
17837
  const JSON_INDENT = /^([ \t]+)"/m;
15595
17838
  /** A line of the barrel: `export {}`, or a re-export of one object file's exports. */
15596
17839
  const BARREL_LINE = /^export (?:\{\}|(?:type )?\{ [\w, ]+ \} from '\.\/[\w./-]+')$/;
17840
+ /** The objects whose pipelines a new project pulls: those Kalup writes the pipelines of among the standard objects. */
17841
+ const PIPELINED = /* @__PURE__ */ new Set(["deals", "tickets"]);
15597
17842
  function init(ctx) {
15598
17843
  const { cwd, flags } = ctx;
15599
17844
  const portalId = parsePortal$1(flags.portal);
@@ -15618,7 +17863,7 @@ function init(ctx) {
15618
17863
  const config = {
15619
17864
  imports: [],
15620
17865
  dir,
15621
- objects: Object.fromEntries(objects.map((object) => [object, {}])),
17866
+ objects: Object.fromEntries(objects.map((object) => [object, PIPELINED.has(object) ? { pipelines: true } : {}])),
15622
17867
  targets: { [target]: targetConfig }
15623
17868
  };
15624
17869
  const files = [];
@@ -16025,8 +18270,6 @@ function ir(ctx) {
16025
18270
  }
16026
18271
  //#endregion
16027
18272
  //#region src/commands/rm.ts
16028
- const REMOVABLE = /* @__PURE__ */ new Set(["property", "group"]);
16029
- const ON_OBJECT = /^[^\s/]+\/[^\s/]+$/;
16030
18273
  function rm(ctx) {
16031
18274
  const [given] = ctx.args;
16032
18275
  if (given === void 0) throw usageError("missing argument ADDRESS");
@@ -16079,11 +18322,11 @@ function rm(ctx) {
16079
18322
  message: `${issue.message}${after}`
16080
18323
  })), exitCodes.invalid);
16081
18324
  }
16082
- const changed = Object.keys(next).filter((file) => next[file] !== files[file]);
18325
+ const changed = [.../* @__PURE__ */ new Set([...Object.keys(next), ...Object.keys(files)])].filter((file) => next[file] !== files[file]);
16083
18326
  const data = {
16084
18327
  address,
16085
18328
  action,
16086
- files: writeStaged(root, Object.fromEntries(changed.map((file) => [file, next[file] ?? ""]))),
18329
+ files: writeStaged(root, Object.fromEntries(changed.map((file) => [file, next[file] ?? null]))),
16087
18330
  ...from === void 0 ? {} : { from },
16088
18331
  ...previous === void 0 ? {} : { previous: previous.action }
16089
18332
  };
@@ -16094,18 +18337,19 @@ function rm(ctx) {
16094
18337
  };
16095
18338
  }
16096
18339
  function checkAddress(given) {
16097
- const at = { fix: "pass the address of a property or group, such as 'property:companies/legacy_score'" };
18340
+ const at = { fix: "pass the address of a property, group, pipeline or stage, such as 'property:companies/legacy_score'" };
16098
18341
  if (!isAddress(given)) throw invalidAddress({
16099
18342
  message: `'${sanitize(given)}' is not an address`,
16100
18343
  ...at
16101
18344
  });
16102
18345
  const { type, path } = parseAddress(given);
16103
- if (!REMOVABLE.has(type)) throw invalidAddress({
16104
- message: `cannot remove ${sanitize(given)}: this version removes properties and groups only`,
18346
+ const shape = Object.hasOwn(REMOVABLE, type) ? REMOVABLE[type] : void 0;
18347
+ if (!shape) throw invalidAddress({
18348
+ message: `cannot remove ${sanitize(given)}: this version removes properties, groups, pipelines and stages only`,
16105
18349
  fix: "custom objects are not removed or released in this release"
16106
18350
  });
16107
- if (!ON_OBJECT.test(path)) throw invalidAddress({
16108
- message: `'${sanitize(given)}' is not of the form ${type}:<object>/<name>`,
18351
+ if (!shape.path.test(path)) throw invalidAddress({
18352
+ message: `'${sanitize(given)}' is not of the form ${shape.form}`,
16109
18353
  ...at
16110
18354
  });
16111
18355
  return given;
@@ -16134,7 +18378,9 @@ function refuse(loaded, address, resource, action) {
16134
18378
  function dependents(loaded, address) {
16135
18379
  const { resources } = loaded.ir;
16136
18380
  const key = objectOf(address);
16137
- if (parseAddress(address).type === "group") return Object.entries(resources).filter(([, r]) => r.type === "property" && (r.definition?.group)?.$ref === address).map(([a]) => a).sort();
18381
+ const { type } = parseAddress(address);
18382
+ if (type === "pipeline" || type === "stage") return [];
18383
+ if (type === "group") return Object.entries(resources).filter(([, r]) => r.type === "property" && (r.definition?.group)?.$ref === address).map(([a]) => a).sort();
16138
18384
  const d = (Object.hasOwn(resources, `object:${key}`) ? resources[`object:${key}`] : void 0)?.definition ?? {};
16139
18385
  return [
16140
18386
  d.primaryDisplayProperty,
@@ -16147,6 +18393,8 @@ function takeOut(files, loaded, address) {
16147
18393
  const source = loaded.sources[address];
16148
18394
  const text = source === void 0 ? void 0 : files[source.file];
16149
18395
  if (source === void 0 || text === void 0) return;
18396
+ const { type } = parseAddress(address);
18397
+ if (type === "pipeline" || type === "stage") return takeOutPipeline(files, source, text);
16150
18398
  const result = read(text, source.file);
16151
18399
  if (result.kind !== "object") return;
16152
18400
  const [owner] = source.configPath.split(".");
@@ -16168,6 +18416,21 @@ function takeOut(files, loaded, address) {
16168
18416
  });
16169
18417
  return source.file;
16170
18418
  }
18419
+ function takeOutPipeline(files, source, text) {
18420
+ const result = read(text, source.file, "pipeline");
18421
+ if (result.kind !== "pipeline") return;
18422
+ const [owner, , key] = source.configPath.split(".");
18423
+ const exports = key === void 0 ? result.data.exports.filter((e) => e.name !== owner) : result.data.exports.map((e) => e.name === owner ? {
18424
+ ...e,
18425
+ stages: e.stages.filter((st) => st.key !== key)
18426
+ } : e);
18427
+ if (exports.length === 0) delete files[source.file];
18428
+ else files[source.file] = write("pipeline", {
18429
+ ...result.data,
18430
+ exports
18431
+ });
18432
+ return source.file;
18433
+ }
16171
18434
  function removedFile(text, file) {
16172
18435
  if (text === void 0) return {
16173
18436
  imports: [],
@@ -16243,12 +18506,15 @@ function countsOf(document) {
16243
18506
  return {
16244
18507
  objects: Object.values(document.observation.coverage.objects).filter((o) => o.status === "read").length,
16245
18508
  groups: resources.filter((r) => r.type === "group").length,
16246
- properties: resources.filter((r) => r.type === "property").length
18509
+ pipelines: resources.filter((r) => r.type === "pipeline").length,
18510
+ properties: resources.filter((r) => r.type === "property").length,
18511
+ stages: resources.filter((r) => r.type === "stage").length
16247
18512
  };
16248
18513
  }
16249
18514
  function summary(data) {
16250
- const { objects, groups, properties } = data.counts;
16251
- const held = `${plural(objects, "object")}, ${plural(groups, "group")}, ${plural(properties, "property", "properties")}`;
18515
+ const { objects, groups, properties, pipelines, stages } = data.counts;
18516
+ const piped = pipelines > 0 ? `, ${plural(pipelines, "pipeline")}, ${plural(stages, "stage")}` : "";
18517
+ const held = `${plural(objects, "object")}, ${plural(groups, "group")}, ${plural(properties, "property", "properties")}${piped}`;
16252
18518
  return `${`Snapshot of target ${sanitize(data.target)}, portal ${data.portalId}, observed at ${data.observedAt}: ${held}`}\n${wrote(data.file)}`;
16253
18519
  }
16254
18520
  //#endregion
@@ -16460,12 +18726,15 @@ async function status(ctx) {
16460
18726
  const counts = {
16461
18727
  objects: Object.keys(loaded.config.objects).length,
16462
18728
  properties: count(loaded.ir, "property"),
16463
- groups: count(loaded.ir, "group")
18729
+ groups: count(loaded.ir, "group"),
18730
+ pipelines: count(loaded.ir, "pipeline"),
18731
+ stages: count(loaded.ir, "stage")
16464
18732
  };
18733
+ const piped = counts.pipelines > 0 ? `, ${plural(counts.pipelines, "pipeline")}, ${plural(counts.stages, "stage")}` : "";
16465
18734
  const exitCode = exitCodeOf(targets);
16466
18735
  const lines = [
16467
18736
  `${bin} ${version}`,
16468
- `Config: valid (${plural(counts.objects, "object")}, ${plural(counts.properties, "property", "properties")}, ${plural(counts.groups, "group")}) in ${loaded.layout.dir}/`,
18737
+ `Config: valid (${plural(counts.objects, "object")}, ${plural(counts.properties, "property", "properties")}, ${plural(counts.groups, "group")}${piped}) in ${loaded.layout.dir}/`,
16469
18738
  ...targets.flatMap((t) => describe(root, t, recommended.scope, writeScopes))
16470
18739
  ];
16471
18740
  return {
@@ -17095,7 +19364,7 @@ var ApplyCommand = class extends KalupCommand {
17095
19364
  }
17096
19365
  };
17097
19366
  var RmCommand = class extends KalupCommand {
17098
- static summary = "Take a property or group out of config and write its tombstone in removed.ts.";
19367
+ static summary = "Take a resource out of config and write its tombstone in removed.ts.";
17099
19368
  static args = { address: Args.string({
17100
19369
  description: "The address, for example property:companies/legacy_score.",
17101
19370
  required: true