kalup 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -53,7 +53,7 @@ Commit `kalup.config.ts` and `hubspot/`. Keep `.kalup/`, plan files and `.env` o
53
53
 
54
54
  `kalup <command> --help` lists each command's flags.
55
55
 
56
- ## What 0.2 covers
56
+ ## What it covers
57
57
 
58
58
  - Reads and writes properties and property groups on standard and custom objects. Custom object schemas are read and compared, not written.
59
59
  - Every property definition field HubSpot lets you write, such as display hints, `hidden`, `displayOrder` and calculation formulas, checked against a live developer test account.
@@ -61,7 +61,7 @@ Commit `kalup.config.ts` and `hubspot/`. Keep `.kalup/`, plan files and `.env` o
61
61
  - State local to your machine by default, or committed with `state: 'repo'`. Monorepos and git worktrees.
62
62
  - Every command takes `--json` and prints one `envelope/1` document with stable issue codes and exit codes. The JSON Schemas ship as `kalup/schemas/<file>`.
63
63
 
64
- The pull, plan, apply and drift workflow passed [live runs](https://github.com/scopiousdigital/kalup/blob/main/docs/hubspot.md#live-runs) on a HubSpot developer test account; other account types are not verified yet. Pipelines, custom object schema writes and association labels are next. Before 1.0, a minor release may change the config grammar or the JSON output, and its release notes say so.
64
+ The pull, plan, apply and drift workflow passed [live runs](https://github.com/scopiousdigital/kalup/blob/main/docs/hubspot.md#live-runs) on a HubSpot developer test account. Start on a test account or sandbox. Pipelines, custom object schema writes and association labels are next. Before 1.0, a minor release may change the config grammar or the JSON output, and its release notes say so.
65
65
 
66
66
  ## Docs
67
67
 
@@ -1252,6 +1252,15 @@ const TYPE_FIELDS = {
1252
1252
  currencyPropertyName: ["number"],
1253
1253
  textDisplayHint: ["string", "phone_number"]
1254
1254
  };
1255
+ /**
1256
+ * The name prefixes HubSpot reserves: `hs_` for its own properties and `a<appId>_` for an integration's. A create with
1257
+ * either is refused (400, live runs 2026-10-01). Kalup never manages such a property; pull writes it as a reference.
1258
+ */
1259
+ const RESERVED_PREFIX = /^(hs_|a\d+_)/;
1260
+ /** The reserved prefix a name carries, or undefined. */
1261
+ function reservedPrefix(name) {
1262
+ return RESERVED_PREFIX.exec(name)?.[1];
1263
+ }
1255
1264
  const ADDRESS = /^[a-z]+:\S+$/;
1256
1265
  /** Whether `value` is an address: a lowercase type, a colon and a path without whitespace. */
1257
1266
  function isAddress(value) {
@@ -2692,7 +2701,7 @@ function validate$2(loaded, options = {}) {
2692
2701
  });
2693
2702
  if (loaded.layout.legacy) warnings.push({
2694
2703
  code: "W_LEGACY_DIR",
2695
- message: `the object files are in ${LEGACY_DIR}/, the folder Kalup 0.1 used; the default is now ${DEFAULT_DIR}/`,
2704
+ message: `the object files are in ${LEGACY_DIR}/, the old default folder; the default is now ${DEFAULT_DIR}/`,
2696
2705
  file: CONFIG$2$1,
2697
2706
  fix: `add dir: '${LEGACY_DIR}' to ${CONFIG$2$1}, or move ${LEGACY_DIR}/ to ${DEFAULT_DIR}/`
2698
2707
  });
@@ -2807,9 +2816,10 @@ function checkProperty$1(loaded, address, resource, keys, { issues, warnings })
2807
2816
  fix: "list the options, or drop .strict()"
2808
2817
  });
2809
2818
  checkLifecycle(resource, d, at, issues);
2810
- if (resource.managed && name.startsWith("hs_")) issues.push({
2819
+ const reserved = reservedPrefix(name);
2820
+ if (resource.managed && reserved !== void 0) issues.push({
2811
2821
  code: "E_HS_PREFIX",
2812
- message: `'${name}' starts with hs_, the prefix HubSpot uses for its own properties`,
2822
+ message: `'${name}' starts with ${reserved}, a prefix HubSpot reserves (hs_ for its own properties, a<digits>_ for an integration's)`,
2813
2823
  ...at(),
2814
2824
  fix: "rename the property, or drop label, group and fieldType to reference it"
2815
2825
  });
@@ -3077,7 +3087,13 @@ function definitionRules(codec, d) {
3077
3087
  message: `calculationFormula needs fieldType '${CALCULATION}': HubSpot turns the property into a calculation`,
3078
3088
  fix: `set fieldType: '${CALCULATION}', or remove calculationFormula`
3079
3089
  });
3080
- if (d.currencyPropertyName !== void 0 && d.showCurrencySymbol !== true) rules.push({
3090
+ if (d.currencyPropertyName === "") rules.push({
3091
+ field: "currencyPropertyName",
3092
+ reads: ["currencyPropertyName"],
3093
+ message: "currencyPropertyName '' is not nothing: HubSpot stores it and then never turns showCurrencySymbol off",
3094
+ fix: "remove currencyPropertyName, or name a currency property"
3095
+ });
3096
+ else if (d.currencyPropertyName !== void 0 && d.showCurrencySymbol !== true) rules.push({
3081
3097
  field: "currencyPropertyName",
3082
3098
  reads: ["currencyPropertyName", "showCurrencySymbol"],
3083
3099
  message: "HubSpot takes currencyPropertyName only with showCurrencySymbol: true",
@@ -3262,10 +3278,11 @@ function rules(blueprint) {
3262
3278
  }
3263
3279
  return issues;
3264
3280
  }
3265
- /** A group or property name: plain, and never HubSpot's own `hs_`. */
3281
+ /** A group or property name: plain, and never a prefix HubSpot reserves (`hs_`, `a<digits>_`). */
3266
3282
  function checkName(report, at, type, name) {
3283
+ const reserved = reservedPrefix(name);
3267
3284
  if (!NAME.test(name)) report(at, `${type} name '${name}' is not lowercase letters, digits and underscores starting with a letter`);
3268
- else if (name.startsWith("hs_")) report(at, `${type} name '${name}' starts with hs_, the prefix HubSpot uses for its own names`);
3285
+ else if (reserved !== void 0) report(at, `${type} name '${name}' starts with ${reserved}, a prefix HubSpot reserves`);
3269
3286
  }
3270
3287
  function checkProperty(report, at, object, resource) {
3271
3288
  const d = resource.definition;
@@ -3454,6 +3471,16 @@ const registry = {
3454
3471
  tag: "read"
3455
3472
  } }
3456
3473
  },
3474
+ tokenInfo: {
3475
+ family: "oauth.private-apps",
3476
+ version: "v2",
3477
+ status: "ga",
3478
+ paths: { read: {
3479
+ method: "POST",
3480
+ path: "/oauth/v2/private-apps/get/access-token-info",
3481
+ tag: "read"
3482
+ } }
3483
+ },
3457
3484
  limits: {
3458
3485
  family: "crm.limits",
3459
3486
  version: "2026-09",
@@ -3501,9 +3528,10 @@ const scopeExceptions = {
3501
3528
  users: "crm.objects.users.read"
3502
3529
  };
3503
3530
  /**
3504
- * The write counterpart of each read exception. Unverified: the scope list of HubSpot's 2026-09 create-property
3505
- * reference names crm.schemas.commercepayments.write, e-commerce and crm.objects.users.write, and none of the others,
3506
- * so they mirror the read exceptions until a live write checks them.
3531
+ * The write counterpart of each read exception. The scope list of HubSpot's 2026-09 create-property reference names
3532
+ * crm.schemas.commercepayments.write, e-commerce and crm.objects.users.write, and none of the others, so they mirror
3533
+ * the read exceptions until a live write checks them. A key with crm.schemas.companies.write alone creates, updates
3534
+ * and archives company properties and reads nothing (live runs, 2026-10-01).
3507
3535
  */
3508
3536
  const writeScopeExceptions = {
3509
3537
  commerce_payments: "crm.schemas.commercepayments.write",
@@ -3606,6 +3634,8 @@ const MILESTONE_3_WRITES = [
3606
3634
  path: "delete"
3607
3635
  }
3608
3636
  ];
3637
+ /** How many `errors` entries an outcome keeps: a property used in hundreds of places must not balloon it. */
3638
+ const ERRORS_MAX = 50;
3609
3639
  var HubSpotApiError = class extends KalupError {
3610
3640
  status;
3611
3641
  category;
@@ -3919,14 +3949,16 @@ function outcomeOf(answer, context) {
3919
3949
  const REJECTED_MESSAGE_MAX = 400;
3920
3950
  function rejectedOf(status, body, context) {
3921
3951
  const { key, method, path, scope } = context;
3922
- const { category, correlationId, subCategory } = body;
3952
+ const { category, correlationId, subCategory, context: fields, errors } = body;
3923
3953
  return {
3924
3954
  kind: "rejected",
3925
3955
  status,
3926
3956
  ...category === void 0 ? {} : { category },
3927
3957
  ...subCategory === void 0 ? {} : { subCategory },
3928
3958
  ...correlationId === void 0 ? {} : { correlationId },
3929
- message: issueOf(status, body, method, path, key, scope, REJECTED_MESSAGE_MAX).message
3959
+ message: issueOf(status, body, method, path, key, scope, REJECTED_MESSAGE_MAX).message,
3960
+ ...fields === void 0 ? {} : { context: fields },
3961
+ ...errors === void 0 ? {} : { errors }
3930
3962
  };
3931
3963
  }
3932
3964
  function retryAfterMs(headers) {
@@ -3966,13 +3998,33 @@ function errorBody(text, key) {
3966
3998
  }
3967
3999
  function fieldsOf(body, key) {
3968
4000
  if (typeof body !== "object" || body === null) return {};
3969
- const { category, correlationId, message, policyName, subCategory } = body;
4001
+ const { category, context, correlationId, errors, message, policyName, subCategory } = body;
4002
+ const fields = contextOf(context, key);
4003
+ const details = Array.isArray(errors) ? errors.slice(0, ERRORS_MAX).map((item) => detailOf(item, key)) : void 0;
3970
4004
  return {
3971
4005
  ...typeof category === "string" ? { category: quote(category, key) } : {},
3972
4006
  ...typeof subCategory === "string" ? { subCategory: quote(subCategory, key) } : {},
3973
4007
  ...typeof correlationId === "string" ? { correlationId: quote(correlationId, key) } : {},
3974
4008
  ...typeof message === "string" ? { message } : {},
3975
- ...typeof policyName === "string" ? { policyName } : {}
4009
+ ...typeof policyName === "string" ? { policyName } : {},
4010
+ ...fields === void 0 ? {} : { context: fields },
4011
+ ...details === void 0 ? {} : { errors: details }
4012
+ };
4013
+ }
4014
+ function contextOf(context, key) {
4015
+ if (typeof context !== "object" || context === null || Array.isArray(context)) return;
4016
+ const out = {};
4017
+ for (const [name, value] of Object.entries(context)) if (Array.isArray(value) && value.every((item) => typeof item === "string")) out[quote(name, key)] = value.map((item) => quote(item, key));
4018
+ return out;
4019
+ }
4020
+ function detailOf(item, key) {
4021
+ if (typeof item !== "object" || item === null) return {};
4022
+ const { context, message, subCategory } = item;
4023
+ const fields = contextOf(context, key);
4024
+ return {
4025
+ ...typeof subCategory === "string" ? { subCategory: quote(subCategory, key) } : {},
4026
+ ...typeof message === "string" ? { message: quote(message, key) } : {},
4027
+ ...fields === void 0 ? {} : { context: fields }
3976
4028
  };
3977
4029
  }
3978
4030
  function issueOf(status, body, method, path, key, scope, max) {
@@ -4013,6 +4065,114 @@ function portalMidnight(now, timeZone) {
4013
4065
  const offset = Date.UTC(part.year ?? 0, (part.month ?? 1) - 1, part.day, part.hour, part.minute, part.second) - Math.floor(now.getTime() / 1e3) * 1e3;
4014
4066
  return new Date(Date.UTC(part.year ?? 0, (part.month ?? 1) - 1, (part.day ?? 1) + 1) - offset).toISOString();
4015
4067
  }
4068
+ /**
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.
4109
+ */
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"
4140
+ };
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
+ };
4149
+ }
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));
4154
+ }
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);
4163
+ }
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));
4168
+ }
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);
4175
+ }
4016
4176
  const SETS$1 = /* @__PURE__ */ new Set(["requiredProperties", "searchableProperties"]);
4017
4177
  /** Every unit desired owns that observed holds, sorted by unit. */
4018
4178
  function classify(base, desired, observed, rules) {
@@ -4934,7 +5094,7 @@ function normalizeProperties(object, raw, issues, warn = () => true) {
4934
5094
  for (const p of raw) {
4935
5095
  if (p.archived) continue;
4936
5096
  const hubspotDefined = Boolean(p.hubspotDefined);
4937
- const reference = Boolean(p.hubspotDefined || p.calculated && p.fieldType !== "calculation_equation");
5097
+ const reference = Boolean(p.hubspotDefined || p.calculated && p.fieldType !== "calculation_equation" || RESERVED_PREFIX.test(p.name));
4938
5098
  const external = p.externalOptions === true || p.referencedObjectType === "OWNER";
4939
5099
  const kind = builderOf(p, external);
4940
5100
  if (kind === void 0 || !(reference || writable(p, kind, external))) {
@@ -5011,7 +5171,8 @@ function metaOf(p) {
5011
5171
  modificationMetadata,
5012
5172
  createdAt: text(p.createdAt),
5013
5173
  updatedAt: text(p.updatedAt),
5014
- options: options?.length ? options : void 0
5174
+ options: options?.length ? options : void 0,
5175
+ currencyPropertyName: text(p.currencyPropertyName)
5015
5176
  });
5016
5177
  }
5017
5178
  function definitionOf$1(p, reference, options) {
@@ -5074,114 +5235,6 @@ function normalizeSchema(schema) {
5074
5235
  function compact$4(value) {
5075
5236
  return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== void 0));
5076
5237
  }
5077
- /**
5078
- * The objects whose properties the properties API lists by name. A config key outside this set names a custom object
5079
- * schema. The set is the 2026-09 `crm.schemas.<object>.read` scope list plus the objects that carry their own scopes
5080
- * (products, leads, goals, marketing events, feedback submissions, users).
5081
- */
5082
- const STANDARD_OBJECTS = /* @__PURE__ */ new Set([
5083
- "appointments",
5084
- "calls",
5085
- "carts",
5086
- "commerce_payments",
5087
- "communications",
5088
- "companies",
5089
- "contacts",
5090
- "courses",
5091
- "deals",
5092
- "emails",
5093
- "feedback_submissions",
5094
- "goals",
5095
- "invoices",
5096
- "leads",
5097
- "line_items",
5098
- "listings",
5099
- "marketing_events",
5100
- "meetings",
5101
- "notes",
5102
- "orders",
5103
- "postal_mail",
5104
- "products",
5105
- "projects",
5106
- "quotes",
5107
- "services",
5108
- "subscriptions",
5109
- "tasks",
5110
- "tickets",
5111
- "users"
5112
- ]);
5113
- /**
5114
- * The object type ID of each standard object, by config key. Limits Tracking keys its per-object entries by these.
5115
- * Source: "Object type ID values", retrieved 2026-09-24:
5116
- * https://developers.hubspot.com/docs/api-reference/latest/crm/understanding-the-crm
5117
- * A plain record: look a key up with `Object.hasOwn`, so `constructor` finds nothing.
5118
- */
5119
- const STANDARD_OBJECT_TYPE_IDS = {
5120
- appointments: "0-421",
5121
- calls: "0-48",
5122
- carts: "0-142",
5123
- commerce_payments: "0-101",
5124
- communications: "0-18",
5125
- companies: "0-2",
5126
- contacts: "0-1",
5127
- courses: "0-410",
5128
- deals: "0-3",
5129
- emails: "0-49",
5130
- feedback_submissions: "0-19",
5131
- goals: "0-74",
5132
- invoices: "0-53",
5133
- leads: "0-136",
5134
- line_items: "0-8",
5135
- listings: "0-420",
5136
- marketing_events: "0-54",
5137
- meetings: "0-47",
5138
- notes: "0-46",
5139
- orders: "0-123",
5140
- postal_mail: "0-116",
5141
- products: "0-7",
5142
- projects: "0-970",
5143
- quotes: "0-14",
5144
- services: "0-162",
5145
- subscriptions: "0-69",
5146
- tasks: "0-27",
5147
- tickets: "0-5",
5148
- users: "0-115"
5149
- };
5150
- /** One object's pull scope from its settings under `objects` and the property names its object file defines. */
5151
- function scopeOf(scope = {}, defined = []) {
5152
- return {
5153
- custom: scope.custom ?? true,
5154
- defined: new Set(defined),
5155
- exclude: excluder(scope.exclude),
5156
- include: new Set(scope.include ?? [])
5157
- };
5158
- }
5159
- /** The names of the properties the object files define on `object`, references included. */
5160
- function definedOn(ir, object) {
5161
- const prefix = `property:${object}/`;
5162
- return Object.keys(ir.resources).filter((address) => address.startsWith(prefix)).map((address) => address.slice(prefix.length));
5163
- }
5164
- /**
5165
- * A portal property is in scope when the object files define it or `include` names it, whatever `custom` and
5166
- * `exclude` say, or when it is custom, `custom` is on and `exclude` does not name it. validate refuses a name both
5167
- * `include` and `exclude` hold.
5168
- */
5169
- function inScope(scope, property) {
5170
- const { name } = property;
5171
- return scope.defined.has(name) || scope.include.has(name) || scope.custom && !property.hubspotDefined && !scope.exclude(name);
5172
- }
5173
- /** The `exclude` patterns of one object as a predicate over internal names. */
5174
- function excluder(patterns = []) {
5175
- const matchers = patterns.map(addressMatcher);
5176
- return (name) => matchers.some((matches) => matches(name));
5177
- }
5178
- /** The --only glob as a predicate over addresses. `*` matches any run of characters, `/` included. */
5179
- function addressMatcher(glob) {
5180
- if (glob === void 0) return () => true;
5181
- const literals = glob.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
5182
- const pattern = new RegExp(`^${literals.join(".*")}$`);
5183
- return (address) => pattern.test(address);
5184
- }
5185
5238
  const CONFIG$1$1 = "kalup.config.ts";
5186
5239
  /** HubSpot lists only non-sensitive properties unless asked, and takes one sensitivity per request. */
5187
5240
  const SENSITIVITIES$1 = [
@@ -5497,6 +5550,7 @@ function pinWarnings(rows, now = Date.now()) {
5497
5550
  const out = [];
5498
5551
  const seen = /* @__PURE__ */ new Set();
5499
5552
  for (const row of rows) {
5553
+ if (row.expires === void 0) continue;
5500
5554
  const pin = `${row.family} ${row.version}`;
5501
5555
  const expires = /* @__PURE__ */ new Date(`${row.expires}-01T00:00:00Z`);
5502
5556
  if (seen.has(pin) || expires.getTime() - now > pinWarningDays * 864e5) continue;
@@ -5617,23 +5671,23 @@ function fieldOf(unit) {
5617
5671
  /**
5618
5672
  * Why HubSpot cannot take an adopt or update of `kind`, or undefined when it can. A difference in `type` or
5619
5673
  * `hasUniqueValue` blocks whatever else the step holds, since the property has to be migrated. Written units must be
5620
- * writable, a read-only definition blocks writing its fields, and read-only options block writing an option.
5621
- * `observed` is the portal's resource: HubSpot answers 400 to turning showCurrencySymbol off while it holds a
5622
- * currencyPropertyName (observed, docs/hubspot.md), which config may leave out.
5674
+ * writable, a read-only definition blocks writing its fields, and read-only options block writing an option. HubSpot
5675
+ * answers 400 to turning showCurrencySymbol off once the property ever had a currencyPropertyName, `''` included, and
5676
+ * nothing clears it (observed 2026-10-01); `meta` carries the value as HubSpot returned it, which config may leave out.
5623
5677
  */
5624
- function writeBlock(kind, units, written, meta, observed) {
5678
+ function writeBlock(kind, units, written, meta) {
5625
5679
  const fixed = kind === "property" ? units.filter((u) => FIXED.has(u.unit) && u.class !== "converged") : [];
5626
5680
  if (fixed.length > 0) return {
5627
5681
  short: `${fixed.map((u) => u.unit).join(" and ")} ${fixed.length > 1 ? "differ" : "differs"}`,
5628
5682
  detail: fixed.map((u) => `config has ${u.unit} ${JSON.stringify(u.desired)} and the portal ${JSON.stringify(u.observed)}`).join("; "),
5629
5683
  fix: `change the builder to match the portal, or migrate: create a new property, copy the values over, point what uses this one at the new one, then run ${bin} rm on this one`
5630
5684
  };
5631
- const currency = observed?.definition?.currencyPropertyName;
5685
+ const currency = meta?.currencyPropertyName;
5632
5686
  const symbolOff = units.some((u) => u.unit === "showCurrencySymbol" && u.desired !== true);
5633
- if (written.includes("showCurrencySymbol") && symbolOff && typeof currency === "string" && currency !== "") return {
5687
+ if (written.includes("showCurrencySymbol") && symbolOff && currency !== void 0) return {
5634
5688
  short: "currency property set",
5635
- detail: `HubSpot does not turn showCurrencySymbol off while the property has currencyPropertyName ${JSON.stringify(currency)}`,
5636
- fix: "keep showCurrencySymbol: true, or clear the currency property in HubSpot first"
5689
+ detail: `HubSpot never turns showCurrencySymbol off once the property had a currencyPropertyName; this one holds ${JSON.stringify(currency)}, and clearing it does not lift the refusal`,
5690
+ fix: `keep showCurrencySymbol: true, or migrate: create a new property under another name, copy the values over, point what uses this one at the new one, then run ${bin} rm on this one`
5637
5691
  };
5638
5692
  const unwritable = written.filter((unit) => !WRITABLE[kind].has(fieldOf(unit)));
5639
5693
  if (unwritable.length > 0) return {
@@ -5657,9 +5711,10 @@ function writeBlock(kind, units, written, meta, observed) {
5657
5711
  }
5658
5712
  /**
5659
5713
  * Why a delete cannot run, or undefined when it can: HubSpot marks the property not archivable, or, for a group,
5660
- * properties still name it, active or archived, apart from those the same plan deletes first. On a developer test
5661
- * account (2026-09-29) HubSpot refused to archive a group that held an active property, and archived one whose
5662
- * properties were all archived; what a later restore of those properties then does is not confirmed.
5714
+ * active properties still name it, apart from those the same plan deletes first. HubSpot refuses to archive a group
5715
+ * that holds an active property and archives one whose properties are all archived (observed 2026-09-29 and
5716
+ * 2026-10-01); an archived group never comes back, and its archived properties can only be restored into another
5717
+ * group, so archived members do not block.
5663
5718
  */
5664
5719
  function deleteBlock(meta, members) {
5665
5720
  if (meta?.modificationMetadata?.archivable === false) return {
@@ -5669,12 +5724,11 @@ function deleteBlock(meta, members) {
5669
5724
  };
5670
5725
  if (members === void 0) return;
5671
5726
  const active = members.active.filter((name) => !members.deleted.has(name));
5672
- const archived = members.archived.filter((name) => !members.deleted.has(name));
5673
- if (active.length === 0 && archived.length === 0) return;
5727
+ if (active.length === 0) return;
5674
5728
  return {
5675
5729
  short: "group still holds properties",
5676
- detail: `properties in HubSpot still name this group: ${[...active.length > 0 ? [active.join(", ")] : [], ...archived.length > 0 ? [`archived: ${archived.join(", ")}`] : []].join("; ")}`,
5677
- fix: "move them to another group or delete them first; HubSpot refused to archive a group that held an active property on a developer test account (2026-09-29)"
5730
+ detail: `properties in HubSpot still name this group: ${active.join(", ")}`,
5731
+ fix: "move them to another group or delete them first; HubSpot archives a group only once every property in it is archived"
5678
5732
  };
5679
5733
  }
5680
5734
  function classOf(change, context) {
@@ -6678,8 +6732,8 @@ function plan$1(input) {
6678
6732
  }
6679
6733
  /**
6680
6734
  * What the command reads between the observation and plan: the Limits Tracking readings for the creates it plans, and
6681
- * the archived properties of each object that exists and gets a property create, holds a property state owns that
6682
- * HubSpot no longer has, or gets a group delete an entry owns.
6735
+ * the archived properties of each object that exists and gets a property create or holds a property state owns that
6736
+ * HubSpot no longer has.
6683
6737
  */
6684
6738
  function planReads(input) {
6685
6739
  const { observation } = input;
@@ -6691,14 +6745,9 @@ function planReads(input) {
6691
6745
  }, coverage);
6692
6746
  const creates = decided.steps.filter((s) => s.action === "create" && s.risk !== "blocked" && kindOf$3(s.address) === "property").map((s) => s.address);
6693
6747
  const gone = decided.gone.filter((g) => kindOf$3(g.address) === "property");
6694
- const groupDeletes = decided.steps.filter((s) => s.action === "delete" && s.blocked?.reason !== "not-owned" && kindOf$3(s.address) === "group");
6695
6748
  const ids = objectTypeIds(observation);
6696
6749
  const archived = {};
6697
- for (const key of [
6698
- ...creates,
6699
- ...gone.map((g) => g.address),
6700
- ...groupDeletes.map((s) => s.address)
6701
- ].map(objectOf)) {
6750
+ for (const key of [...creates, ...gone.map((g) => g.address)].map(objectOf)) {
6702
6751
  const objectType = own$2(ids, key) ?? (STANDARD_OBJECTS.has(key) ? key : void 0);
6703
6752
  if (objectType !== void 0) archived[key] = objectType;
6704
6753
  }
@@ -7053,7 +7102,7 @@ function present(context, address, resource, owner) {
7053
7102
  function referenced(context, address) {
7054
7103
  const { target } = context.input;
7055
7104
  const short = "HubSpot-defined or calculated";
7056
- return blocked(address, "adopt", "unsupported", short, `${short} in this portal; run ${bin} pull to make it a reference`, `run ${pullCommand(target, address)}`);
7105
+ return blocked(address, "adopt", "unsupported", short, `${short} in this portal, or named with a prefix HubSpot reserves; run ${bin} pull to make it a reference`, `run ${pullCommand(target, address)}`);
7057
7106
  }
7058
7107
  function settle$1(context, r) {
7059
7108
  const { action, address, kind, observed, owner, units } = r;
@@ -7069,7 +7118,7 @@ function settle$1(context, r) {
7069
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");
7070
7119
  const meta = context.input.observation.meta?.[address];
7071
7120
  reorder(r, changes, meta);
7072
- const block = writeBlock(kind, units, changes.map((c) => c.unit), meta, observed);
7121
+ const block = writeBlock(kind, units, changes.map((c) => c.unit), meta);
7073
7122
  if (block) return blocked(address, action, "unsupported", block.short, block.detail, block.fix);
7074
7123
  const takeover = takeoverUnits(context, r, changes);
7075
7124
  const refused = takeover.size > 0 ? takeoverBlock(context, address, action, [...takeover]) : void 0;
@@ -7295,16 +7344,10 @@ function takeoverDelete(context, address, deleted) {
7295
7344
  ...notes
7296
7345
  });
7297
7346
  if (!context.coverage.complete) return noted(blocked(address, "delete", "scope", "read incomplete", `takeover would archive ${name}, and the read of target ${target} was incomplete, so takeover removes nothing there`, INCOMPLETE_FIX));
7298
- let members;
7299
- if (kindOf$3(address) === "group") {
7300
- const archived = own$2(input.archivedProperties, key);
7301
- if (archived === void 0) return noted(blocked(address, "delete", "unsupported", "members unknown", `the archived properties of ${key} were not read, so whether any names this group is unknown`));
7302
- members = {
7303
- active: own$2(observation.members?.[key] ?? {}, name) ?? [],
7304
- archived: archived.filter((p) => p.groupName === name).map((p) => p.name),
7305
- deleted: deleted.get(key) ?? /* @__PURE__ */ new Set()
7306
- };
7307
- }
7347
+ const members = kindOf$3(address) === "group" ? {
7348
+ active: own$2(observation.members?.[key] ?? {}, name) ?? [],
7349
+ deleted: deleted.get(key) ?? /* @__PURE__ */ new Set()
7350
+ } : void 0;
7308
7351
  const block = deleteBlock(observation.meta?.[address], members);
7309
7352
  if (block) return noted(blocked(address, "delete", "unsupported", block.short, block.detail, block.fix));
7310
7353
  if (!policy.allowDestroy) return noted(blocked(address, "delete", "policy", "deletes not allowed", `takeover archives ${name}, and target ${target} does not allow deletes`, `keep it in config: run ${pullCommand(target, address)}; or leave it unmanaged: add '${name}' to objects.${key}.exclude; or archive it: set allowDestroy: true under targets.${target} in kalup.config.ts`));
@@ -7391,16 +7434,10 @@ function destroy(context, address, entry, deleted) {
7391
7434
  const key = objectOf(address);
7392
7435
  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");
7393
7436
  const name = portalName$1(context, address);
7394
- let members;
7395
- if (kindOf$3(address) === "group") {
7396
- const archived = own$2(input.archivedProperties, key);
7397
- if (archived === void 0) return blocked(address, "delete", "unsupported", "members unknown", `the archived properties of ${key} were not read, so whether any names this group is unknown`);
7398
- members = {
7399
- active: own$2(input.observation.members?.[key] ?? {}, name) ?? [],
7400
- archived: archived.filter((p) => p.groupName === name).map((p) => p.name),
7401
- deleted: deleted.get(key) ?? /* @__PURE__ */ new Set()
7402
- };
7403
- }
7437
+ const members = kindOf$3(address) === "group" ? {
7438
+ active: own$2(input.observation.members?.[key] ?? {}, name) ?? [],
7439
+ deleted: deleted.get(key) ?? /* @__PURE__ */ new Set()
7440
+ } : void 0;
7404
7441
  const block = deleteBlock(input.observation.meta?.[address], members);
7405
7442
  if (block) return blocked(address, "delete", "unsupported", block.short, block.detail, block.fix);
7406
7443
  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`);
@@ -7887,7 +7924,7 @@ function recordObject(out, address, resource) {
7887
7924
  else out.unsupported.push(address);
7888
7925
  }
7889
7926
  function needsArchived(step) {
7890
- return step.action === "create" && kindOf$2(step.address) === "property" || step.action === "delete";
7927
+ return step.action === "create" && kindOf$2(step.address) === "property";
7891
7928
  }
7892
7929
  async function checkBindings(plan, read, overrides) {
7893
7930
  const effects = plan.steps.filter(hasEffect);
@@ -8466,7 +8503,7 @@ function writeRefusal(step, trusted, observation) {
8466
8503
  if (unsupported || observed?.managed === false) return unsupported ? "Kalup does not write this kind of property" : "it is HubSpot-defined or calculated";
8467
8504
  const units = unitsOf$1(step, trusted, observed);
8468
8505
  const written = (step.changes ?? []).map((c) => c.unit);
8469
- return writeBlock(kindOf$1(step.address), units, written, observation.meta[step.address], observed)?.detail;
8506
+ return writeBlock(kindOf$1(step.address), units, written, observation.meta[step.address])?.detail;
8470
8507
  }
8471
8508
  function deleteRefusal(plan, step, { owner, overrides }, observation) {
8472
8509
  const takeover = step.labels?.includes("takeover") === true;
@@ -8483,14 +8520,11 @@ function deleteRefusal(plan, step, { owner, overrides }, observation) {
8483
8520
  const kept = takeover ? keptByRead(overrides, plan.target.name, step.address, observed, observation.schemaNamed[key] ?? []) : void 0;
8484
8521
  return kept === void 0 ? deleteBlock(observation.meta[step.address])?.detail : `takeover never archives it: ${kept}`;
8485
8522
  }
8486
- const archived = observation.archived[key];
8487
- if (archived === void 0) return `the archived properties of ${key} were not read`;
8488
8523
  const name = names.portalName(step.address);
8489
8524
  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";
8490
8525
  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)));
8491
8526
  return deleteBlock(void 0, {
8492
8527
  active: observation.members[key]?.[name] ?? [],
8493
- archived: archived.filter((p) => p.groupName === name).map((p) => p.name),
8494
8528
  deleted
8495
8529
  })?.detail;
8496
8530
  }
@@ -8673,6 +8707,8 @@ const LINE_MAX$1 = 1e3;
8673
8707
  const SCOPE = /the scope (\S+)\./;
8674
8708
  const TEXT_MAX = 400;
8675
8709
  const USES = /used in (\d+) places?/;
8710
+ const USES_SHOWN = 5;
8711
+ const USE_MAX = 60;
8676
8712
  /** Thrown by a request made after the signal aborted. */
8677
8713
  var Stopped = class extends Error {};
8678
8714
  /**
@@ -9078,13 +9114,32 @@ function rejected(run, step, sent) {
9078
9114
  };
9079
9115
  }
9080
9116
  function refusal(run, step, sent, plan, otherwise) {
9117
+ if (sent.status === 403 && sent.message.includes("sensitive-data-property-create")) {
9118
+ const key = objectOf(step.address);
9119
+ const object = STANDARD_OBJECTS.has(key) ? key : "custom";
9120
+ const level = step.desired?.dataSensitivity === "highly_sensitive" ? "highly_sensitive" : "sensitive";
9121
+ return {
9122
+ why: `HubSpot refuses the create because the key lacks the sensitive data scope for ${key}`,
9123
+ fix: `add the scope crm.objects.${object}.${level}.write to the write key, then run ${plan}`
9124
+ };
9125
+ }
9081
9126
  const reason = reasonOf(sent);
9127
+ if (reason === "PORTAL_NOT_ENABLED_FOR_SENSITIVE_DATA") return {
9128
+ why: "HubSpot has sensitive data turned off for this portal",
9129
+ fix: `turn it on under Settings > Privacy & Consent > Sensitive data, then run ${plan}`
9130
+ };
9131
+ if (reason === "ONLY_CURRENCY_PROPERTIES_CAN_SPECIFY_CURRENCY") return {
9132
+ why: `HubSpot never turns showCurrencySymbol off once ${portalName(run, step)} had a currencyPropertyName`,
9133
+ fix: `keep showCurrencySymbol: true, or migrate to a new property under another name, then run ${plan}`
9134
+ };
9082
9135
  if (reason === "CANNOT_DELETE_PROPERTY_IN_USE") {
9083
- const count = USES.exec(sent.message)?.[1];
9136
+ const count = sent.context?.usageCount?.[0] ?? USES.exec(sent.message)?.[1];
9084
9137
  const counted = count === void 0 ? "" : ` (HubSpot counts ${count} use${count === "1" ? "" : "s"})`;
9138
+ const uses = usesOf(sent);
9139
+ const where = uses.length === 0 ? "" : ` (${uses.join(", ")})`;
9085
9140
  return {
9086
9141
  why: `HubSpot refuses to archive ${portalName(run, step)} because it is in use${counted}`,
9087
- fix: `remove those uses in HubSpot first, then run ${plan}`
9142
+ fix: `remove those uses in HubSpot first${where}, then run ${plan}`
9088
9143
  };
9089
9144
  }
9090
9145
  if (reason === "GROUP_WITH_ACTIVE_PROPERTIES") return {
@@ -9097,6 +9152,13 @@ function refusal(run, step, sent, plan, otherwise) {
9097
9152
  };
9098
9153
  return otherwise;
9099
9154
  }
9155
+ function usesOf(sent) {
9156
+ const uses = (sent.errors ?? []).filter((e) => e.subCategory?.endsWith("PROPERTY_USAGE")).map((e) => {
9157
+ return sanitize(`${(e.context?.parentDisplayType?.[0] ?? e.context?.parentType?.[0] ?? "use").toLowerCase().replaceAll("_", " ")} ${e.context?.parentName?.[0] ?? ""}`.trim(), USE_MAX);
9158
+ });
9159
+ const rest = uses.length - USES_SHOWN;
9160
+ return rest > 0 ? [...uses.slice(0, USES_SHOWN), `and ${rest} more`] : uses;
9161
+ }
9100
9162
  function reasonOf(sent) {
9101
9163
  return sent.subCategory?.split(".").at(-1);
9102
9164
  }
@@ -12507,6 +12569,28 @@ function pick$4(d, fields) {
12507
12569
  function own(record, key) {
12508
12570
  return Object.hasOwn(record, key) ? record[key] : void 0;
12509
12571
  }
12572
+ /** The key's scopes, or undefined when HubSpot refused the request, gave no answer, or answered without a scope list. */
12573
+ async function readTokenInfo(http, key) {
12574
+ let answer;
12575
+ try {
12576
+ answer = await http.request({
12577
+ type: "tokenInfo",
12578
+ path: "read",
12579
+ body: { tokenKey: key }
12580
+ });
12581
+ } catch (error) {
12582
+ if (error instanceof KalupError) return;
12583
+ throw error;
12584
+ }
12585
+ const scopes = typeof answer === "object" && answer !== null ? answer.scopes : void 0;
12586
+ if (!(Array.isArray(scopes) && scopes.every((scope) => typeof scope === "string"))) return;
12587
+ return { scopes: scopes.map((scope) => sanitize(scope)) };
12588
+ }
12589
+ /** Whether `held` names `scope`, as is or with a version suffix HubSpot adds (`.sensitive.write.v2`). */
12590
+ function holdsScope(held, scope) {
12591
+ return held.some((name) => name === scope || name.replace(VERSION_SUFFIX, "") === scope);
12592
+ }
12593
+ const VERSION_SUFFIX = /\.v\d+$/;
12510
12594
  /**
12511
12595
  * The target for `requested`, the name a command was given, or the reason there is none. An unknown requested name
12512
12596
  * never falls back to the default, and the first of several targets is never chosen. Choices are in declaration order.
@@ -12643,7 +12727,7 @@ function projectLayout(root) {
12643
12727
  code: "E_DIR_AMBIGUOUS",
12644
12728
  message: `both ${DEFAULT_DIR}/ and ${LEGACY_DIR}/ hold .ts files, and ${configFile} does not say which one holds the object files`,
12645
12729
  file: configFile,
12646
- fix: `add dir: '${LEGACY_DIR}' to ${configFile} to keep the 0.1 folder, or dir: '${DEFAULT_DIR}' when the object files are there`
12730
+ fix: `add dir: '${LEGACY_DIR}' to ${configFile} to keep the old folder, or dir: '${DEFAULT_DIR}' when the object files are there`
12647
12731
  }, exitCodes.invalid);
12648
12732
  }
12649
12733
  function holdsTs(dir) {
@@ -16361,19 +16445,23 @@ async function status(ctx) {
16361
16445
  });
16362
16446
  };
16363
16447
  const names = ctx.flags.target === void 0 ? Object.keys(loaded.config.targets) : [ctx.flags.target];
16448
+ const recommended = {
16449
+ scope: limitScope(Object.keys(loaded.config.objects)),
16450
+ neededFor: ["the property limit check in plan"]
16451
+ };
16452
+ const writeScopes = scopeLines(Object.keys(loaded.config.objects), "write");
16453
+ const wanted = {
16454
+ recommended,
16455
+ writeScopes
16456
+ };
16364
16457
  const targets = [];
16365
- for (const name of names) targets.push(await checkTarget(root, name, loaded, found, warn));
16458
+ for (const name of names) targets.push(await checkTarget(root, name, loaded, wanted, found, warn));
16366
16459
  found.push(...pinWarnings(Object.values(registry)));
16367
16460
  const counts = {
16368
16461
  objects: Object.keys(loaded.config.objects).length,
16369
16462
  properties: count(loaded.ir, "property"),
16370
16463
  groups: count(loaded.ir, "group")
16371
16464
  };
16372
- const recommended = {
16373
- scope: limitScope(Object.keys(loaded.config.objects)),
16374
- neededFor: ["the property limit check in plan"]
16375
- };
16376
- const writeScopes = scopeLines(Object.keys(loaded.config.objects), "write");
16377
16465
  const exitCode = exitCodeOf(targets);
16378
16466
  const lines = [
16379
16467
  `${bin} ${version}`,
@@ -16396,7 +16484,7 @@ async function status(ctx) {
16396
16484
  text: `${lines.join("\n")}\n`
16397
16485
  };
16398
16486
  }
16399
- async function checkTarget(root, name, loaded, issues, warn) {
16487
+ async function checkTarget(root, name, loaded, wanted, issues, warn) {
16400
16488
  const target = loaded.config.targets[name] ?? {};
16401
16489
  const keyVariable = target.credentials?.read.env ?? "HUBSPOT_SERVICE_KEY";
16402
16490
  const writeVariable = target.credentials?.write?.env ?? keyVariable;
@@ -16425,9 +16513,11 @@ async function checkTarget(root, name, loaded, issues, warn) {
16425
16513
  write
16426
16514
  };
16427
16515
  let http;
16516
+ let key;
16428
16517
  try {
16518
+ ({key} = resolveReadKey(target, root));
16429
16519
  http = createHttp({
16430
- key: resolveReadKey(target, root).key,
16520
+ key,
16431
16521
  warn
16432
16522
  });
16433
16523
  } catch (error) {
@@ -16445,7 +16535,35 @@ async function checkTarget(root, name, loaded, issues, warn) {
16445
16535
  }
16446
16536
  out.protected = policyOf(target, out.account.accountType).protected;
16447
16537
  out.protectedBy = target.protected === void 0 ? "default" : "config";
16538
+ let info;
16539
+ try {
16540
+ info = await readTokenInfo(http, key);
16541
+ } catch (error) {
16542
+ return fail(out, "unreachable", error, issues);
16543
+ }
16448
16544
  for (const probe of probes(loaded)) out.scopes.push(await checkScope(http, probe, issues));
16545
+ if (info !== void 0) out.keyScopes = keyScopesOf(info.scopes, out, wanted, issues);
16546
+ return out;
16547
+ }
16548
+ function keyScopesOf(held, t, wanted, issues) {
16549
+ const { recommended, writeScopes } = wanted;
16550
+ const out = {
16551
+ held,
16552
+ recommended: {
16553
+ ...recommended,
16554
+ ok: holdsScope(held, recommended.scope)
16555
+ }
16556
+ };
16557
+ if (t.write.separate) return out;
16558
+ out.write = writeScopes.map((line) => ({
16559
+ ...line,
16560
+ ok: holdsScope(held, line.scope)
16561
+ }));
16562
+ for (const missing of out.write.filter((s) => !s.ok)) issues.push({
16563
+ code: "W_WRITE_SCOPE",
16564
+ message: `the key in ${t.keyVariable} does not hold ${missing.scope}, which apply needs for ${missing.neededFor.join(", ")}`,
16565
+ fix: `add the scope ${missing.scope} to the key`
16566
+ });
16449
16567
  return out;
16450
16568
  }
16451
16569
  async function checkScope(http, probe, issues) {
@@ -16570,18 +16688,21 @@ function describe(root, t, recommended, writeScopes) {
16570
16688
  const a = t.account;
16571
16689
  const scopes = t.scopes.map(scopeText);
16572
16690
  const why = t.protectedBy === "default" ? ` (${a?.accountType} account, default)` : "";
16691
+ const limit = t.keyScopes === void 0 ? ", not checked" : checkedText(t.keyScopes.recommended.ok);
16573
16692
  return [
16574
16693
  `${head}: portal ${t.portalId} matches, ${a?.accountType}, ${a?.uiDomain}, ${a?.timeZone}, protected: ${t.protected ? "yes" : "no"}${why}`,
16575
16694
  ` Scopes: ${scopes.length > 0 ? scopes.join(", ") : "none needed"}`,
16576
- ` Also recommended: ${recommended}, not checked (the property limit check in plan)`,
16695
+ ` Also recommended: ${recommended}${limit} (the property limit check in plan)`,
16577
16696
  writeLine(t, writeScopes),
16578
16697
  stateLine(root, t.state)
16579
16698
  ];
16580
16699
  }
16581
16700
  function writeLine(t, writeScopes) {
16582
- const scopes = writeScopes.map((s) => s.scope).join(", ");
16701
+ const checked = t.keyScopes?.write;
16702
+ const scopes = checked === void 0 ? writeScopes.map((s) => s.scope).join(", ") : checked.map((s) => `${s.scope} ${s.ok ? "ok" : "missing"}`).join(", ");
16583
16703
  const needs = t.write.separate ? `, which needs the read scopes and ${scopes}` : `, which also needs ${scopes}`;
16584
- return ` Write: apply uses ${t.write.keyVariable}${scopes === "" ? "" : `${needs}, not checked`}`;
16704
+ const tail = checked === void 0 ? ", not checked" : "";
16705
+ return ` Write: apply uses ${t.write.keyVariable}${scopes === "" ? "" : `${needs}${tail}`}`;
16585
16706
  }
16586
16707
  function stateLine(root, state) {
16587
16708
  const rel = relative(root, state.path);
@@ -16594,6 +16715,9 @@ function stateLine(root, state) {
16594
16715
  else if (last) applied = `plan ${sanitize(last.planId)} at ${sanitize(last.at)}, ${last.outcome}`;
16595
16716
  return ` State: ${path}, lineage ${state.lineage}, serial ${state.serial}. Last apply: ${applied}`;
16596
16717
  }
16718
+ function checkedText(ok) {
16719
+ return ok ? " ok" : " missing";
16720
+ }
16597
16721
  function scopeText(s) {
16598
16722
  if (s.ok) return `${s.scope} ok`;
16599
16723
  return s.error ? `${s.scope} failed (${s.error})` : `${s.scope} missing (needed for ${s.neededFor.join(", ")})`;
@@ -16868,7 +16992,7 @@ var FmtCommand = class extends KalupCommand {
16868
16992
  static flags = {
16869
16993
  check: Flags.boolean({ summary: "Report what would change, write nothing, and exit 2 when a file would change." }),
16870
16994
  "exit-code": Flags.boolean({
16871
- summary: "Accepted for 0.1 scripts: --check exits 2 on changes already.",
16995
+ summary: "Accepted for older scripts: --check exits 2 on changes already.",
16872
16996
  hidden: true
16873
16997
  })
16874
16998
  };
@@ -1,4 +1,4 @@
1
- import { n as Prompter, r as Result, t as Handler } from "./context-8pARDYRR.mjs";
1
+ import { n as Prompter, r as Result, t as Handler } from "./context-C1tH5K0x.mjs";
2
2
  import { Command } from "@oclif/core";
3
3
  //#region src/host/commands.d.ts
4
4
  /**
package/dist/commands.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { _ as TargetRebindCommand, a as CompareCommand, c as InitCommand, d as PlanCommand, f as PullCommand, g as StatusCommand, h as StateRebuildCommand, i as COMMANDS, l as IrCommand, m as SnapshotCommand, n as ApplyCommand, o as DocsCommand, p as RmCommand, r as BlueprintUpgradeCommand, s as FmtCommand, t as AddCommand, u as KalupCommand, v as ValidateCommand } from "./commands-Bk1w1oup.mjs";
1
+ import { _ as TargetRebindCommand, a as CompareCommand, c as InitCommand, d as PlanCommand, f as PullCommand, g as StatusCommand, h as StateRebuildCommand, i as COMMANDS, l as IrCommand, m as SnapshotCommand, n as ApplyCommand, o as DocsCommand, p as RmCommand, r as BlueprintUpgradeCommand, s as FmtCommand, t as AddCommand, u as KalupCommand, v as ValidateCommand } from "./commands-T4j_GVBD.mjs";
2
2
  export { AddCommand, ApplyCommand, BlueprintUpgradeCommand, COMMANDS, CompareCommand, DocsCommand, FmtCommand, InitCommand, IrCommand, KalupCommand, PlanCommand, PullCommand, RmCommand, SnapshotCommand, StateRebuildCommand, StatusCommand, TargetRebindCommand, ValidateCommand };
@@ -1237,6 +1237,16 @@ declare const issues: {
1237
1237
  output: string[];
1238
1238
  };
1239
1239
  };
1240
+ W_WRITE_SCOPE: {
1241
+ exit: string;
1242
+ title: string;
1243
+ summary: string;
1244
+ when: string[];
1245
+ fix: string[];
1246
+ example: {
1247
+ output: string[];
1248
+ };
1249
+ };
1240
1250
  };
1241
1251
  type IssueCode = keyof typeof issues;
1242
1252
  /** One entry of a command's issues[], as the envelope contract defines it. */
@@ -1,4 +1,4 @@
1
- import { A as sanitize, C as usageError, D as disclaimer, E as bin, O as escapeJson, S as versionText, T as KalupError, b as formats, k as exitCodes, u as KalupCommand, w as IssueError, x as version, y as PATH_MAX } from "./commands-Bk1w1oup.mjs";
1
+ import { A as sanitize, C as usageError, D as disclaimer, E as bin, O as escapeJson, S as versionText, T as KalupError, b as formats, k as exitCodes, u as KalupCommand, w as IssueError, x as version, y as PATH_MAX } from "./commands-T4j_GVBD.mjs";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
package/dist/host.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { i as ExitCode, n as Prompter, r as Result } from "./context-8pARDYRR.mjs";
1
+ import { i as ExitCode, n as Prompter, r as Result } from "./context-C1tH5K0x.mjs";
2
2
  //#region src/host/host.d.ts
3
3
  interface Out {
4
4
  write: (text: string) => unknown;
package/dist/host.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { n as isInteractive, r as run, t as execute } from "./host-BoqS00po.mjs";
1
+ import { n as isInteractive, r as run, t as execute } from "./host-CDDeLV5T.mjs";
2
2
  export { execute, isInteractive, run };
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { n as isInteractive, r as run } from "./host-BoqS00po.mjs";
2
+ import { n as isInteractive, r as run } from "./host-CDDeLV5T.mjs";
3
3
  //#region src/index.ts
4
4
  process.exitCode = await run(process.argv.slice(2), {
5
5
  cwd: process.cwd(),
package/docs/apply.md CHANGED
@@ -61,4 +61,4 @@ There is no resume and no rollback. After a run that did not finish, run `kalup
61
61
 
62
62
  ## Limits
63
63
 
64
- A read and the write after it are not atomic: an edit in HubSpot between the two is overwritten for that field. The lock keeps apart one user's commands on one machine only; in CI, one workflow per portal applies, in a concurrency group. A delete checks no use first; HubSpot refused to archive a property a calculation property used (developer test account, 2026-09-29).
64
+ A read and the write after it are not atomic: an edit in HubSpot between the two is overwritten for that field. The lock keeps apart one user's commands on one machine only; in CI, one workflow per portal applies, in a concurrency group. A delete checks no use first; HubSpot refuses to archive a property a workflow, list, form or calculation uses, and apply names each use.
package/docs/config.md CHANGED
@@ -56,7 +56,7 @@ A definition with `label`, `group` and `fieldType` is managed: the fields presen
56
56
  - On `p.string`, `p.stringArray`, `p.json` and `p.phoneNumber`: `textDisplayHint` (`unformatted_single_line`, `multi_line`, `email`, `phone_number`, `domain_name`, `ip_address`, `physical_address`, `postal_code`). HubSpot takes no value that removes a hint.
57
57
  - `calculationFormula` with `fieldType: 'calculation_equation'`, in HubSpot's formula syntax. HubSpot stores its own spelling (`a+1` as `a + 1`); write it as pull does, or plan notes the difference. A formula change is risky.
58
58
 
59
- A field another builder or field rules out is `E_DEFINITION_FIELD`. HubSpot ignores `dateDisplayHint`, so it is no field. `group` must name a group declared under `groups` for the same object, in any export or file (`E_UNKNOWN_GROUP`). A managed internal name starting with `hs_` is `E_HS_PREFIX`.
59
+ A field another builder or field rules out is `E_DEFINITION_FIELD`. HubSpot ignores `dateDisplayHint`, so it is no field. `group` must name a group declared under `groups` for the same object, in any export or file (`E_UNKNOWN_GROUP`). A managed internal name starting with `hs_` or `a<digits>_` is `E_HS_PREFIX`: HubSpot reserves `hs_` for its own properties and `a<appId>_` for an integration's, and refuses a create with either. A reference may carry the prefix, and pull writes such properties as references.
60
60
 
61
61
  No definition makes a reference: never created, changed or removed. `p.enum` or `p.multiEnum` with `options` and nothing else is a reference with typed options, which pull refreshes.
62
62
 
@@ -4,7 +4,7 @@ A blueprint is not a valid `blueprint/1` document. Exit 1. Nothing was written.
4
4
 
5
5
  ## When
6
6
 
7
- `kalup add` and `kalup blueprint upgrade` parse the source as JSON, never as code. Another `blueprintVersion` is refused first. Then they check `blueprint-1.schema.json` and the rules the schema cannot state: addresses of the form `group:<object>/<name>` or `property:<object>/<name>` that match their type, plain names that never start with `hs_` (a group `$ref` names a plain group too), unique option values, aliases that name an option, and a codec that fits the HubSpot type and field type. Text that is not JSON or UTF-8 is refused, and so are names a prefix makes invalid. Each issue names its path; quoted text is sanitized.
7
+ `kalup add` and `kalup blueprint upgrade` parse the source as JSON, never as code. Another `blueprintVersion` is refused first. Then they check `blueprint-1.schema.json` and the rules the schema cannot state: addresses of the form `group:<object>/<name>` or `property:<object>/<name>` that match their type, plain names that never start with a prefix HubSpot reserves, `hs_` or `a<digits>_` (a group `$ref` names a plain group too), unique option values, aliases that name an option, and a codec that fits the HubSpot type and field type. Text that is not JSON or UTF-8 is refused, and so are names a prefix makes invalid. Each issue names its path; quoted text is sanitized.
8
8
 
9
9
  ## Fix
10
10
 
@@ -13,5 +13,5 @@ A blueprint is third-party data: ask its author for a version that passes, `blue
13
13
  ## Example
14
14
 
15
15
  ```
16
- E_BLUEPRINT_SCHEMA: property name 'hs_renewal_flag' starts with hs_, the prefix HubSpot uses for its own names (fix: a blueprint is third-party data: ask its author for a version that passes, or fix your own copy of the file) (docs: errors/E_BLUEPRINT_SCHEMA.md)
16
+ E_BLUEPRINT_SCHEMA: property name 'hs_renewal_flag' starts with hs_, a prefix HubSpot reserves (fix: a blueprint is third-party data: ask its author for a version that passes, or fix your own copy of the file) (docs: errors/E_BLUEPRINT_SCHEMA.md)
17
17
  ```
@@ -8,7 +8,7 @@ A property definition states a field HubSpot would refuse or misread for this pr
8
8
 
9
9
  - `numberDisplayHint`, `showCurrencySymbol` and `currencyPropertyName` belong to `p.number`, and `textDisplayHint` to `p.string`, `p.stringArray`, `p.json` and `p.phoneNumber`. HubSpot stores them on any property but shows them only on those.
10
10
  - `calculationFormula` needs `fieldType: 'calculation_equation'`. Sent with another field type, HubSpot turns the property into a calculation.
11
- - `currencyPropertyName` needs `showCurrencySymbol: true`. HubSpot refuses it otherwise (`ONLY_CURRENCY_PROPERTIES_CAN_SPECIFY_CURRENCY`).
11
+ - `currencyPropertyName` needs `showCurrencySymbol: true`. HubSpot refuses it otherwise (`ONLY_CURRENCY_PROPERTIES_CAN_SPECIFY_CURRENCY`), and an empty `''` is refused by Kalup: HubSpot stores it as a value and then never turns the symbol off again (live runs, 2026-10-01).
12
12
  - `displayOrder` is an integer from -1 up.
13
13
  - `p.owner` takes no `options`: HubSpot fills them with the account's users and refuses a create that sends any.
14
14
 
@@ -1,17 +1,17 @@
1
1
  # E_DIR_AMBIGUOUS
2
2
 
3
- Both `hubspot/` and the 0.1 folder `kalup/` hold .ts files, and `kalup.config.ts` does not say which one holds the object files. Exit 3. Nothing was read or written.
3
+ Both `hubspot/` and the old default folder `kalup/` hold .ts files, and `kalup.config.ts` does not say which one holds the object files. Exit 3. Nothing was read or written.
4
4
 
5
5
  ## When
6
6
 
7
- Without `dir` in `kalup.config.ts`, Kalup reads `hubspot/`, or a 0.1 project's `kalup/` while `hubspot/` holds no .ts file (`W_LEGACY_DIR`). When both hold .ts files, such as a half-done move or a HubSpot developer project in `hubspot/`, Kalup does not guess: reading the wrong folder would make everything in the other look removed from config.
7
+ Without `dir` in `kalup.config.ts`, Kalup reads `hubspot/`, or an older project's `kalup/` while `hubspot/` holds no .ts file (`W_LEGACY_DIR`). When both hold .ts files, such as a half-done move or a HubSpot developer project in `hubspot/`, Kalup does not guess: reading the wrong folder would make everything in the other look removed from config.
8
8
 
9
9
  ## Fix
10
10
 
11
- Add `dir: 'kalup'` to `kalup.config.ts` to keep the 0.1 folder, or `dir: 'hubspot'` when the object files are there. Then move or remove the other folder's copy of the object files.
11
+ Add `dir: 'kalup'` to `kalup.config.ts` to keep the old folder, or `dir: 'hubspot'` when the object files are there. Then move or remove the other folder's copy of the object files.
12
12
 
13
13
  ## Example
14
14
 
15
15
  ```
16
- kalup.config.ts: E_DIR_AMBIGUOUS: both hubspot/ and kalup/ hold .ts files, and kalup.config.ts does not say which one holds the object files (fix: add dir: 'kalup' to kalup.config.ts to keep the 0.1 folder, or dir: 'hubspot' when the object files are there) (docs: errors/E_DIR_AMBIGUOUS.md)
16
+ kalup.config.ts: E_DIR_AMBIGUOUS: both hubspot/ and kalup/ hold .ts files, and kalup.config.ts does not say which one holds the object files (fix: add dir: 'kalup' to kalup.config.ts to keep the old folder, or dir: 'hubspot' when the object files are there) (docs: errors/E_DIR_AMBIGUOUS.md)
17
17
  ```
@@ -1,10 +1,10 @@
1
1
  # E_HS_PREFIX
2
2
 
3
- A managed property's internal name starts with `hs_`. Exit 3.
3
+ A managed property's internal name starts with `hs_` or `a<digits>_`. Exit 3.
4
4
 
5
5
  ## When
6
6
 
7
- HubSpot uses `hs_` for its own properties. Kalup never claims that prefix for a property it would own. Whether HubSpot refuses such a create is not confirmed. A reference (no definition) may carry the prefix.
7
+ HubSpot reserves `hs_` for its own properties and `a<appId>_` for an integration's, and refuses a create with either (400, live runs 2026-10-01). Kalup never claims those prefixes for a property it would own; `pull` writes such a property as a reference. A reference (no definition) may carry the prefix.
8
8
 
9
9
  ## Fix
10
10
 
@@ -17,5 +17,5 @@ plotCount: p.number('hs_plot_count', { label: 'Plot count', group: 'orchard', fi
17
17
  ```
18
18
 
19
19
  ```
20
- hubspot/objects/companies.ts:9: E_HS_PREFIX: 'hs_plot_count' starts with hs_, the prefix HubSpot uses for its own properties (fix: rename the property, or drop label, group and fieldType to reference it) (docs: errors/E_HS_PREFIX.md)
20
+ hubspot/objects/companies.ts:9: E_HS_PREFIX: 'hs_plot_count' starts with hs_, a prefix HubSpot reserves (hs_ for its own properties, a<digits>_ for an integration's) (fix: rename the property, or drop label, group and fieldType to reference it) (docs: errors/E_HS_PREFIX.md)
21
21
  ```
@@ -4,7 +4,7 @@ HubSpot returned an error Kalup has no other code for. Exit 1.
4
4
 
5
5
  ## When
6
6
 
7
- A 400, a 404, a 5xx that three retries did not clear, or a success whose body is not JSON (often a proxy's HTML page). The issue holds the status, the method, the path and HubSpot's message when it sent one. In `apply`, a refusal whose reason HubSpot names and Kalup knows says it in plain words: a property in use, a group that still holds properties, or a property name that exists.
7
+ A 400, a 404, a 5xx that three retries did not clear, or a success whose body is not JSON (often a proxy's HTML page). The issue holds the status, the method, the path and HubSpot's message when it sent one. In `apply`, a refusal whose reason HubSpot names and Kalup knows says it in plain words: a property in use (each workflow, list, form or calculation named), a group that still holds active properties, a property name that exists, a currency symbol HubSpot never turns off again, or a sensitive property on a portal with sensitive data turned off.
8
8
 
9
9
  ## Fix
10
10
 
@@ -6,7 +6,7 @@ The files `pull` merged would not load or validate, so it wrote nothing. Exit 3,
6
6
 
7
7
  Pull merges the portal into the object files, then loads and validates the whole project as it would write it, before saving anything. The issues after this one are what `validate` would report, with the file and line in the merged text, not the file on disk.
8
8
 
9
- Two examples: a property HubSpot does not define whose name starts with `hs_` (`E_HS_PREFIX`), and a new property whose key is taken twice (`E_DUPLICATE_KEY`).
9
+ An example: a new property whose internal name another key of the same object already uses (`E_DUPLICATE_KEY`).
10
10
 
11
11
  ## Fix
12
12
 
@@ -16,5 +16,5 @@ Change the portal or the file so the two agree, then pull again. To pull everyth
16
16
 
17
17
  ```
18
18
  E_PULL_INVALID: the pulled project would not validate; nothing was written (fix: the issues that follow point at the files as pull would write them: change the portal or the file so they agree, or leave the resource out with --only) (docs: errors/E_PULL_INVALID.md)
19
- hubspot/objects/companies.ts:20: E_HS_PREFIX: 'hs_orchard_score' starts with hs_, the prefix HubSpot uses for its own properties (fix: rename the property, or drop label, group and fieldType to reference it) (docs: errors/E_HS_PREFIX.md)
19
+ hubspot/objects/companies.ts:20: E_DUPLICATE_KEY: internal name 'plot_count' is used by two keys of Company: 'plotCount' and 'plotTotal' (fix: remove or rename one of the two entries) (docs: errors/E_DUPLICATE_KEY.md)
20
20
  ```
@@ -4,7 +4,7 @@ A warning from every command that reads the project: the object files are in `ka
4
4
 
5
5
  ## When
6
6
 
7
- Kalup 0.2 keeps the object files in the folder `dir` in `kalup.config.ts` names, `hubspot/` by default. When `dir` is not set, `kalup/` holds .ts files and `hubspot/` holds none, Kalup keeps reading and writing `kalup/` and warns once per command. When both hold .ts files it stops with `E_DIR_AMBIGUOUS` instead.
7
+ Kalup keeps the object files in the folder `dir` in `kalup.config.ts` names, `hubspot/` by default. When `dir` is not set, `kalup/` holds .ts files and `hubspot/` holds none, Kalup keeps reading and writing `kalup/` and warns once per command. When both hold .ts files it stops with `E_DIR_AMBIGUOUS` instead.
8
8
 
9
9
  ## Fix
10
10
 
@@ -13,5 +13,5 @@ Add `dir: 'kalup'` to `kalup.config.ts` to keep the folder, or move `kalup/` to
13
13
  ## Example
14
14
 
15
15
  ```
16
- kalup.config.ts: W_LEGACY_DIR: the object files are in kalup/, the folder Kalup 0.1 used; the default is now hubspot/ (fix: add dir: 'kalup' to kalup.config.ts, or move kalup/ to hubspot/) (docs: errors/W_LEGACY_DIR.md)
16
+ kalup.config.ts: W_LEGACY_DIR: the object files are in kalup/, the old default folder; the default is now hubspot/ (fix: add dir: 'kalup' to kalup.config.ts, or move kalup/ to hubspot/) (docs: errors/W_LEGACY_DIR.md)
17
17
  ```
@@ -4,11 +4,11 @@ A warning from `plan`, and from `apply` without a plan file: the plan creates pr
4
4
 
5
5
  ## When
6
6
 
7
- Before it plans a property create, `plan` reads HubSpot's Limits Tracking API for the custom property limit (W_LIMIT_HEADROOM). On a developer test account (2026-09-29) that read answered 403 to a key with `crm.schemas.*` scopes only. The message gives HubSpot's status, or the issue code for another error or a 200 without a limit and a usage (`E_HTTP`). A create past the limit then fails in `apply` instead of being blocked in the plan.
7
+ Before it plans a property create, `plan` reads HubSpot's Limits Tracking API for the custom property limit (W_LIMIT_HEADROOM). That read answers 403 to a key with `crm.schemas.*` scopes only, and 200 once the key holds one `crm.objects.<object>.read` scope of any object (live runs, 2026-09-29 and 2026-10-01). The message gives HubSpot's status, or the issue code for another error or a 200 without a limit and a usage (`E_HTTP`). A create past the limit then fails in `apply` instead of being blocked in the plan.
8
8
 
9
9
  ## Fix
10
10
 
11
- Add a `crm.objects.<object>.read` scope to the key, such as `crm.objects.companies.read` (Development > Keys > Service keys); it also lets the key read that object's records, which Kalup never requests. Whether one such scope is enough is not yet confirmed live.
11
+ Add a `crm.objects.<object>.read` scope to the key, such as `crm.objects.companies.read` (Development > Keys > Service keys); it also lets the key read that object's records, which Kalup never requests. One such scope, of any object, is enough for every Limits Tracking reading.
12
12
 
13
13
  ## Example
14
14
 
@@ -8,7 +8,7 @@ From `status`: HubSpot sent no rate-limit headers, so Kalup sends at most 8 requ
8
8
 
9
9
  From `plan`: HubSpot sent no daily figure, or one that is not a whole number of requests (empty, fractional, negative), so `budget.dailyRemaining` is `null` and the plan cannot weigh its calls against the daily limit. From `apply`: the same, so it cannot refuse a run that would use more than half of what is left (`E_BUDGET`).
10
10
 
11
- A service key's answers carried the daily headers on a developer test account (2026-09-29). Other account types are not confirmed, so this warning may still appear.
11
+ A service key's answers carry the daily headers (live runs, 2026-09-29 and 2026-10-01), so this warning is not expected with one; the fallback stays for an answer without them.
12
12
 
13
13
  ## Fix
14
14
 
@@ -4,7 +4,7 @@ A warning from `pull`, `plan`, `snapshot` or `compare`: HubSpot sent no rate-lim
4
4
 
5
5
  ## When
6
6
 
7
- Kalup paces requests from HubSpot's rate-limit headers. Without them it sends at most 8 requests per second. A service key's answers carried them on a developer test account (2026-09-29); other account types are not confirmed. `status` reports the same thing as W_RATE_HEADERS.
7
+ Kalup paces requests from HubSpot's rate-limit headers. Without them it sends at most 8 requests per second. A service key's answers carry them (live runs, 2026-09-29 and 2026-10-01), so this warning is not expected with one. `status` reports the same thing as W_RATE_HEADERS.
8
8
 
9
9
  ## Fix
10
10
 
@@ -0,0 +1,17 @@
1
+ # W_WRITE_SCOPE
2
+
3
+ A warning from `status`: HubSpot's token introspection lists the read key's scopes, and a write scope apply needs is not among them. Exit stays 0.
4
+
5
+ ## When
6
+
7
+ `status` reads the scopes a service key holds through HubSpot's token introspection (the key goes in the request body, as HubSpot requires) and checks the write scopes `init` lists against them by name, when apply writes with the same key. A separate write key is never resolved or sent, so its scopes stay unchecked and the line says so. When introspection answers nothing, status checks no write scope.
8
+
9
+ ## Fix
10
+
11
+ Add the scope the message names to the key (Development > Keys > Service keys), then run `kalup status` again.
12
+
13
+ ## Example
14
+
15
+ ```
16
+ W_WRITE_SCOPE: the key in HUBSPOT_SANDBOX_KEY does not hold crm.schemas.companies.write, which apply needs for companies (fix: add the scope crm.schemas.companies.write to the key) (docs: errors/W_WRITE_SCOPE.md)
17
+ ```
package/docs/plan.md CHANGED
@@ -10,7 +10,7 @@ This page is the reference. For the walk-through with examples, see [kalup plan]
10
10
  2. The read key, then the portal guard (`E_TARGET_PORTAL_MISMATCH`, exit 4).
11
11
  3. State for that portal, `.kalup/state/portal-<portalId>.json`; an unusable file is `E_STATE_INVALID`.
12
12
  4. Pull's read and scope, plus tombstoned properties. A 403 leaves that object unread (`E_SCOPE`).
13
- 5. Limits Tracking (403 without a `crm.objects.*` scope, developer test account 2026-09-29; `W_LIMIT_UNREADABLE` for property creates), then the three `archived=true` lists of each object with a property create, an owned property HubSpot no longer holds, or an owned group delete. A 403 there is exit 1.
13
+ 5. Limits Tracking (403 without a `crm.objects.*` scope; `W_LIMIT_UNREADABLE` for property creates), then the three `archived=true` lists of each object with a property create or an owned property HubSpot no longer holds. A group delete reads no archived list: only active properties block it. A 403 there is exit 1.
14
14
  6. The plan, checked against `plan-1.schema.json` (`E_PLAN_SCHEMA` is a bug).
15
15
 
16
16
  ## State and the base
@@ -46,10 +46,10 @@ The first rule that matches: a `skip` override (no step, `coverage.excluded`); a
46
46
  `hubspot/removed.ts` tombstones name properties and groups:
47
47
 
48
48
  - `release`: a `release` step drops the entry, even one naming another portal name; nothing is sent.
49
- - `destroy`, present: a `delete`, risk `destructive`, labelled `existed-before-kalup` for an adopted resource, expecting every base unit's live value. Blocked with `policy` without `allowDestroy: true`, `unsupported` when it is not archivable or a group still holds properties (active or archived) the plan does not delete, `not-owned` without an owning entry.
49
+ - `destroy`, present: a `delete`, risk `destructive`, labelled `existed-before-kalup` for an adopted resource, expecting every base unit's live value. Blocked with `policy` without `allowDestroy: true`, `unsupported` when it is not archivable or a group still holds active properties the plan does not delete (archived ones do not block: HubSpot archives a group once every property in it is archived), `not-owned` without an owning entry.
50
50
  - `destroy`, absent by a complete read: a release expecting `exists: false`.
51
51
 
52
- Under takeover (config.md), a `delete` labelled `takeover` archives each custom property and group in the pull scope that config lacks, with a `mode` note naming the statement that asked for it; an option removal takeover asks for carries the note too. One `Takeover on <objects>` heading precedes the first such step and says whether each is confirmed at a terminal or all are blocked. Blocked with `policy` without `allowDestroy`, `scope` after an incomplete read, `unsupported` when not archivable or a group keeps a property. The `policy` fix leads with `kalup pull --target <t> --only <address>`, which keeps it in config, then `exclude` or `lifecycle: { options: 'additive' }` to leave it unmanaged, then `allowDestroy`. A delete expects every captured field's live value. Apply checks the same rules against its own read (a skipped group, a schema's properties, an empty group).
52
+ Under takeover (config.md), a `delete` labelled `takeover` archives each custom property and group in the pull scope that config lacks, with a `mode` note naming the statement that asked for it; an option removal takeover asks for carries the note too. One `Takeover on <objects>` heading precedes the first such step and says whether each is confirmed at a terminal or all are blocked. Blocked with `policy` without `allowDestroy`, `scope` after an incomplete read, `unsupported` when not archivable or a group keeps an active property. The `policy` fix leads with `kalup pull --target <t> --only <address>`, which keeps it in config, then `exclude` or `lifecycle: { options: 'additive' }` to leave it unmanaged, then `allowDestroy`. A delete expects every captured field's live value. Apply checks the same rules against its own read (a skipped group, a schema's properties, an empty group).
53
53
 
54
54
  Releases follow the config steps, then deletes, the tombstones' and then takeover's, properties before groups.
55
55
 
package/docs/targets.md CHANGED
@@ -38,7 +38,7 @@ The first of several targets is never chosen, and no choice is remembered. Text
38
38
 
39
39
  `credentials.read.env` names the variable that holds the read key. Without `credentials` it is `HUBSPOT_SERVICE_KEY`, the variable the target `init` writes names. `init` itself needs no key. The key comes from the environment, else from the project's `.env`. A missing key is `E_MISSING_KEY`. No output carries a key.
40
40
 
41
- The key goes out as `Authorization: Bearer`. `init` prints the read scopes the pull scope needs; `status` checks each. Both recommend one `crm.objects.<object>.read` scope too: Limits Tracking answered 403 to `crm.schemas.*` scopes alone and 200 with `crm.objects.companies.read` added (developer test account, 2026-09-29); without it `plan` cannot check the property limit (`W_LIMIT_UNREADABLE`). `credentials.write` names the key apply, `state rebuild --write` and `target rebind` use (apply.md); without it they use the read key. That key needs the read scopes and `crm.schemas.<object>.write` per object (`crm.schemas.custom.write` for custom objects), which `init` and `status` list. Neither checks them: read commands never resolve the write key, and no request can check a write scope.
41
+ The key goes out as `Authorization: Bearer`. `init` prints the read scopes the pull scope needs; `status` checks each. Both recommend one `crm.objects.<object>.read` scope too: Limits Tracking answers 403 to `crm.schemas.*` scopes alone and 200 once one `crm.objects.<object>.read` scope of any object is added; without it `plan` cannot check the property limit (`W_LIMIT_UNREADABLE`). `credentials.write` names the key apply, `state rebuild --write` and `target rebind` use (apply.md); without it they use the read key. That key needs the read scopes and `crm.schemas.<object>.write` per object (`crm.schemas.custom.write` for custom objects), which `init` and `status` list. `status` reads the scopes a key holds through HubSpot's token introspection and checks the recommended scope and, when the write key is the read key, each write scope by name; a separate write key is never resolved by a read command, so its scopes stay unchecked.
42
42
 
43
43
  ## Overrides
44
44
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kalup",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Configuration as code for HubSpot: pull, compare, plan and apply properties and property groups",
5
5
  "keywords": [
6
6
  "hubspot",
@@ -62,8 +62,8 @@
62
62
  }
63
63
  },
64
64
  "dependencies": {
65
- "@oclif/core": "^5.0.0",
66
- "@kalup/core": "0.2.0"
65
+ "@oclif/core": "^5.1.2",
66
+ "@kalup/core": "0.3.0"
67
67
  },
68
68
  "engines": {
69
69
  "node": ">=22.13.1"