@siloy/hubspot-provisioning 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -2
  3. package/dist/engine/src/blueprint/associations.d.ts +17 -0
  4. package/dist/engine/src/blueprint/associations.js +22 -0
  5. package/dist/engine/src/blueprint/field-types.d.ts +14 -0
  6. package/dist/engine/src/blueprint/field-types.js +22 -0
  7. package/dist/engine/src/blueprint/formula.d.ts +25 -0
  8. package/dist/engine/src/blueprint/formula.js +106 -0
  9. package/dist/engine/src/blueprint/groups.d.ts +16 -0
  10. package/dist/engine/src/blueprint/groups.js +30 -0
  11. package/dist/engine/src/blueprint/index.d.ts +10 -0
  12. package/dist/engine/src/blueprint/index.js +11 -0
  13. package/dist/engine/src/blueprint/mapping-targets.d.ts +10 -0
  14. package/dist/engine/src/blueprint/mapping-targets.js +5 -0
  15. package/dist/engine/src/blueprint/object-names.d.ts +11 -0
  16. package/dist/engine/src/blueprint/object-names.js +22 -0
  17. package/dist/engine/src/blueprint/pipelines.d.ts +40 -0
  18. package/dist/engine/src/blueprint/pipelines.js +99 -0
  19. package/dist/engine/src/blueprint/schema.d.ts +422 -0
  20. package/dist/engine/src/blueprint/schema.js +163 -0
  21. package/dist/engine/src/blueprint/serialize.d.ts +22 -0
  22. package/dist/engine/src/blueprint/serialize.js +70 -0
  23. package/dist/engine/src/blueprint/validate.d.ts +35 -0
  24. package/dist/engine/src/blueprint/validate.js +385 -0
  25. package/dist/engine/src/errors.d.ts +40 -0
  26. package/dist/engine/src/errors.js +73 -0
  27. package/dist/provisioning/src/api/client.d.ts +36 -0
  28. package/dist/provisioning/src/api/client.js +186 -0
  29. package/dist/provisioning/src/api/commit.d.ts +13 -0
  30. package/dist/provisioning/src/api/commit.js +12 -0
  31. package/dist/provisioning/src/api/types.d.ts +98 -0
  32. package/dist/provisioning/src/api/types.js +5 -0
  33. package/dist/provisioning/src/cli/commands.d.ts +16 -0
  34. package/dist/provisioning/src/cli/commands.js +373 -0
  35. package/dist/provisioning/src/cli/hints.d.ts +4 -0
  36. package/dist/provisioning/src/cli/hints.js +21 -0
  37. package/dist/provisioning/src/cli/main.d.ts +2 -0
  38. package/dist/provisioning/src/cli/main.js +24 -0
  39. package/dist/provisioning/src/cli/render.d.ts +25 -0
  40. package/dist/provisioning/src/cli/render.js +149 -0
  41. package/dist/provisioning/src/index.d.ts +6 -0
  42. package/dist/provisioning/src/index.js +5 -0
  43. package/dist/provisioning/src/limits.d.ts +2 -0
  44. package/dist/provisioning/src/limits.js +2 -0
  45. package/dist/provisioning/src/targets.d.ts +16 -0
  46. package/dist/provisioning/src/targets.js +27 -0
  47. package/dist/provisioning/src/validate.d.ts +10 -0
  48. package/dist/provisioning/src/validate.js +15 -0
  49. package/dist/provisioning/src/version.d.ts +2 -0
  50. package/dist/provisioning/src/version.js +17 -0
  51. package/package.json +38 -4
  52. package/schema/blueprint.schema.json +420 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Siloy Benelux B.V.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,204 @@
1
- # Temporary Holding Version
1
+ # @siloy/hubspot-provisioning
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Validate a HubSpot provisioning blueprint offline, preview it on pull requests, and deploy it from CI. The blueprint lives in your integration repo as `blueprint.yaml`; the provisioning app plans and applies it on the HubSpot portal your deploy key is bound to.
4
+
5
+ Requires Node 20 or newer.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npx -y @siloy/hubspot-provisioning@0.1.1 validate
11
+ ```
12
+
13
+ In CI, run this pinned `npx` form (see "GitHub Actions"); don't install the CLI as a repo dependency in a job that has the deploy key. For editor support of the schema, a local install is fine: `npm install --save-dev @siloy/hubspot-provisioning`.
14
+
15
+ ## Commands
16
+
17
+ ```
18
+ hubspot-provisioning validate [file] [--targets <json>]
19
+ hubspot-provisioning plan [file] --mode preview|deploy [--targets <json>] [--comment-file <path>]
20
+ [--sha <sha>] [--ref <ref>] [--ci-run-url <url>] [--timeout <minutes>]
21
+ hubspot-provisioning apply --plan-run <runId> [--timeout <minutes>]
22
+ hubspot-provisioning deploy [file] [plan options except --mode]
23
+ ```
24
+
25
+ `file` defaults to `blueprint.yaml`.
26
+
27
+ | Command | What it does |
28
+ | --- | --- |
29
+ | `validate` | Checks the blueprint offline, with the same rules as the server. Needs no URL or key. |
30
+ | `plan` | Sends the blueprint to the provisioning app, waits for the plan and prints it. `--mode preview` changes nothing and can never be applied; `--mode deploy` makes a plan that `apply` can apply. |
31
+ | `apply` | Applies a deploy plan made by the same key (`--plan-run` is the plan's run id, printed by `plan`). |
32
+ | `deploy` | `plan --mode deploy`, then `apply` when the plan can be applied. Exits 0 without applying when the portal already matches ("nothing to apply"). |
33
+
34
+ | Flag | Meaning |
35
+ | --- | --- |
36
+ | `--targets <json>` | A JSON file of mapping targets that must exist in the blueprint or in HubSpot: `[{ "objectType": "contacts", "properties": ["cc_sync_number"] }]`. A target found nowhere blocks the plan. With `validate`, targets assumed to exist in HubSpot are listed. |
37
+ | `--comment-file <path>` | Writes the plan as a Markdown comment for a pull request. Also written when the command fails: an invalid blueprint (with its issues), a missing setting, a server refusal or a timeout. The comment starts with `<!-- hubspot-provisioning -->`. A comment that cannot be written is reported on stderr and does not change the exit code. |
38
+ | `--sha`, `--ref`, `--ci-run-url` | The commit the plan belongs to. Default to `GITHUB_SHA`, `GITHUB_REF` and the URL of the Actions run. |
39
+ | `--timeout <minutes>` | Give up waiting after this long. Default 15, at most 360. Each request counts against it, so a server that stops answering cannot hold the job longer. |
40
+ | `--help`, `--version` | |
41
+
42
+ ### Commit info on pull requests
43
+
44
+ On a `pull_request` event, `GITHUB_SHA` and `GITHUB_REF` are the merge commit and `refs/pull/N/merge`, not your branch. That is what the plan is recorded under unless you pass the head yourself:
45
+
46
+ ```yaml
47
+ - run: npx -y @siloy/hubspot-provisioning@0.1.1 plan --mode preview --sha "$HEAD_SHA" --ref "$HEAD_REF"
48
+ env:
49
+ npm_config_registry: https://registry.npmjs.org
50
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
51
+ HEAD_REF: ${{ github.head_ref }}
52
+ ```
53
+
54
+ Pass them through `env:`, never inline in the `run:` text: a branch name is attacker-controlled and would be run as shell.
55
+
56
+ ## Exit codes
57
+
58
+ | Code | Meaning |
59
+ | --- | --- |
60
+ | 0 | Success: the blueprint is valid, the plan is deployable (or there is nothing to change), the apply succeeded. |
61
+ | 1 | The blueprint is invalid or over 5 MiB, the server refused the request, the wait timed out, or the plan cannot be deployed (conflicts, updates that would overwrite hand edits in HubSpot, missing mapping targets, a failed run). |
62
+ | 2 | Usage or configuration error: unknown command or flag, a missing or non-https `PROVISIONING_URL`, a missing `PROVISIONING_KEY`, an unreadable file, no commit sha or ref. |
63
+
64
+ ## Configuration
65
+
66
+ | Variable | Value |
67
+ | --- | --- |
68
+ | `PROVISIONING_URL` | The provisioning app's URL, for example `https://provisioning.example.com`. Must be `https://`; plain `http://` is accepted only for `localhost`, `127.0.0.1` and `[::1]`. |
69
+ | `PROVISIONING_KEY` | The deploy key's secret (`hspk_...`). Store it as a CI secret, never in the repo. |
70
+
71
+ `validate` needs neither. The key is scrubbed from everything the CLI prints.
72
+
73
+ ## Getting a deploy key
74
+
75
+ A deploy key belongs to a workspace and is bound to one portal and blueprint. A workspace owner creates it on the blueprint page in the provisioning app and either:
76
+
77
+ - copies the secret and puts it in the repo's CI secrets, or
78
+ - chooses "Create a link for a developer" and sends you the link. The link works once and expires. Open it, click "Reveal secret" and copy the `hspk_` secret into the repo's CI secrets. If the link expired unused, ask the owner for a new one. If it was already opened (by you or someone else), the owner revokes the key and creates a new one.
79
+
80
+ ### Expiry
81
+
82
+ A key expires after 90 days without use, counted from its last use, creation or reactivation. An expired key gets `deploy_key_expired`. A workspace owner reactivates it on the blueprint page; the secret stays the same, so nothing changes in your CI secrets. A revoked key never comes back.
83
+
84
+ ## GitHub Actions
85
+
86
+ Preview on pull requests (with a comment that is updated, not repeated) and deploy on pushes to `main`. Pin the actions by commit SHA as your own policy requires; versions are shown here for readability. The samples pin 0.1.1; pin the version you use and bump it on purpose.
87
+
88
+ ```yaml
89
+ # .github/workflows/blueprint.yml
90
+ name: blueprint
91
+ on:
92
+ pull_request:
93
+ paths: [blueprint.yaml]
94
+ push:
95
+ branches: [main]
96
+ paths: [blueprint.yaml]
97
+
98
+ jobs:
99
+ preview:
100
+ if: github.event.pull_request.head.repo.full_name == github.repository
101
+ runs-on: ubuntu-latest
102
+ permissions:
103
+ contents: read
104
+ pull-requests: write
105
+ steps:
106
+ - uses: actions/checkout@v7
107
+ with:
108
+ persist-credentials: false
109
+ - uses: actions/setup-node@v7
110
+ with:
111
+ node-version: 24
112
+ # No npm ci: the job holds the deploy key, so it runs only the pinned CLI, never the branch's dependencies.
113
+ # npm_config_registry outranks a project .npmrc, so a branch cannot point npx at another registry.
114
+ - run: npx -y @siloy/hubspot-provisioning@0.1.1 validate
115
+ env:
116
+ npm_config_registry: https://registry.npmjs.org
117
+ - run: >-
118
+ npx -y @siloy/hubspot-provisioning@0.1.1 plan --mode preview
119
+ --comment-file plan-comment.md
120
+ --sha "$HEAD_SHA" --ref "$HEAD_REF"
121
+ env:
122
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
123
+ HEAD_REF: ${{ github.head_ref }}
124
+ npm_config_registry: https://registry.npmjs.org
125
+ PROVISIONING_URL: ${{ vars.PROVISIONING_URL }}
126
+ PROVISIONING_KEY: ${{ secrets.PROVISIONING_KEY }}
127
+ # Runs also when the plan failed or was refused: the comment says why.
128
+ - name: Post the plan on the pull request
129
+ if: always() && hashFiles('plan-comment.md') != ''
130
+ env:
131
+ GH_TOKEN: ${{ github.token }}
132
+ PR: ${{ github.event.pull_request.number }}
133
+ run: |
134
+ # Update this workflow's earlier comment (by the Actions bot, starting with the marker), else create one.
135
+ id=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
136
+ --jq '.[] | select(.user.login == "github-actions[bot]" and (.body | startswith("<!-- hubspot-provisioning -->"))) | .id' | head -n 1)
137
+ if [ -n "$id" ]; then
138
+ gh api --method PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${id}" -F body=@plan-comment.md
139
+ else
140
+ gh api --method POST "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" -F body=@plan-comment.md
141
+ fi
142
+
143
+ deploy:
144
+ if: github.event_name == 'push'
145
+ runs-on: ubuntu-latest
146
+ permissions:
147
+ contents: read
148
+ concurrency: blueprint-deploy
149
+ steps:
150
+ - uses: actions/checkout@v7
151
+ with:
152
+ persist-credentials: false
153
+ - uses: actions/setup-node@v7
154
+ with:
155
+ node-version: 24
156
+ - run: npx -y @siloy/hubspot-provisioning@0.1.1 deploy
157
+ env:
158
+ npm_config_registry: https://registry.npmjs.org
159
+ PROVISIONING_URL: ${{ vars.PROVISIONING_URL }}
160
+ PROVISIONING_KEY: ${{ secrets.PROVISIONING_KEY }}
161
+ ```
162
+
163
+ Notes:
164
+
165
+ - Pull requests from forks get no secrets (`plan` exits 2) and a read-only token (the comment step fails), so the preview job runs only for branches in the same repository.
166
+ - The samples pin the CLI's version and its registry (`npm_config_registry`) and do not install the repo's dependencies, so a branch cannot swap the CLI through the repo's dependencies or npm config. Anyone who can push a branch can still change the workflow itself: see "Trust boundary".
167
+
168
+ On a push, `GITHUB_SHA` and `GITHUB_REF` are the pushed commit and branch, so `deploy` needs no commit flags.
169
+
170
+ A deploy that would overwrite something someone changed by hand in HubSpot is refused (exit 1) and the output lists what changed. Adopt HubSpot's values in `blueprint.yaml` and deploy again.
171
+
172
+ ### Trust boundary
173
+
174
+ `PROVISIONING_KEY` is like any GitHub secret: anyone who can push a branch to the integration repo can change the workflow on that branch and use the key. Keep write access to the integration repo to trusted developers. A key can deploy as well as preview, so a preview job's key could also deploy to its portal; a preview-only key is planned.
175
+
176
+ ## Editor support
177
+
178
+ The package ships a JSON Schema for the blueprint. In VS Code with the YAML extension, add this as the first line of `blueprint.yaml`:
179
+
180
+ ```yaml
181
+ # yaml-language-server: $schema=node_modules/@siloy/hubspot-provisioning/schema/blueprint.schema.json
182
+ ```
183
+
184
+ or set it in `.vscode/settings.json`:
185
+
186
+ ```json
187
+ {
188
+ "yaml.schemas": {
189
+ "./node_modules/@siloy/hubspot-provisioning/schema/blueprint.schema.json": "blueprint.yaml"
190
+ }
191
+ }
192
+ ```
193
+
194
+ The schema is also available as `@siloy/hubspot-provisioning/schema.json`. It describes the shape; `validate` applies the full rules.
195
+
196
+ ## Upgrading
197
+
198
+ When the blueprint rules change, the server may refuse older CLIs with `cli_outdated`. Upgrade with `npm install --save-dev @siloy/hubspot-provisioning@latest`, or raise the version in your workflow's `npx -y @siloy/hubspot-provisioning@0.1.1`.
199
+
200
+ When a command fails after the server created a run, it prints `Plan run:` and `Apply run:` ids; look them up in the provisioning app. After a timeout or error while waiting for an apply, the apply may still be running: check it before re-running.
201
+
202
+ ## As a library
203
+
204
+ The package also exports the offline validation (`validateBlueprintText`, `checkMappingTargets`) and a client for the CI API (`CiClient`, `CiApiError`, `CiTimeoutError` and the request and response types). The command-line output and comment rendering are internal.
@@ -0,0 +1,17 @@
1
+ import type { Association, AssociationLabel } from './schema.ts';
2
+ /** A label with the ends of its pair: `label` reads from `from`. The unit most consumers iterate. */
3
+ export interface LabelEntry extends AssociationLabel {
4
+ from: string;
5
+ to: string;
6
+ }
7
+ /** Every label of every pair, in pair order, then label order. */
8
+ export declare function labelEntries(associations: readonly Association[]): LabelEntry[];
9
+ /** An unordered pair's name: its ends sorted, `a<->b`. */
10
+ export declare function pairId(a: string, b: string): string;
11
+ /** The pair entry for two objects in either order; `reversed` when it is written `b` → `a`. */
12
+ export declare function findPair(associations: readonly Association[], a: string, b: string): {
13
+ pair: Association;
14
+ reversed: boolean;
15
+ } | undefined;
16
+ /** One direction's limit in words: "deals → projects: at most 1 per record". */
17
+ export declare function describeLimit(from: string, to: string, max: number | undefined): string;
@@ -0,0 +1,22 @@
1
+ /** Every label of every pair, in pair order, then label order. */
2
+ export function labelEntries(associations) {
3
+ return associations.flatMap(({ from, to, labels }) => labels.map((label) => ({ ...label, from, to })));
4
+ }
5
+ /** An unordered pair's name: its ends sorted, `a<->b`. */
6
+ export function pairId(a, b) {
7
+ return [a, b].sort().join('<->');
8
+ }
9
+ /** The pair entry for two objects in either order; `reversed` when it is written `b` → `a`. */
10
+ export function findPair(associations, a, b) {
11
+ for (const pair of associations) {
12
+ if (pair.from === a && pair.to === b)
13
+ return { pair, reversed: false };
14
+ if (pair.from === b && pair.to === a)
15
+ return { pair, reversed: true };
16
+ }
17
+ return undefined;
18
+ }
19
+ /** One direction's limit in words: "deals → projects: at most 1 per record". */
20
+ export function describeLimit(from, to, max) {
21
+ return `${from} → ${to}: ${max === undefined ? 'no limit' : `at most ${max} per record`}`;
22
+ }
@@ -0,0 +1,14 @@
1
+ import { type PropertyType } from './schema.ts';
2
+ export type FieldTypeId = 'text' | 'textarea' | 'html' | 'file' | 'phone' | 'number' | 'date' | 'datetime' | 'select' | 'radio' | 'checkbox' | 'booleancheckbox' | 'equation';
3
+ export interface FieldTypeChoice {
4
+ id: FieldTypeId;
5
+ label: string;
6
+ type: PropertyType;
7
+ fieldType: string;
8
+ }
9
+ /** The editor's "Field type" picker (spec #28 §5.4): one choice sets type and fieldType together, like HubSpot's form. */
10
+ export declare const FIELD_TYPES: readonly FieldTypeChoice[];
11
+ export declare function fieldTypeOf(property: {
12
+ type: string;
13
+ fieldType: string;
14
+ }): FieldTypeChoice | undefined;
@@ -0,0 +1,22 @@
1
+ import { EQUATION_FIELD_TYPE } from './schema.js';
2
+ /** The editor's "Field type" picker (spec #28 §5.4): one choice sets type and fieldType together, like HubSpot's form. */
3
+ export const FIELD_TYPES = [
4
+ { id: 'text', label: 'Single-line text', type: 'string', fieldType: 'text' },
5
+ { id: 'textarea', label: 'Multi-line text', type: 'string', fieldType: 'textarea' },
6
+ { id: 'html', label: 'Rich text', type: 'string', fieldType: 'html' },
7
+ { id: 'file', label: 'File', type: 'string', fieldType: 'file' },
8
+ { id: 'phone', label: 'Phone number', type: 'phone_number', fieldType: 'phonenumber' },
9
+ { id: 'number', label: 'Number', type: 'number', fieldType: 'number' },
10
+ { id: 'date', label: 'Date', type: 'date', fieldType: 'date' },
11
+ { id: 'datetime', label: 'Date and time', type: 'datetime', fieldType: 'date' },
12
+ { id: 'select', label: 'Dropdown select', type: 'enumeration', fieldType: 'select' },
13
+ { id: 'radio', label: 'Radio select', type: 'enumeration', fieldType: 'radio' },
14
+ { id: 'checkbox', label: 'Multiple checkboxes', type: 'enumeration', fieldType: 'checkbox' },
15
+ { id: 'booleancheckbox', label: 'Single checkbox', type: 'bool', fieldType: 'booleancheckbox' },
16
+ { id: 'equation', label: 'Equation', type: 'number', fieldType: EQUATION_FIELD_TYPE },
17
+ ];
18
+ export function fieldTypeOf(property) {
19
+ if (property.fieldType === EQUATION_FIELD_TYPE)
20
+ return FIELD_TYPES.find((c) => c.id === 'equation');
21
+ return FIELD_TYPES.find((c) => c.type === property.type && c.fieldType === property.fieldType);
22
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The property names a HubSpot calculation formula refers to, without duplicates (spec addendum
3
+ * §4.1). Lexical on purpose: identifiers outside double-quoted strings, minus keywords (any case)
4
+ * and anything immediately followed by `(` (a function call). There is no function list to keep in
5
+ * sync. It fails closed: whether a backslash escapes the next character in a string is unverified,
6
+ * so both readings are scanned and the union returned; an unterminated string is scanned as code.
7
+ * A mistake only ever over-reports, which the planner turns into a conflict. The order is
8
+ * deterministic but not strictly first use: the names of the backslash-escapes reading in order of
9
+ * first use, then any names only the other reading finds.
10
+ */
11
+ export declare function formulaReferences(formula: string): string[];
12
+ /**
13
+ * `formula` with every reference to `from` renamed to `to` (spec #28 §5.7). Only where both string readings
14
+ * agree the name is code, so text inside a string is never changed; a reference left alone stays visible
15
+ * in the editor's Uses list.
16
+ */
17
+ export declare function renameInFormula(formula: string, from: string, to: string): string;
18
+ /**
19
+ * Properties whose formulas refer to each other in a cycle, each with the names in its cycle (input order).
20
+ * HubSpot can create none of them first. References to names outside `properties` are ignored.
21
+ */
22
+ export declare function formulaCycles(properties: ReadonlyArray<{
23
+ name: string;
24
+ calculationFormula?: string;
25
+ }>): Map<string, string[]>;
@@ -0,0 +1,106 @@
1
+ const KEYWORDS = new Set(['if', 'then', 'else', 'elseif', 'endif', 'and', 'or', 'not', 'true', 'false']);
2
+ const IDENTIFIER_START = /[A-Za-z_]/;
3
+ const IDENTIFIER_PART = /[A-Za-z0-9_]/;
4
+ /**
5
+ * The property names a HubSpot calculation formula refers to, without duplicates (spec addendum
6
+ * §4.1). Lexical on purpose: identifiers outside double-quoted strings, minus keywords (any case)
7
+ * and anything immediately followed by `(` (a function call). There is no function list to keep in
8
+ * sync. It fails closed: whether a backslash escapes the next character in a string is unverified,
9
+ * so both readings are scanned and the union returned; an unterminated string is scanned as code.
10
+ * A mistake only ever over-reports, which the planner turns into a conflict. The order is
11
+ * deterministic but not strictly first use: the names of the backslash-escapes reading in order of
12
+ * first use, then any names only the other reading finds.
13
+ */
14
+ export function formulaReferences(formula) {
15
+ return [...new Set([...scan(formula, true), ...scan(formula, false)])];
16
+ }
17
+ /** Identifier tokens that are property references under one reading of string escapes. */
18
+ function identifierSpans(formula, backslashEscapes) {
19
+ const spans = [];
20
+ let i = 0;
21
+ while (i < formula.length) {
22
+ const ch = formula[i];
23
+ if (ch === '"') {
24
+ let j = i + 1;
25
+ while (j < formula.length && formula[j] !== '"')
26
+ j += backslashEscapes && formula[j] === '\\' ? 2 : 1;
27
+ // No closing quote: the quote is an ordinary character and the rest is code.
28
+ i = j < formula.length ? j + 1 : i + 1;
29
+ }
30
+ else if (IDENTIFIER_START.test(ch)) {
31
+ let end = i + 1;
32
+ while (end < formula.length && IDENTIFIER_PART.test(formula[end]))
33
+ end++;
34
+ const word = formula.slice(i, end);
35
+ if (formula[end] !== '(' && !KEYWORDS.has(word.toLowerCase()))
36
+ spans.push({ start: i, end, word });
37
+ i = end;
38
+ }
39
+ else if (/[0-9]/.test(ch)) {
40
+ i = skipNumber(formula, i);
41
+ }
42
+ else {
43
+ i++;
44
+ }
45
+ }
46
+ return spans;
47
+ }
48
+ function scan(formula, backslashEscapes) {
49
+ return [...new Set(identifierSpans(formula, backslashEscapes).map((s) => s.word))];
50
+ }
51
+ /**
52
+ * `formula` with every reference to `from` renamed to `to` (spec #28 §5.7). Only where both string readings
53
+ * agree the name is code, so text inside a string is never changed; a reference left alone stays visible
54
+ * in the editor's Uses list.
55
+ */
56
+ export function renameInFormula(formula, from, to) {
57
+ const other = new Set(identifierSpans(formula, false).filter((s) => s.word === from).map((s) => s.start));
58
+ const spans = identifierSpans(formula, true).filter((s) => s.word === from && other.has(s.start));
59
+ let out = formula;
60
+ for (const s of spans.reverse())
61
+ out = out.slice(0, s.start) + to + out.slice(s.end);
62
+ return out;
63
+ }
64
+ /** Whether `goal` is reachable from `from` through formula references. */
65
+ function reaches(formulas, from, goal, seen) {
66
+ for (const next of formulas.get(from) ?? []) {
67
+ if (next === goal)
68
+ return true;
69
+ if (seen.has(next))
70
+ continue;
71
+ seen.add(next);
72
+ if (reaches(formulas, next, goal, seen))
73
+ return true;
74
+ }
75
+ return false;
76
+ }
77
+ /**
78
+ * Properties whose formulas refer to each other in a cycle, each with the names in its cycle (input order).
79
+ * HubSpot can create none of them first. References to names outside `properties` are ignored.
80
+ */
81
+ export function formulaCycles(properties) {
82
+ const formulas = new Map();
83
+ for (const p of properties)
84
+ if (p.calculationFormula !== undefined)
85
+ formulas.set(p.name, formulaReferences(p.calculationFormula));
86
+ const cyclic = [...formulas.keys()].filter((name) => reaches(formulas, name, name, new Set()));
87
+ const cycles = new Map();
88
+ for (const name of cyclic) {
89
+ cycles.set(name, cyclic.filter((other) => other === name || (reaches(formulas, name, other, new Set()) && reaches(formulas, other, name, new Set()))));
90
+ }
91
+ return cycles;
92
+ }
93
+ /** A number is digits and dots; an exponent marker belongs to it only when a digit or sign follows. */
94
+ function skipNumber(formula, start) {
95
+ let i = start;
96
+ while (i < formula.length) {
97
+ const c = formula[i];
98
+ if (/[0-9.]/.test(c))
99
+ i++;
100
+ else if ((c === 'e' || c === 'E') && /[0-9+-]/.test(formula[i + 1] ?? ''))
101
+ i += 2;
102
+ else
103
+ break;
104
+ }
105
+ return i;
106
+ }
@@ -0,0 +1,16 @@
1
+ import type { StandardObject } from './schema.ts';
2
+ /** HubSpot rejects longer custom object names and labels (400 OBJECT_TYPE_NAME_LENGTH, Q12). */
3
+ export declare const CUSTOM_OBJECT_NAME_MAX = 50;
4
+ /** The group HubSpot creates with a custom object (Q12): the name lowercased, whatever its case. */
5
+ export declare function defaultGroupName(objectType: string): string;
6
+ export declare function defaultGroupLabel(singular: string): string;
7
+ /** Each standard object's main group, measured on portals A and B (Q12). No underscore, unlike custom objects. */
8
+ export declare const STOCK_PROPERTY_GROUPS: Record<StandardObject, {
9
+ name: string;
10
+ label: string;
11
+ }>;
12
+ /**
13
+ * Common HubSpot-defined property names, for an editor warning only. Not complete and not meant to be:
14
+ * the planner's origin conflict is the real check (spec #28 §4.1).
15
+ */
16
+ export declare const STOCK_PROPERTY_NAMES: Record<StandardObject, readonly string[]>;
@@ -0,0 +1,30 @@
1
+ /** HubSpot rejects longer custom object names and labels (400 OBJECT_TYPE_NAME_LENGTH, Q12). */
2
+ export const CUSTOM_OBJECT_NAME_MAX = 50;
3
+ /** The group HubSpot creates with a custom object (Q12): the name lowercased, whatever its case. */
4
+ export function defaultGroupName(objectType) {
5
+ return `${objectType.toLowerCase()}_information`;
6
+ }
7
+ export function defaultGroupLabel(singular) {
8
+ return `${singular} Information`;
9
+ }
10
+ /** Each standard object's main group, measured on portals A and B (Q12). No underscore, unlike custom objects. */
11
+ export const STOCK_PROPERTY_GROUPS = {
12
+ contacts: { name: 'contactinformation', label: 'Contact information' },
13
+ companies: { name: 'companyinformation', label: 'Company information' },
14
+ deals: { name: 'dealinformation', label: 'Deal information' },
15
+ tickets: { name: 'ticketinformation', label: 'Ticket information' },
16
+ products: { name: 'productinformation', label: 'Product information' },
17
+ line_items: { name: 'lineiteminformation', label: 'Line item information' },
18
+ };
19
+ /**
20
+ * Common HubSpot-defined property names, for an editor warning only. Not complete and not meant to be:
21
+ * the planner's origin conflict is the real check (spec #28 §4.1).
22
+ */
23
+ export const STOCK_PROPERTY_NAMES = {
24
+ contacts: ['email', 'firstname', 'lastname', 'phone', 'mobilephone', 'company', 'jobtitle', 'address', 'city', 'state', 'zip', 'country', 'website', 'lifecyclestage', 'hubspot_owner_id'],
25
+ companies: ['name', 'domain', 'phone', 'address', 'city', 'state', 'zip', 'country', 'industry', 'website', 'numberofemployees', 'annualrevenue', 'description', 'hubspot_owner_id'],
26
+ deals: ['dealname', 'amount', 'closedate', 'dealstage', 'pipeline', 'dealtype', 'description', 'hubspot_owner_id'],
27
+ tickets: ['subject', 'content', 'hs_pipeline', 'hs_pipeline_stage', 'hs_ticket_priority', 'hubspot_owner_id'],
28
+ products: ['name', 'price', 'description', 'hs_sku'],
29
+ line_items: ['name', 'price', 'quantity', 'amount', 'hs_sku'],
30
+ };
@@ -0,0 +1,10 @@
1
+ export { ASSOCIATION_LIMIT_MAX, BLUEPRINT_FORMAT, BlueprintSchema, DraftBlueprintSchema, EQUATION_TYPES, GROUP_NAME_LOWERCASE, GROUP_NAME_MAX, GROUP_NAME_RULE, PROPERTY_TYPES, STANDARD_OBJECTS, isEquation, isGroupName, isLowercaseGroupName, isStandardObject, type Association, type AssociationLabel, type Blueprint, type BlueprintObject, type CustomObjectDefinition, type DraftBlueprint, type Pipeline, type Property, type PropertyGroup, type PropertyType, type Stage, type StageMetadata, type StandardObject, } from './schema.ts';
2
+ export { DEFAULT_PIPELINE_IDS, STOCK_PIPELINES, adoptablePipeline, canHavePipelines, identifierSlug, isClosedStage, metadataKeyFor, pipelineKeySlug, type MetadataKey, } from './pipelines.ts';
3
+ export { CUSTOM_OBJECT_NAME_MAX, STOCK_PROPERTY_GROUPS, STOCK_PROPERTY_NAMES, defaultGroupLabel, defaultGroupName } from './groups.ts';
4
+ export { STANDARD_OBJECT_NAMES, isRenamed, objectDisplayName, type ObjectNames } from './object-names.ts';
5
+ export { FIELD_TYPES, fieldTypeOf, type FieldTypeChoice, type FieldTypeId } from './field-types.ts';
6
+ export { formulaCycles, formulaReferences, renameInFormula } from './formula.ts';
7
+ export { describeLimit, findPair, labelEntries, pairId, type LabelEntry } from './associations.ts';
8
+ export { blueprintIssues, issueText, validateBlueprint, type BlueprintIssue, type IssuePath } from './validate.ts';
9
+ export { mappingTargetsSchema, type MappingTarget } from './mapping-targets.ts';
10
+ export { blueprintJsonSchema, parseBlueprint, readDraftYaml, serializeBlueprint, type DraftYaml } from './serialize.ts';
@@ -0,0 +1,11 @@
1
+ // The browser-safe part of the engine (@cc/engine/blueprint): zod and yaml only. test/blueprint/entry.test.ts keeps it that way.
2
+ export { ASSOCIATION_LIMIT_MAX, BLUEPRINT_FORMAT, BlueprintSchema, DraftBlueprintSchema, EQUATION_TYPES, GROUP_NAME_LOWERCASE, GROUP_NAME_MAX, GROUP_NAME_RULE, PROPERTY_TYPES, STANDARD_OBJECTS, isEquation, isGroupName, isLowercaseGroupName, isStandardObject, } from './schema.js';
3
+ export { DEFAULT_PIPELINE_IDS, STOCK_PIPELINES, adoptablePipeline, canHavePipelines, identifierSlug, isClosedStage, metadataKeyFor, pipelineKeySlug, } from './pipelines.js';
4
+ export { CUSTOM_OBJECT_NAME_MAX, STOCK_PROPERTY_GROUPS, STOCK_PROPERTY_NAMES, defaultGroupLabel, defaultGroupName } from './groups.js';
5
+ export { STANDARD_OBJECT_NAMES, isRenamed, objectDisplayName } from './object-names.js';
6
+ export { FIELD_TYPES, fieldTypeOf } from './field-types.js';
7
+ export { formulaCycles, formulaReferences, renameInFormula } from './formula.js';
8
+ export { describeLimit, findPair, labelEntries, pairId } from './associations.js';
9
+ export { blueprintIssues, issueText, validateBlueprint } from './validate.js';
10
+ export { mappingTargetsSchema } from './mapping-targets.js';
11
+ export { blueprintJsonSchema, parseBlueprint, readDraftYaml, serializeBlueprint } from './serialize.js';
@@ -0,0 +1,10 @@
1
+ import { z } from 'zod';
2
+ /** An object type and the property names an integration's mappings point at. */
3
+ export interface MappingTarget {
4
+ objectType: string;
5
+ properties: string[];
6
+ }
7
+ export declare const mappingTargetsSchema: z.ZodArray<z.ZodObject<{
8
+ objectType: z.ZodString;
9
+ properties: z.ZodArray<z.ZodString>;
10
+ }, z.core.$strip>>;
@@ -0,0 +1,5 @@
1
+ import { z } from 'zod';
2
+ export const mappingTargetsSchema = z.array(z.object({
3
+ objectType: z.string().min(1).max(100),
4
+ properties: z.array(z.string().min(1).max(100)).max(500),
5
+ })).max(50);
@@ -0,0 +1,11 @@
1
+ import { type StandardObject } from './schema.ts';
2
+ export type ObjectNames = {
3
+ singular: string;
4
+ plural: string;
5
+ };
6
+ /** HubSpot's own names for the standard objects, before anyone renames them in Settings → Data model. */
7
+ export declare const STANDARD_OBJECT_NAMES: Record<StandardObject, ObjectNames>;
8
+ /** Renamed = either name differs from HubSpot's default by exact text. */
9
+ export declare function isRenamed(objectType: StandardObject, names: ObjectNames): boolean;
10
+ /** `Organizations (Companies)` for a renamed standard object, the default plural otherwise; anything else is returned as is. */
11
+ export declare function objectDisplayName(objectType: string, labels?: ObjectNames): string;
@@ -0,0 +1,22 @@
1
+ import { isStandardObject } from './schema.js';
2
+ /** HubSpot's own names for the standard objects, before anyone renames them in Settings → Data model. */
3
+ export const STANDARD_OBJECT_NAMES = {
4
+ contacts: { singular: 'Contact', plural: 'Contacts' },
5
+ companies: { singular: 'Company', plural: 'Companies' },
6
+ deals: { singular: 'Deal', plural: 'Deals' },
7
+ tickets: { singular: 'Ticket', plural: 'Tickets' },
8
+ products: { singular: 'Product', plural: 'Products' },
9
+ line_items: { singular: 'Line item', plural: 'Line items' },
10
+ };
11
+ /** Renamed = either name differs from HubSpot's default by exact text. */
12
+ export function isRenamed(objectType, names) {
13
+ const standard = STANDARD_OBJECT_NAMES[objectType];
14
+ return names.singular !== standard.singular || names.plural !== standard.plural;
15
+ }
16
+ /** `Organizations (Companies)` for a renamed standard object, the default plural otherwise; anything else is returned as is. */
17
+ export function objectDisplayName(objectType, labels) {
18
+ if (!isStandardObject(objectType))
19
+ return objectType;
20
+ const defaultPlural = STANDARD_OBJECT_NAMES[objectType].plural;
21
+ return labels && isRenamed(objectType, labels) ? `${labels.plural} (${defaultPlural})` : defaultPlural;
22
+ }
@@ -0,0 +1,40 @@
1
+ import { type Pipeline, type StageMetadata } from './schema.ts';
2
+ /** Pipelines exist on deals, tickets and custom objects only (P1). */
3
+ export declare function canHavePipelines(objectType: string): boolean;
4
+ export type MetadataKey = 'probability' | 'ticketState' | 'state';
5
+ /** Each object kind keeps its stage semantics under a different metadata key (P2). */
6
+ export declare function metadataKeyFor(objectType: string): MetadataKey;
7
+ /** The pipeline HubSpot ships with a portal; adopted with origin: hubspot, never created. */
8
+ export declare const DEFAULT_PIPELINE_IDS: Readonly<Record<string, string>>;
9
+ export interface StockPipeline {
10
+ id: string;
11
+ label: string;
12
+ stages: Array<{
13
+ id: string;
14
+ label: string;
15
+ metadata: Record<string, string>;
16
+ }>;
17
+ }
18
+ /** The pipeline every new portal has on deals and tickets, measured on portals A and B (docs/hubspot-quirks.md, Pipelines). */
19
+ export declare const STOCK_PIPELINES: Readonly<Record<'deals' | 'tickets', StockPipeline>>;
20
+ /**
21
+ * The stock pipeline as a blueprint pipeline with origin: hubspot, for the editor's "Adopt HubSpot's default
22
+ * pipeline". Keys are slugged from the labels, as ingest does without state; the planner matches the portal's
23
+ * stages by label.
24
+ */
25
+ export declare function adoptablePipeline(objectType: 'deals' | 'tickets'): Pipeline;
26
+ /** The record property holding the stage id. */
27
+ export declare function stagePropertyFor(objectType: string): string;
28
+ /** HubSpot generates one set of these per stage; they are not ours to manage. */
29
+ export declare const STAGE_PROPERTY_PATTERN: RegExp;
30
+ export declare function isStageGeneratedProperty(name: string): boolean;
31
+ /** Properties that pipelines and stages own, so the property engine must leave them alone. */
32
+ export declare function isPipelineOwnedProperty(objectType: string, name: string): boolean;
33
+ /** The four properties whose options are the pipelines and stages themselves. */
34
+ export declare function isOptionsOwnedByPipelines(objectType: string, name: string): boolean;
35
+ /** A readable identifier from a label: lower case letters, digits and underscores, starting with a letter. */
36
+ export declare function identifierSlug(label: string, prefix: string, fallback: string): string;
37
+ /** A readable, identifier-safe key from a label. */
38
+ export declare function pipelineKeySlug(label: string, prefix: 'p' | 's'): string;
39
+ /** isClosed is derived by HubSpot, so we infer it from the metadata that drives it (P2). */
40
+ export declare function isClosedStage(objectType: string, metadata: StageMetadata): boolean;