kalup

Config files

The files in a Kalup project, the grammar they follow, canonical form, and what survives a pull

A Kalup project is kalup.config.ts plus the files under hubspot/. The CLI parses these files and never runs them. Your app imports the same files for its types. Both read one grammar, described on this page.

  • kalup.config.ts is the reference for the project file: scope, targets, overrides.
  • Property builders is the reference for defineObject, defineCustomObject and every p.<kind> builder.

The files

kalup.config.ts              defineConfig({ ... })
hubspot/
  index.ts                   tool-written barrel, re-exports every object
  objects/companies.ts       export const Company = defineObject('companies', { ... })
  objects/subscription.ts    export const Subscription = defineCustomObject('subscription', { ... })
.kalup/                      tool data, gitignored
  state/portal-<id>.json     what Kalup owns in each portal and the values both sides agreed on
  plans/<target>-<id>.json   plans saved with plan --out and no file
  journal/, snapshots/       one file per apply run, per snapshot
  history/<timestamp>/       copies of files before the tool overwrote them, last 20 kept

The folder is hubspot/ by default. To keep it elsewhere, such as next to the rest of your app's config, set dir: 'lib/config/hubspot' in kalup.config.ts or pass --dir to kalup init; every command then reads and writes there, and the paths on this page move with it. The folder belongs to Kalup: keep other files out of it.

The path of an object file carries no meaning. The loader reads every .ts file under hubspot/ except index.ts, and a resource keeps the file it was found in. Pull writes a new object to hubspot/objects/<object>.ts.

A project made with Kalup 0.1 has its files in kalup/. While dir is unset and there is no hubspot/ folder, Kalup keeps using kalup/ and warns with W_LEGACY_DIR. Set dir: 'kalup' to keep the folder, or move it to hubspot/.

hubspot/removed.ts holds the tombstones kalup rm writes. Some names are reserved for later releases and refused with E_UNSUPPORTED_FILE: anything under hubspot/pipelines/, and a defineConfig or defineRemoved file elsewhere under hubspot/. A file under hubspot/ with no defineObject or defineCustomObject is E_MISSING_EXPORT.

What to commit

CommitDo not commit
kalup.config.ts, everything under hubspot/: the object files, index.ts, removed.ts, the blueprints lock, and state/ only with state: 'repo'. Also AGENTS.md, CLAUDE.md, the .gitignore and formatter lines init added, package.json and its lockfile.kalup/ (local state, saved plans, the journal, history), a plan file such as plan.json, .env or any file that holds a key, the IR

State stays out of git by default: it is this machine's record of what Kalup owns in each portal, and a fresh clone rebuilds it with kalup state rebuild or adopts again at the first plan. To share it with teammates and CI instead, set state: 'repo'. A saved plan is for one review and one apply, then it is stale: keep it out of git, and let CI make and post its own.

An object file

import { defineObject, type InferProperties, p } from '@kalup/core'

export const Company = defineObject('companies', {
  groups: {
    billing: { label: 'Billing' },
  },
  properties: {
    // Set by the billing sync. Do not edit by hand.
    billingStatus: p.enum('billing_status', {
      label: 'Billing status',
      group: 'billing',
      fieldType: 'select',
      options: [
        { value: 'active', label: 'Active' },
        { value: 'PAST DUE', label: 'Past due', as: 'past_due' },
      ],
    }),
    domain: p.string('domain'),
    name: p.string('name'),
  },
})

export type CompanyData = InferProperties<typeof Company.properties> & { id: string }

billingStatus is managed: its definition has label, group and fieldType, so Kalup owns it. domain and name are references: HubSpot-defined, typed for your app, never created, changed or removed. Property builders explains every part.

Every resource has an address: property:companies/billing_status, group:companies/billing, object:subscription. Command output, --only globs and overrides all use addresses. See Addresses.

The grammar

The reader accepts this and nothing else. Anything outside it is E_NOT_DATA with the file, the line and a fix.

  • Imports. The @kalup/core and kalup imports are tool-owned and rewritten. Other imports are kept as written, for p.json schemas.
  • Exports. export const <Name> = defineObject('<object>', { ... }) or defineCustomObject('<name>', { ... }), one or more per file. In kalup.config.ts, export default defineConfig({ ... }) once and nothing else.
  • Type lines. export type <Name>Data = InferProperties<typeof <Name>.properties> & { id: string }, at most once per export.
  • Values. Inside a call: object literals, arrays, strings, numbers, booleans, and builder calls followed by chains.
  • The p.json validator. The second argument of p.json is kept as opaque source text.
  • Comments. A comment block before the imports is the file header and stays at the top. A comment on the line above a property, a group or an export stays with it. A comment anywhere else is E_NOT_DATA with the fix "move this comment above the entry it describes".
  • Syntax. Object keys as identifiers or quoted strings. Either quote style, trailing commas, semicolons and any whitespace are accepted on read.

These are all E_NOT_DATA:

RejectedExample
Spreads...sharedFields
Computed keys[key]: p.string('x')
Shorthand properties{ label }
Variables and other identifierslabel: BILLING_LABEL
Other function callsoptions: makeOptions()
Template stringslabel: `Billing ${x}`
Functions, loops, conditionalsproperties: Object.fromEntries(...)
A field the grammar does not knowdefineObject('companies', { color: 'red' })
Anything after defineConfig(...) in kalup.config.tsa second export

A builder that does not exist (p.text) is E_UNKNOWN_BUILDER, and a bad chain call is E_BAD_CHAIN.

The restriction is what lets the tool read your config without running it, print it back, and let pull merge into it. It is also why an AI agent can edit these files safely: nothing in them executes.

Canonical form

The writer emits one form, so two people pulling the same portal get byte-identical files and a diff shows only real change:

  • LF line endings, two-space indent, no semicolons, trailing commas. Single quotes, or double quotes when the string holds more single quotes than double quotes. Line breaks follow biome's rules at 120 columns.
  • Properties sorted by internal name. Groups sorted by name. Options in display order.
  • Definition fields in the order label, group, fieldType, description, options, hasUniqueValue, formField, hidden, displayOrder, numberDisplayHint, showCurrencySymbol, currencyPropertyName, textDisplayHint, calculationFormula, dataSensitivity, lifecycle. Option fields in the order value, label, as, hidden, description.
  • Every value you wrote is kept, including an empty description, false and an empty list, because a field that is present is owned. Chains in the order .strict(), .required(), .readonly(), .managed(false).
  • hubspot/index.ts regenerated on every write, one export and one export type per object, sorted by export name, each from a relative path ending in .js.

kalup fmt rewrites every file into this form and fmt --check lists the files that would change. The output passes biome unchanged. init adds hubspot/ to the project's formatter ignore, so the writer's format is the only format.

The app side

Your app imports the same files:

import { propertyNames } from '@kalup/core'
import { Company } from './hubspot'

// The internal names to request, for the `properties` query parameter of a CRM read.
const names = propertyNames(Company)

const status = Company.properties.billingStatus.get(record.properties)
// 'active' | 'past_due' | Unlisted | null: Unlisted is a value the options do not list

const patch: Record<string, string> = {}
Company.properties.billingStatus.set(patch, 'past_due')
// patch is { billing_status: 'PAST DUE' }

Supported setups

hubspot/index.ts imports each object file by a relative path ending in .js, such as ./objects/companies.js. TypeScript maps that path to the .ts file. The same files work in:

  • TypeScript with moduleResolution set to NodeNext, Node16 or Bundler.
  • Vite, Next.js and other bundlers.
  • Plain Node running the output of tsc.

With a bundler or Bundler resolution, import the folder: import { Company } from './hubspot'. Under NodeNext, name the file: import { Company } from './hubspot/index.js'. A .ts file you import from an object file, such as a p.json validator, needs the .js ending under NodeNext too. Node's own type stripping cannot run the .ts files directly, because it does not map .js to .ts; compile them with tsc first.

In a monorepo, run kalup init in the package that owns the portal, such as apps/crm. It adds its ignore lines to the .gitignore, biome.json or .prettierignore it finds up to the repository root, the project name comes from that package's package.json, and .kalup/ stays in the package. Each package with its own kalup.config.ts is a separate project.

Adding a property to the file changes the types at once, with no build step. Types and codecs covers get, set and InferProperties in full.

What survives a pull

Pull merges the portal into the files. The file wins for what HubSpot cannot know. The portal wins for what HubSpot owns.

Yours, kept as writtenThe portal's, refreshed
The TypeScript keylabel
The builder, even when it no longer matches the portal type (a warning)group
as aliasesfieldType
.strict(), .required(), .readonly(), .managed(false)description
lifecycleoptions: the portal's label, hidden and description per value, in portal order
Comments and the file headerhasUniqueValue, formField, hidden, displayOrder, the display hints, calculationFormula, dataSensitivity
The p.json schema argument and its importGroup labels, and a custom object's labels and display fields
A property or group missing from the portalA property new in the portal and in scope: added with a camelCase key
A property a skip override leaves out on the targetWhether a property is managed or a reference

kalup pull has the merge rules in full.

What validate checks

kalup validate runs every rule offline and reports each problem with the file, the line and a fix. Every other command runs the same checks first. The rules, grouped:

AreaCodes
GrammarE_NOT_DATA, E_UNKNOWN_BUILDER, E_BAD_CHAIN, E_MISSING_EXPORT, E_UNSUPPORTED_FILE
UniquenessE_DUPLICATE_ADDRESS, E_DUPLICATE_KEY, E_KEY_COLLISION, E_DUPLICATE_OPTION, E_DUPLICATE_ALIAS
DefinitionsE_TYPE_FIELDTYPE, E_UNKNOWN_GROUP, E_REFERENCE_DEFINITION, E_HS_PREFIX, E_LIFECYCLE, E_STRICT_WITHOUT_OPTIONS
TargetsE_PORTAL_ID, E_TARGET_NAME, E_UNKNOWN_OVERRIDE, E_DEFAULT_TARGET, E_UNKNOWN_TARGET

Warnings never change the exit code: W_PREFIX (a managed property without the project prefix), W_JSON_FIELDTYPE (a p.json whose fieldType is not textarea) and W_UNRESOLVED (an $unresolved marker left by pull).

Each code, what causes it and how to fix it: Errors.

Kalup is an independent open-source project maintained by Scopious. It is not affiliated with, endorsed by, or sponsored by HubSpot, Inc. HubSpot is a registered trademark of HubSpot, Inc.

On this page