Skip to content

Commit 615bb97

Browse files
fix(cli): os build carries a package-relative OpenAPI connector spec into the artifact (#28533)
Fixes #28505 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:`os build` 现在把 `provider: 'openapi'` 连接器以包内相对路径引用的 OpenAPI 文档读出来,直接内联写进 `dist/objectstack.json`;路径无效时构建失败。 - 为什么改:装进别的运行时(`os package install` 或市场安装)只带过去那份 JSON,路径会按宿主自己的目录解析,找不到文件,连接器静默不出现;Stripe 应用装上即用不了。 - 风险与回滚:产物里 `providerConfig.spec` 由路径变成对象,体积随文档增大(Stripe 由约 4.7 KB 到 59.8 KB);读产物的云端流水线需接受内联形态(spec 本就允许)。回滚即还原本 PR,源码不需要改。 - 先看:`packages/cli/src/utils/bundle-connector-spec-files.ts`(只识别 openapi 的 `spec` 字符串且非 http(s) 的那一种);`packages/cli/src/commands/compile.ts` 新增的 4a-bis 段;`packages/qa/dogfood/test/install-local-connector-spec-file.dogfood.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/connector-app-stripe build`,预期输出一行 `Bundling connector spec files... 1 document (46.0 KB)`,且 `dist/objectstack.json` 里 `connectors[0].providerConfig.spec.openapi` 为 `3.0.0`。 - 需要你做的:无,席位落地。 ## What changes - **`os build` carries a package-relative OpenAPI spec into the artifact.** New `packages/cli/src/utils/bundle-connector-spec-files.ts`, wired in `compile.ts` right after the seed-file bundling, split the same way (`collect` = the gate, `place` = the assembly). For every connector entry (top level, `manifest`, and each `packages[i].manifest` body) with `provider: 'openapi'` whose `providerConfig.spec` is a non-empty string that is not an http(s) URL, the file is read relative to the directory holding `objectstack.config.ts`, confined to it by the runtime loader's own rule, parsed as JSON, and written in place of the path. Every other provider, key and form is written as before. - **The build refuses a reference it cannot carry**: absolute, climbing out of the root, naming no file, or naming a file that is not a JSON object. Located at the entry (`connectors[0].providerConfig.spec`), in the text face and in `--json`. - **Docs**: one paragraph in `content/docs/automation/connectors.mdx` (the `provider: 'openapi'` section) says the build carries the file. - **Changeset**: `@objectstack/cli` patch. **What this changes for readers of the artifact** (the cloud ingestion pipeline among them): an artifact built by this CLI carries `providerConfig.spec` as the inline document object, never the path, and is larger by the document's size. The inline form is one the spec schema (`providerConfig: z.record(z.string(), z.unknown())`) and the `openapi` provider already accept; no `packages/spec` change. ### The measurement (before any code), at `23d1303d` Scratch app, `provider: 'openapi'`, `providerConfig.spec: './openapi/fixture.openapi.json'`, `auth: bearer` with the credential set: 1. `os build` wrote `dist/objectstack.json` (0.7 KB) with `providerConfig: { spec: './openapi/fixture.openapi.json' }`, no document. 2. Installed into the showcase (a different root) through `POST /api/v1/marketplace/install-local`, body exactly as `packages/cli/src/commands/package/install.ts` builds it for an artifact file: `200`, `hotLoaded: true`, `credentialRefs[0].status: set`. 3. The reconcile then logged, and `GET /api/v1/automation/connectors` did not list the instance at all (not even degraded): ```text ERROR [Automation] failed to materialize connector instance 'qa28505_fixture' via provider 'openapi' (ADR-0097). {"error":"connector-openapi provider: connector 'qa28505_fixture' failed to read providerConfig.spec './openapi/fixture.openapi.json': package file ref './openapi/fixture.openapi.json' could not be read (resolved to '/home/user/objectstack-issue-28505/examples/app-showcase/openapi/fixture.openapi.json'): ENOENT ..."} ``` The install door caches the artifact JSON alone (`STORAGE_DIR/SAFE_ID.json`, `marketplace-install-local-plugin.ts` header), and `service-automation` hands every instance `createPackageFileLoader(this.options.packageRoot)`, the host's root. An installed package has no directory on disk, so "resolve against the installed package's own root" (route b) has nothing to resolve against without the artifact carrying the file anyway; route (a) needs no contract change, so it is the route taken. ADR-0097 (§ Deliberate scope boundaries) describes the file form as resolved against "the declaring stack/package root (`packageRoot`, CLI default: the `objectstack.config.ts` directory)" and does not anticipate an installed package having a root of its own. After the fix, the real Stripe app (`packages/apps/connector-stripe`, now on `main`) builds to 59.8 KB with the 46.0 KB document inline, and installed into the showcase the same way it materializes `ready` with its eight actions. ## Tests - `packages/cli/src/utils/bundle-connector-spec-files.test.ts` (unit tier): carried with its other keys kept; manifest and `packages[]` bodies; inline document, http(s) URL, another provider and a descriptor left as written; five refusals, each located and naming the reference, nothing changed. - `packages/qa/dogfood/test/install-local-connector-spec-file.dogfood.test.ts`: spawns the BUILT `os build` on a one-connector app, asserts the artifact carries the document, installs it into the showcase through install-local and asserts `GET /automation/connectors` lists the instance `ready` with the document's operation. Declares a measured `test-budget: 20` (class: spawns the real producer once per single-cause fixture). <details><summary>Red before, green after, one ablation (dogfood file, reads the CLI's dist)</summary> - Red, CLI dist built from `main`: `Tests 2 failed (2)` — `expected './openapi/fixture.openapi.json' to deeply equal { openapi: '3.0.3', …(3) }`; the listing held only the showcase's five connectors. - Green after `pnpm --filter @objectstack/cli build`: `Tests 2 passed (2)`. - Ablation, committed head `9b81f435`: `node scripts/ablation-replace.mjs --file packages/cli/src/commands/compile.ts --anchor 'placeConnectorSpecFiles(connectorSpecs);' --replacement 'void placeConnectorSpecFiles; /* ABLATED_SPEC_PLACE */' -- …` → `anchor 1 -> 0, blob daba252 -> 0994c37b6d13`; rebuild; `ablation-dist-preflight @objectstack/cli ABLATED_SPEC_PLACE` → `marker present in 1 built file`; `Tests 2 failed (2)`; restored `blob == HEAD (daba252) and git diff HEAD is empty`. Restore leg: rebuild; preflight `--absent` → `marker absent from all 772 built files`, `working tree clean against HEAD`; `Tests 2 passed (2)`. </details> <details><summary>Final head f6ca481 (after merging origin/main)</summary> - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 src/utils/bundle-connector-spec-files.test.ts src/utils/bundle-seed-files.test.ts` → `Tests 16 passed (16)`. - `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/install-local-connector-spec-file.dogfood.test.ts` → `Tests 2 passed (2)`. - `pnpm --filter @objectstack/connector-app-stripe test` → `Tests 16 passed (16)`. - `pnpm --filter @objectstack/cli typecheck` and `pnpm --filter @objectstack/dogfood typecheck` → exit 0 (`tsc --listFiles`: the dogfood program holds the new test; the CLI's `tsconfig.json` program covers `src/**` tests). - CLI `integration` tier: declared to CI (no integration-tier file or spawn entry touched). </details> ## Acceptance notes - `os validate` does not run this gate; only `os build` does (seed files run in both). A missing spec file is therefore refused at build, not at validate. - `os dev`'s watcher watches `objectstack.config.ts` and `src/`; a spec file outside `src/` (the Stripe app's `./openapi/`) edited during `os dev` reaches the running artifact at the next recompile, not on its own. - A non-OpenAPI-3 document carried inline is refused at materialization naming "(the inline document)" rather than the file path. - The runtime half is untouched: `loadPackageFile` / `resolvePackagePath` still resolve an installed package's file ref against the host root. Measured: an installed package naming `./src/system/connectors/status-openapi.json` materialized `ready` with the showcase host's own document. Reported for a separate card, not fixed here. - ADR-0097's scope-boundary paragraph does not mention build-time carrying; an amendment note is the maintainer's call (governed surface, not edited here). `skills/objectstack-automation/references/integration-recipes.md` recipe 1 stays correct ("ships in the package"). ## Verification <details><summary>Derived gate families, local, head f6ca481</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` → 90 commands; all 90 run, exit 0 each (two first exited 3, PREREQUISITE NOT MET for unbuilt packages outside this diff — `check:skill-examples` needed `@objectstack/client-react` dist, `check:dual-build-cjs-loads` seven package dists; built, re-run: `278 prose examples type-check across 3 surface(s)` and `113 published require entry point(s) across 70 package(s) load`). `--ran` reconciliation: `90 derived famil(ies) accounted for — 90 run, 0 NOT-MEASURED`. Repo-wide scans (`pnpm lint`) and `test:repo` are CI's. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_017xRBcVBKAFk1ntXSjvPt7U)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8a12a17 commit 615bb97

10 files changed

Lines changed: 628 additions & 1 deletion
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os build` carries the OpenAPI document a `provider: 'openapi'` connector names by package-relative file into the artifact, so the app works once installed into another runtime
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A declarative OpenAPI connector may name its document by file, relative to the directory holding `objectstack.config.ts` (`providerConfig.spec: './openapi/stripe.openapi.json'`). The build used to write that path into `dist/objectstack.json` unchanged and carry no document. The runtime the artifact is installed into (`os package install ./dist/objectstack.json`, or a marketplace install) keeps the artifact JSON and nothing else, so the path resolved against that runtime's own root, named no file, and the connector never came up: the install answered success, and the instance was missing from `GET /api/v1/automation/connectors`.
11+
12+
- **What the build now writes.** For each `provider: 'openapi'` entry whose `providerConfig.spec` is a string that is not an http(s) URL, the file is read and its JSON written in place of the path, as the inline document the provider already accepts. Every other key of `providerConfig`, an inline document, an http(s) URL and every other provider are written as before. The entries of `packages[i].manifest` bodies are read too.
13+
- **What the build now refuses.** A spec path that is absolute, climbs out of the directory holding the config, names no file, or names a file that is not a JSON object fails the build, located at the entry (`connectors[0].providerConfig.spec`) — it would fail the instance after install otherwise.
14+
- **What changes for readers of the artifact.** An artifact built this way carries the document (its size grows by the document's), and `providerConfig.spec` there is an object rather than a path. Whether the document is OpenAPI 3.x is still judged by the provider when the instance materializes.
15+
- **Your fix, if you ship such an app:** rebuild it with this CLI and publish or install the new artifact. Nothing in the source changes.

‎content/docs/automation/connectors.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ only the listed operations become actions). The `spec` takes any of three forms:
131131
- a **package-relative file path** like `'./specs/billing-openapi.json'`,
132132
resolved against the directory containing `objectstack.config.ts` and
133133
**confined to it** — absolute and `..`-escaping paths are rejected.
134+
`os build` reads the file and writes its JSON into
135+
`dist/objectstack.json` in place of the path, so an artifact installed into
136+
another runtime carries the document; a path that names no file, or no JSON
137+
object, fails the build.
134138

135139
{/* os:check */}
136140
```typescript

‎packages/cli/src/commands/compile.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from '@objectstack/spec';
1717
import { loadConfig, namedExportRejectionHints } from '../utils/config.js';
1818
import { collectSeedFiles, placeSeedFiles } from '../utils/bundle-seed-files.js';
19+
import { collectConnectorSpecFiles, placeConnectorSpecFiles } from '../utils/bundle-connector-spec-files.js';
1920
import { judgeSeedRecordValues } from '../utils/seed-record-values.js';
2021
import { refuseUnbuiltStack } from '../utils/stack-provenance-refusal.js';
2122
import { isExtractionRefusal, lowerCallables } from '../utils/lower-callables.js';
@@ -1122,6 +1123,30 @@ export default class Compile extends Command {
11221123
printStep(`Bundling seed files... ${seedFiles.files} file${seedFiles.files === 1 ? '' : 's'} (${(seedFiles.bytes / 1024).toFixed(1)} KB)`);
11231124
}
11241125

1126+
// 4a-bis. The OpenAPI document a `provider: 'openapi'` connector names
1127+
// by package-relative file (ADR-0097), read relative to the
1128+
// directory holding the config and carried in the artifact as the
1129+
// inline document. An installed artifact has no package directory —
1130+
// the runtime it is installed into caches the JSON alone — so a
1131+
// reference left as a path resolves against that runtime's own root
1132+
// and names nothing. A path that names no file fails the build here
1133+
// rather than the instance after install.
1134+
const connectorSpecs = collectConnectorSpecFiles(absolutePath, finalBundle);
1135+
if (connectorSpecs.issues.length > 0) {
1136+
if (flags.json) {
1137+
await emitJson({ success: false, error: 'connector spec file bundling failed', issues: connectorSpecs.issues, warnings: warningsSoFar(), conversions: conversionNotices }, 0, { compact: true });
1138+
this.exit(1);
1139+
}
1140+
console.log('');
1141+
printError(`Connector spec file bundling failed (${connectorSpecs.issues.length} issue${connectorSpecs.issues.length > 1 ? 's' : ''})`);
1142+
for (const issue of connectorSpecs.issues) console.log(chalk.red(` ✗ ${issue.location}: ${issue.message}`));
1143+
this.exit(1);
1144+
}
1145+
placeConnectorSpecFiles(connectorSpecs);
1146+
if (connectorSpecs.files > 0 && !flags.json) {
1147+
printStep(`Bundling connector spec files... ${connectorSpecs.files} document${connectorSpecs.files === 1 ? '' : 's'} (${(connectorSpecs.bytes / 1024).toFixed(1)} KB)`);
1148+
}
1149+
11251150
// 4b. Bundle handler functions into `<artifactDir>/objectstack-runtime.{hash}.mjs`
11261151
// and stamp the relative path into the JSON so the runtime can
11271152
// dynamic-import it at boot. `runtimeModule` is part of the

‎packages/cli/src/commands/validate.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { resolveJsxGateManifest, printJsxGateNotices } from '../utils/sdui-manif
2626
import { preflightDeclaredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
2727
import { collectAndLintDocs, type DocIssue } from '../utils/collect-docs.js';
2828
import { collectSeedFiles } from '../utils/bundle-seed-files.js';
29+
import { collectConnectorSpecFiles } from '../utils/bundle-connector-spec-files.js';
2930
import { judgeSeedRecordValues } from '../utils/seed-record-values.js';
3031
import {
3132
printHeader,
@@ -804,6 +805,29 @@ export default class Validate extends Command {
804805
this.exit(1);
805806
}
806807

808+
// 3d-ter. Connector spec files — the OpenAPI document a
809+
// `provider: 'openapi'` connector names by package-relative file must
810+
// name a JSON object under the directory holding the config. `os build`
811+
// refuses the same references before it carries the documents into the
812+
// artifact, where an installed copy has no package directory to read.
813+
const connectorSpecs = collectConnectorSpecFiles(absolutePath, result.data as Record<string, unknown>);
814+
if (connectorSpecs.issues.length > 0) {
815+
if (flags.json) {
816+
await emitJson({
817+
valid: false,
818+
errors: connectorSpecs.issues,
819+
warnings: warningsSoFar(),
820+
conversions: conversionNotices,
821+
duration: timer.elapsed(),
822+
});
823+
this.exit(1);
824+
}
825+
console.log('');
826+
printError(`Connector spec file validation failed (${connectorSpecs.issues.length} issue${connectorSpecs.issues.length > 1 ? 's' : ''})`);
827+
for (const issue of connectorSpecs.issues) console.log(chalk.red(` ✗ ${issue.location}: ${issue.message}`));
828+
this.exit(1);
829+
}
830+
807831
// 3d-bis. [#27259] Seed record VALUES — each value a seed record writes,
808832
// judged by the insert write's own value verdict
809833
// (`validateInsertValues`, `@objectstack/objectql`), so a value the
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import fs from 'node:fs';
4+
import os from 'node:os';
5+
import path from 'node:path';
6+
import { afterEach, describe, expect, it } from 'vitest';
7+
import { collectConnectorSpecFiles, placeConnectorSpecFiles } from './bundle-connector-spec-files.js';
8+
9+
/**
10+
* `os build` reads the OpenAPI document a `provider: 'openapi'` connector names
11+
* by package-relative file, relative to the directory holding the config
12+
* (`collectConnectorSpecFiles`), and writes it into the entry's
13+
* `providerConfig.spec` as the inline document (`placeConnectorSpecFiles`), so
14+
* an artifact installed into another runtime carries it. A reference that is
15+
* absolute, climbs out of the root, names no file or names no JSON object is
16+
* refused; every other provider, key and form is left as written.
17+
*/
18+
19+
const DOC = { openapi: '3.0.3', info: { title: 'Fixture', version: '1.0.0' }, paths: {} };
20+
21+
const roots: string[] = [];
22+
afterEach(() => {
23+
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
24+
});
25+
26+
/** A package root holding `files`; answers the path of its (unread) config. */
27+
function packageRoot(files: Record<string, string>): string {
28+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'os-connector-spec-'));
29+
roots.push(root);
30+
for (const [rel, content] of Object.entries(files)) {
31+
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
32+
fs.writeFileSync(path.join(root, rel), content);
33+
}
34+
return path.join(root, 'objectstack.config.ts');
35+
}
36+
37+
/** Both halves, as `os build` runs them. */
38+
function bundle(stack: Record<string, unknown>, config: string) {
39+
const collected = collectConnectorSpecFiles(config, stack);
40+
if (collected.issues.length === 0) placeConnectorSpecFiles(collected);
41+
return collected;
42+
}
43+
44+
const openapi = (spec: unknown, name = 'billing') => ({
45+
name,
46+
provider: 'openapi',
47+
providerConfig: { spec, include: ['GetThings'] },
48+
});
49+
50+
describe('os build — connector spec files', () => {
51+
it('carries a package-relative spec into the entry as the inline document, keeping its other keys', () => {
52+
const config = packageRoot({ 'openapi/billing.json': JSON.stringify(DOC) });
53+
const stack = { connectors: [openapi('./openapi/billing.json')] };
54+
const collected = bundle(stack, config);
55+
expect(collected.issues).toEqual([]);
56+
expect(collected.files).toBe(1);
57+
expect(stack.connectors[0].providerConfig).toEqual({ spec: DOC, include: ['GetThings'] });
58+
});
59+
60+
it('reads the entries of every package body and the manifest, by where each sits', () => {
61+
const config = packageRoot({ 'a.json': JSON.stringify(DOC), 'b.json': JSON.stringify({ ...DOC, x: 1 }) });
62+
const stack = {
63+
manifest: { connectors: [openapi('a.json', 'a')] },
64+
packages: [{ manifest: { connectors: [openapi('b.json', 'b')] } }],
65+
};
66+
const collected = bundle(stack, config);
67+
expect(collected.issues).toEqual([]);
68+
expect(stack.manifest.connectors[0].providerConfig.spec).toEqual(DOC);
69+
expect(stack.packages[0].manifest.connectors[0].providerConfig.spec).toEqual({ ...DOC, x: 1 });
70+
});
71+
72+
it('leaves an inline document, an http(s) URL and every other provider as written', () => {
73+
const config = packageRoot({ 'x.json': JSON.stringify(DOC) });
74+
const stack = {
75+
connectors: [
76+
openapi(DOC, 'inline'),
77+
openapi('https://example.com/openapi.json', 'remote'),
78+
{ name: 'rest', provider: 'rest', providerConfig: { spec: 'x.json', baseUrl: 'https://example.com' } },
79+
{ name: 'descriptor', type: 'api' },
80+
],
81+
};
82+
const before = JSON.parse(JSON.stringify(stack));
83+
const collected = bundle(stack, config);
84+
expect(collected).toMatchObject({ issues: [], files: 0 });
85+
expect(stack).toEqual(before);
86+
});
87+
88+
it.each([
89+
['names no file', './openapi/absent.json'],
90+
['climbs out of the root', '../outside.json'],
91+
['is absolute', '/etc/openapi.json'],
92+
['is not JSON', 'bad.json'],
93+
['is not a JSON object', 'list.json'],
94+
])('refuses a reference that %s, located and naming it, and changes nothing', (_label, ref) => {
95+
const config = packageRoot({ 'bad.json': 'openapi: 3.0.3', 'list.json': '[]' });
96+
const stack = { connectors: [openapi(ref)] };
97+
const collected = collectConnectorSpecFiles(config, stack);
98+
expect(collected.issues).toHaveLength(1);
99+
expect(collected.issues[0].location).toBe('connectors[0].providerConfig.spec');
100+
expect(collected.issues[0].message).toContain(`connector 'billing' providerConfig.spec '${ref}'`);
101+
expect(stack.connectors[0].providerConfig.spec).toBe(ref);
102+
});
103+
});

0 commit comments

Comments
 (0)