Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/culori-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'onsystem': patch
---

The published types no longer fail a TypeScript project that checks library types (`skipLibCheck: false`) with TS7016, "Could not find a declaration file for module 'culori'", as soon as it imports `onsystem`, as an `onsystem.config.ts` with `defineConfig` does. The color types the API uses come from `@types/culori`, which is now a dependency rather than a devDependency; with `skipLibCheck: true`, those types were read as `any`.
9 changes: 7 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Contributing

Bug reports with a minimal snippet and the `inspect` output are the most useful contribution: most fixes start from one. For a change in behaviour, open an issue first so we can agree on it before you write it.
Bug reports with a minimal snippet and the `inspect` output are the most useful contribution: most fixes start from one. For a change in behaviour, open an issue first so we can agree on it before you write it. Issues that make a good first change are labelled [good first issue](https://github.com/dgesteves/onsystem/labels/good%20first%20issue).

## New integrations

Expand All @@ -17,6 +17,8 @@ corepack enable
pnpm install
```

Without Corepack, install pnpm 11 with npm (`npm install -g pnpm@11`), then run `pnpm install`. Until the first `pnpm build`, `pnpm install` warns that it failed to create a bin at `site/node_modules/.bin/onsystem`: the website depends on this package, whose `dist/cli.js` the build writes. The warning is harmless.

## Checks

CI runs these on every pull request, in this order:
Expand All @@ -25,14 +27,17 @@ CI runs these on every pull request, in this order:
pnpm lint # ESLint
pnpm format:check # Prettier (pnpm format writes)
pnpm typecheck # tsc --noEmit
pnpm --filter "./examples/*" typecheck # the examples, against this package's types
pnpm build # tsdown, then schema.json from the config schema
pnpm smoke # the built server over stdio, every tool, against examples/shadcn-demo
pnpm test # Vitest, on Node 22 and 24 (Linux), and on Windows and macOS
pnpm test # Vitest, on Node 22, 24 and 26 (Linux), and on Windows and macOS
pnpm pack-test # npm pack, install into a new project, run it (Linux, Windows, macOS)
```

A pull request that touches `src/` also runs the [real-world corpus](#real-world-corpus), which is not a required check yet.

The Website job builds the site, which renders `docs/*.md` and data generated from the built library: after `pnpm build`, it runs `pnpm --filter site generate`, then the site's `test`, `typecheck`, `lint` and `build`. Run them when you change the docs or `site/`. To see a docs page as the website shows it, run `pnpm build`, then `pnpm --filter site dev`, which serves the site on http://localhost:3300.

`pnpm smoke` spawns `dist/cli.js` the way an MCP client does, so it catches what unit tests cannot: a broken build, a tool that fails over stdio, output that no longer parses. It connects twice, on the 2025 protocol and on 2026-07-28. Run it after `pnpm build`; add `--verbose` to see every response. CI also runs `check` on the demo's clean page (it must pass) and the Claude Code hook on its draft (it must block).

`pnpm pack-test` (after `pnpm build`) tests the package as a user gets it: it packs the repository with `npm pack`, so the publish hooks run, installs the tarball into a new project in the system temp folder, and runs the installed `onsystem` bin (`--version`, `inspect`, `check`), an MCP stdio handshake with the installed server in both protocol eras, and the Claude Code hook, which picks up the project's own install. Then it installs the tarball with plain npm into a project already on ESLint 8.57.0, the oldest version the `eslint` peer range admits (npm stops the install when the range leaves the project's ESLint out), checks that the installed `onsystem/eslint` imports no `eslint`, and lints and fixes a page with that ESLint (`scripts/pack-test-eslint8.mjs`). It needs the npm registry for the package's dependencies.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
},
"dependencies": {
"@modelcontextprotocol/server": "^2.3.1",
"@types/culori": "^4.0.1",
"culori": "^4.0.2",
"postcss": "^8.5.30",
"stemmer": "^2.0.1",
Expand All @@ -110,7 +111,6 @@
"@eslint/js": "^10.0.1",
"@modelcontextprotocol/client": "^2.3.1",
"@modelcontextprotocol/sdk": "^1.32.1",
"@types/culori": "^4.0.1",
"@types/node": "^24.19.2",
"@vitest/coverage-istanbul": "^5.0.5",
"ajv": "^8.20.0",
Expand Down
6 changes: 3 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions test/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,32 @@ describe('packages for other agents', () => {
]);
const plugin = readJson('plugins/onsystem-cursor/.cursor-plugin/plugin.json');
expect(plugin).toMatchObject({ name: 'onsystem', version, logo: 'icon.svg' });
// The properties of Cursor's manifest schema (github.com/cursor/plugins,
// schemas/plugin.schema.json), and no other: the schema sets additionalProperties to false.
const fields = [
'name',
'displayName',
'description',
'version',
'minClientVersions',
'author',
'publisher',
'homepage',
'repository',
'license',
'logo',
'keywords',
'category',
'tags',
'commands',
'agents',
'skills',
'rules',
'hooks',
'variables',
'mcpServers',
];
expect(Object.keys(plugin).filter((key) => !fields.includes(key))).toEqual([]);
expect(plugin.name).toMatch(/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/);
expect(fs.existsSync(path.join(CURSOR, 'icon.svg'))).toBe(true);
const skill = frontMatter(path.join(CURSOR, SKILL));
Expand Down
78 changes: 78 additions & 0 deletions test/published-types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import fs from 'node:fs';
import { builtinModules } from 'node:module';
import path from 'node:path';

import { build } from 'tsdown';
import { beforeAll, describe, expect, it } from 'vitest';

// The type declarations the package publishes import types from other packages: an install has
// those types only when `dependencies` (or a peer) brings them. `Oklch` from culori, which ships
// no types of its own, came from @types/culori, a devDependency, so a project with skipLibCheck
// off failed to typecheck on `import { defineConfig } from 'onsystem'` (TS7016), and with it on
// read those types as `any`. Built as it ships, into a folder of its own.
const REPO = path.resolve(import.meta.dirname, '..');
const OUT = path.join(REPO, 'node_modules/.cache/onsystem-published-types-test');
const pkg = JSON.parse(fs.readFileSync(path.join(REPO, 'package.json'), 'utf8')) as {
dependencies: Record<string, string>;
peerDependencies: Record<string, string>;
};

let declarations: Map<string, string>;

beforeAll(async () => {
await build({
cwd: REPO,
config: false,
entry: { index: 'src/index.ts', eslint: 'src/eslint/index.ts' },
outDir: OUT,
format: 'esm',
platform: 'node',
target: 'node22.18',
fixedExtension: false,
dts: true,
publint: false,
clean: true,
logLevel: 'silent',
});
declarations = new Map(
fs
.readdirSync(OUT)
.filter((file) => file.endsWith('.d.ts'))
.map((file) => [file, fs.readFileSync(path.join(OUT, file), 'utf8')]),
);
}, 120_000);

/** The package an import specifier names: `culori`, `@modelcontextprotocol/server`. */
function packageOf(specifier: string): string {
const parts = specifier.split('/');
return (specifier.startsWith('@') ? parts.slice(0, 2) : parts.slice(0, 1)).join('/');
}

/** Whether the installed package ships its own type declarations. */
function shipsTypes(name: string): boolean {
const json = fs.readFileSync(path.join(REPO, 'node_modules', name, 'package.json'), 'utf8');
const { types, typings } = JSON.parse(json) as { types?: string; typings?: string };
return Boolean(types ?? typings) || /"types"\s*:/.test(json);
}

describe('the published type declarations', () => {
it('import types only from packages an install brings with their types', () => {
const imported = new Set<string>();
for (const text of declarations.values()) {
for (const match of text.matchAll(/(?:\bfrom\s+|\bimport\(\s*)["']([^"'.][^"']*)["']/g)) {
const specifier = match[1] ?? '';
if (specifier.startsWith('node:') || builtinModules.includes(specifier)) continue;
imported.add(packageOf(specifier));
}
}
// The build read the public entry points: the API's types come from these.
expect(imported).toContain('culori');
const installed = { ...pkg.dependencies, ...pkg.peerDependencies };
for (const name of imported) {
expect(installed, `${name} is imported by the declarations`).toHaveProperty([name]);
if (shipsTypes(name)) continue;
const types = `@types/${name.replace(/^@/, '').replace('/', '__')}`;
expect(pkg.dependencies, `${name} ships no types: ${types}`).toHaveProperty([types]);
}
});
});
Loading