Skip to content
7 changes: 7 additions & 0 deletions .changeset/cli-hook-body-subpath-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@objectstack/cli': minor
---

Ratify `./hook-body` as a public subpath export — `extractHookBody`, `HookBodyExtractionError`, `HookBodyRefusalKind` and `ExtractedBody` were reachable as a deep `dist/utils/extract-hook-body.js` import until #13123 sealed the surface, and an app's hook-body fidelity harness (hotcrm's `test/helpers/action-sandbox.ts`) consumes them to run the SAME body-only lowering `os build` ships through the real QuickJS runner, so a test executes what production executes rather than a lookalike. The #13123 body names exactly this remedy for an out-of-repo consumer — ratify the subpath as public surface rather than read `dist/` paths — and 17.3.0 applied it to `./console` for cloud's `objectos-runtime`; this applies it to the second consumer (#15325). `@objectstack/cli/hook-body` is a dedicated entry that re-exports those four names and nothing else; the deep `dist/` path stays sealed. Also admits `./package.json`, so the ordinary tooling idiom of reading a dependency's own manifest resolves again.

`minor`, not `patch`: a new subpath on a published package's `exports` map is a purely additive widening of its public surface — a new accepted key — which takes at least `minor` under the maintainer's 2026-09-04 rule (decision batch #35, on #15294) in the Check Changeset step's "WHICH LEVEL" prose; the commit type never lowers it.
46 changes: 46 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,52 @@ os environments bind <id> --artifact dist/objectstack.json # 8. Bind to a Cloud
└── package.json # oclif config under "oclif" key
```

## Public subpath exports

`@objectstack/cli` is a command-line tool first, and its `exports` map is
deliberately sealed: a deep `dist/` path is not a supported import and an
internal refactor may move it without notice. What an out-of-repo consumer may
resolve is exactly this map — a subpath is added here on purpose, with a
`minor` changeset, never discovered by reaching into `dist/`:

| Subpath | What it is for |
|:---|:---|
| `@objectstack/cli` | The command classes `bin/run.js` loads — the oclif entry. |
| `@objectstack/cli/console` | Console SPA resolution helpers (`resolveConsolePath`, `hasConsoleDist`, `createConsoleStaticPlugin` and the drift guards), consumed by cloud's `objectos-runtime` node server to mount the Console. |
| `@objectstack/cli/hook-body` | The hook-body extractor `os build` and `os lint` apply, for an app harness that must run the **same** body-only lowering the build ships (below). |
| `@objectstack/cli/package.json` | The manifest itself, for the ordinary tooling idiom of reading a dependency's own version. |

### `@objectstack/cli/hook-body`

```typescript
import { extractHookBody, HookBodyExtractionError } from '@objectstack/cli/hook-body';
import type { ExtractedBody, HookBodyRefusalKind } from '@objectstack/cli/hook-body';

// The metadata-only source `os build` ships for this handler — hand it to the
// runtime's QuickJS runner in a test and you execute what production executes.
const body: ExtractedBody = extractHookBody(handler, 'hooks.account.beforeInsert');
body.source; // the lowered function body
body.capabilities; // the capability tokens inferred from it

// A handler that is no longer shippable body-only is refused with the SAME
// classification `os lint` reports, so a test can assert the kind, not prose.
try {
extractHookBody(leakyHandler, 'hooks.account.afterUpdate');
} catch (e) {
if (e instanceof HookBodyExtractionError) {
const kind: HookBodyRefusalKind = e.kind; // 'unparseable' | 'forbidden-token' | 'free-identifiers'
e.freeIdentifiers; // the module-scope names the handler reached for
e.nodeOnlyIdentifiers; // the subset only the Node host provides
}
}
```

An app that wants to assert "my hooks are still metadata-only" needs the
platform's own extractor: a local reimplementation passes its own tests while
diverging from the rule the build actually applies. `os lint`'s
`hook-body/not-lowerable` rule answers the pass/fail question; this entry hands
a test the lowered `source` to run. The four names above are the whole surface
— the entry re-exports them and nothing else.

## Default capability slate (always-on)

Expand Down
7 changes: 6 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@
"./console": {
"types": "./dist/utils/console.d.ts",
"default": "./dist/utils/console.js"
}
},
"./hook-body": {
"types": "./dist/hook-body.d.ts",
"default": "./dist/hook-body.js"
},
"./package.json": "./package.json"
},
"bin": {
"objectstack": "./bin/run.js",
Expand Down
42 changes: 42 additions & 0 deletions packages/cli/src/hook-body.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `@objectstack/cli/hook-body` — the public entry for the hook-body extractor.
*
* ## Why this entry exists (#15325)
*
* `extractHookBody` decides whether a hook or script action is still shippable
* **body-only**: it peels the handler to its statements, refuses the forbidden
* tokens, infers capabilities, and throws a `HookBodyExtractionError` carrying
* `kind` / `freeIdentifiers` / `nodeOnlyIdentifiers`. `os build` applies it to
* lower a handler, `os lint` calls the same function so its verdict cannot
* drift from the build's. An app that wants to assert "my hooks are still
* metadata-only" — and to RUN the lowered `source` through the real QuickJS
* runner in a test — needs this exact function, not a lookalike: a local
* reimplementation passes its own tests while diverging from the rule the
* build actually applies, which is the failure mode #13651 was filed about.
*
* Until 17.3.0 the extractor was reachable as a deep `dist/utils/` import, and
* one out-of-repo consumer (hotcrm's hook-body fidelity harness) reached it
* that way on purpose. #13123 then sealed this package behind an `exports`
* map and named the remedy for an out-of-repo consumer in its own body:
* ratify the subpath as public surface rather than read `dist/` paths. That
* remedy was applied to `./console` for cloud's `objectos-runtime` (#13662);
* this entry applies it to the second consumer.
*
* ## Why a dedicated file and not the internal module itself
*
* `./console` points its subpath straight at `dist/utils/console.js`, so every
* export that module ever gains is public the moment it lands. The card asks
* for four names, and that is what this file re-exports — by name, no star. An
* export `extract-hook-body.ts` grows tomorrow is NOT public until someone
* edits this list, and `test/published-subpath-hook-body.pin.test.ts` holds
* the packed `.d.ts` to exactly these four so the widening is a deliberate,
* reviewed, `minor`-bumped act rather than a side effect of a refactor.
*
* ⛔ Do not add to this list to make something convenient reachable. A new
* name here is a new public contract on a published package.
*/

export { extractHookBody, HookBodyExtractionError } from './utils/extract-hook-body.js';
export type { ExtractedBody, HookBodyRefusalKind } from './utils/extract-hook-body.js';
Loading
Loading