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
14 changes: 14 additions & 0 deletions .changeset/26332-create-in-repo-typescript.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/cli': patch
---

fix(cli): an `os create plugin NAME --in-repo` scaffold passes its own `typecheck` and `build`

Clause-②: no
Metadata-protocol: none

- **Before.** The in-repo plugin declared `typescript` as the standalone scaffold range (`^5.3.0`, installed as 5.9.3). It extends `tsconfig.base.json`, whose `ignoreDeprecations: "6.0"` only TypeScript 6 accepts, so `tsc` stopped with TS5103. With the workspace's TypeScript 6 it then stopped on the starter `init(context)` with TS6133, under the base's `noUnusedParameters`.
- **After.** The in-repo placement declares the workspace's TypeScript (`^6.0.3`, the range the repository root and every sibling plugin declare). The starter's `init` parameter is `_context`, with a comment saying what it is for. A fresh in-repo scaffold's `typecheck` and `build` both exit 0.
- The standalone (default) placement still declares `^5.3.0`, the documented TypeScript floor. Its starter `src/index.ts` takes the same `_context` rename, since both placements share one starter.

What to do: nothing for new scaffolds. An in-repo plugin scaffolded earlier: set its `devDependencies.typescript` to `^6.0.3`, and rename `init(context)` to `init(_context)` unless `init` reads it.
26 changes: 24 additions & 2 deletions packages/cli/src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,26 @@ export function objectstackDependencySpec(placement: ScaffoldPlacement): string
return placement === 'in-repo' ? 'workspace:*' : getCliVersion();
}

/**
* The TypeScript range an emitted manifest declares, by placement.
*
* `standalone` is `SCAFFOLD_TYPESCRIPT_RANGE`, the support floor the docs
* promise a developer's own project. `in-repo` is this workspace's TypeScript:
* the scaffold extends `tsconfig.base.json`, whose `ignoreDeprecations: "6.0"`
* only TypeScript 6 accepts, so the standalone floor (installed as 5.x) failed
* the new package's first `tsc` with TS5103. ⛔ Never `SCAFFOLD_TYPESCRIPT_RANGE`
* here, and never this range for `standalone`.
*
* `test/create-in-repo-scaffold-typechecks.test.ts` pins it equal to the range
* this package declares (the root and every sibling package declare the same
* one), so a workspace TypeScript bump reddens there until this moves with it.
*/
export const IN_REPO_TYPESCRIPT_RANGE = '^6.0.3';

export function typescriptDependencySpec(placement: ScaffoldPlacement): string {
return placement === 'in-repo' ? IN_REPO_TYPESCRIPT_RANGE : SCAFFOLD_TYPESCRIPT_RANGE;
}

/**
* The `extends` an in-repo scaffold needs to reach this repo's shared
* `tsconfig.base.json` — the file every package here extends — DERIVED from
Expand Down Expand Up @@ -421,7 +441,7 @@ export const templates: Record<string, CreateTemplate> = {
},
devDependencies: {
'@types/node': SCAFFOLD_TYPES_NODE_RANGE,
typescript: SCAFFOLD_TYPESCRIPT_RANGE,
typescript: typescriptDependencySpec(placement),
vitest: SCAFFOLD_VITEST_RANGE,
},
// The same npm override `os init` emits, from the same constant, so
Expand Down Expand Up @@ -475,7 +495,9 @@ export const ${sanitizeIdentifier(name)}Plugin: Plugin = {
name: '${name}',
version: '0.1.0',

async init(context) {
// \`_context\` is the kernel's plugin context: register services and hooks
// through it. Drop the underscore once init reads it.
async init(_context) {
console.log('Initializing ${name} plugin...');
// Plugin initialization logic
},
Expand Down
162 changes: 162 additions & 0 deletions packages/cli/test/create-in-repo-scaffold-typechecks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* PIN (#26332) — a fresh `os create plugin NAME --in-repo` passes its own
* `typecheck` and `build` scripts.
*
* ## The defect
*
* Once the in-repo scaffold extended `tsconfig.base.json` and reached its own
* `src/`, its first `pnpm typecheck` in the new package was red twice over:
*
* - TS5103 "Invalid value for '--ignoreDeprecations'": the manifest declared
* `typescript` as `SCAFFOLD_TYPESCRIPT_RANGE` (`^5.3.0`, installed as
* 5.9.3), while the base sets `ignoreDeprecations: "6.0"`, a value only
* TypeScript 6 accepts. Every sibling package declares the workspace's
* TypeScript 6 range.
* - TS6133 "'context' is declared but its value is never read": the starter
* `init(context)` against the base's `noUnusedParameters`.
*
* ## What is run, and where
*
* The in-repo emission is materialised through `create`'s own renderers into a
* sandbox laid out like this repository: the real `tsconfig.base.json` copied
* to the sandbox root, and the scaffold at `packages/plugins/plugin-NAME`
* beneath it, so the emitted `extends` resolves exactly as it does in the
* monorepo. The emitted `typecheck` and `build` scripts are then spawned with
* the workspace's own `tsc`, the compiler the in-repo manifest now declares.
*
* The sandbox lives under this package's `node_modules` (git-ignored), the
* placement `scaffold-emission-typechecks.test.ts` documents: an emitted
* `import … from '@objectstack/spec/…'` resolves by the ordinary upward walk.
* TS2307 on the spec package means its `dist/` is not built; build it with
* `pnpm --filter '@objectstack/cli^...' build`.
*
* The TypeScript VERSION a contributor installs cannot be spawned here without
* an install, so the TS5103 half is pinned on the declaration instead: the
* in-repo range is the range this package declares for the very `tsc` the
* cases spawn (the workspace's TypeScript, which the root and every sibling
* declare too), and its floor is a major that accepts the base's
* `ignoreDeprecations`. The standalone range is pinned unchanged beside it.
*
* ## The control
*
* A planted unused parameter must fail the same `typecheck` with TS6133. Green
* there would mean the scaffold no longer compiles under the base's
* strictness, and the verdict on the starter would mean nothing.
*/

import { afterAll, describe, expect, it } from 'vitest';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { IN_REPO_TYPESCRIPT_RANGE, templates } from '../src/commands/create.js';
import { SCAFFOLD_TYPESCRIPT_RANGE } from '../src/commands/init.js';
import { childEnv } from './helpers/serve-process.js';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const PKG = path.resolve(HERE, '..');
const BASE_TSCONFIG = path.resolve(HERE, '../../..', 'tsconfig.base.json');

const require = createRequire(import.meta.url);
const TSC = require.resolve('typescript/bin/tsc');
const ts = require('typescript') as typeof import('typescript');

const PROJECT = 'probe';
const template = templates.plugin;

const TMP_ROOT = fs.mkdtempSync(path.join(HERE, '..', 'node_modules', '.create-in-repo-typecheck-'));
afterAll(() => {
fs.rmSync(TMP_ROOT, { recursive: true, force: true });
});

interface Manifest {
scripts: Record<string, string>;
devDependencies: Record<string, string>;
}

function manifestFor(placement: 'in-repo' | 'standalone'): Manifest {
return template.filesFor(placement)['package.json'](PROJECT) as Manifest;
}

/** Materialise the in-repo emission under a copy of this repo's base; returns the scaffold dir. */
function emitInRepo(): string {
const workspace = fs.mkdtempSync(path.join(TMP_ROOT, 'ws-'));
fs.copyFileSync(BASE_TSCONFIG, path.join(workspace, 'tsconfig.base.json'));
const dir = path.join(workspace, template.inRepoDir, template.dirName(PROJECT));
for (const [filePath, render] of Object.entries(template.filesFor('in-repo'))) {
const content = render(PROJECT);
fs.mkdirSync(path.dirname(path.join(dir, filePath)), { recursive: true });
fs.writeFileSync(
path.join(dir, filePath),
typeof content === 'string' ? content : JSON.stringify(content, null, 2) + '\n',
);
}
return dir;
}

/** Spawn one emitted script, its `tsc` resolved to the workspace's compiler. */
function runScript(dir: string, script: string): { code: number | null; output: string } {
const [bin, ...args] = manifestFor('in-repo').scripts[script].trim().split(/\s+/);
expect(bin, `the in-repo \`${script}\` script no longer runs tsc`).toBe('tsc');
const res = spawnSync(process.execPath, [TSC, '--pretty', 'false', ...args], {
cwd: dir,
encoding: 'utf-8',
env: childEnv({ NO_COLOR: '1' }),
});
return { code: res.status, output: `${res.stdout ?? ''}${res.stderr ?? ''}` };
}

/** The `ignoreDeprecations` value the base sets, read through the compiler's own config parser. */
function baseIgnoreDeprecations(): string {
const read = ts.readConfigFile(BASE_TSCONFIG, ts.sys.readFile);
expect(read.error, 'tsconfig.base.json did not parse').toBeUndefined();
return String(read.config.compilerOptions.ignoreDeprecations);
}

describe('os create plugin --in-repo: the scaffold passes its own scripts', () => {
it('a planted unused parameter fails `typecheck` with TS6133 (control)', () => {
const dir = emitInRepo();
fs.writeFileSync(path.join(dir, 'src', 'canary.ts'), 'export function canary(unusedProbe: string) {}\n');

const { code, output } = runScript(dir, 'typecheck');

expect(code, `the canary compiled, so the scaffold is not under the base's strictness:\n${output}`).not.toBe(0);
expect(output).toContain('TS6133');
expect(output).toContain('unusedProbe');
}, 120_000);

it('`typecheck` and `build` exit 0, and `build` leaves the starter test out of dist', () => {
const dir = emitInRepo();

const typecheck = runScript(dir, 'typecheck');
expect(typecheck.code, `a fresh in-repo scaffold fails its own \`typecheck\`:\n${typecheck.output}`).toBe(0);

const build = runScript(dir, 'build');
expect(build.code, `a fresh in-repo scaffold fails its own \`build\`:\n${build.output}`).toBe(0);
expect(fs.existsSync(path.join(dir, 'dist', 'index.js'))).toBe(true);
expect(fs.existsSync(path.join(dir, 'dist', 'index.d.ts'))).toBe(true);
expect(fs.existsSync(path.join(dir, 'dist', 'index.test.js'))).toBe(false);
}, 120_000);
});

describe('os create plugin: the TypeScript each placement declares', () => {
it('in-repo declares the workspace\'s TypeScript, the range of the tsc the cases above spawn', () => {
const own = JSON.parse(fs.readFileSync(path.join(PKG, 'package.json'), 'utf8')) as Manifest;
expect(manifestFor('in-repo').devDependencies.typescript).toBe(own.devDependencies.typescript);
expect(IN_REPO_TYPESCRIPT_RANGE).toBe(own.devDependencies.typescript);
});

it('in-repo\'s TypeScript floor accepts the base\'s ignoreDeprecations (TS5103)', () => {
const floorMajor = Number(/^\^?(\d+)\./.exec(manifestFor('in-repo').devDependencies.typescript)?.[1]);
const acceptedFrom = Number(/^(\d+)\./.exec(baseIgnoreDeprecations())?.[1]);
expect(Number.isFinite(acceptedFrom), 'the base sets no numeric ignoreDeprecations').toBe(true);
expect(floorMajor).toBeGreaterThanOrEqual(acceptedFrom);
});

it('standalone still declares SCAFFOLD_TYPESCRIPT_RANGE', () => {
expect(manifestFor('standalone').devDependencies.typescript).toBe(SCAFFOLD_TYPESCRIPT_RANGE);
});
});
5 changes: 4 additions & 1 deletion scripts/check-cross-package-test-inputs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1458,8 +1458,11 @@ export const TOP_LEVEL_DIRECTORIES_AWAITING_IMPORT = Object.freeze({});
* Repo-root FILES declared as `$TURBO_ROOT$` inputs -- the fourth shape, pinned, not collected.
* `vitest.preset.mts` is the shared vitest preset the package configs re-export by
* relative import; it is the `test` task's input so an edit to it reruns their suites.
* `tsconfig.base.json` is `@objectstack/cli#test`'s: the in-repo scaffold test copies
* it into its sandbox. It is a turbo `globalDependencies` entry too, which this gate
* does not read, so the task declares it as well.
*/
export const TURBO_ROOT_FILE_INPUTS = Object.freeze(['sdui.manifest.json', 'vitest.preset.mts']);
export const TURBO_ROOT_FILE_INPUTS = Object.freeze(['sdui.manifest.json', 'tsconfig.base.json', 'vitest.preset.mts']);

/** `.` is the only regex metacharacter these names carry today; escape the class anyway. */
const TOP_LEVEL_ALTERNATION = RECOGNISED_TOP_LEVEL_DIRECTORIES
Expand Down
7 changes: 7 additions & 0 deletions scripts/cross-package-test-inputs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,13 @@ export const CROSS_PACKAGE_TEST_INPUTS = {
// their trees: the pin reads nothing else from either package.
'packages/spec/src/data/hook.zod.ts',
'packages/runtime/src/sandbox/body-runner.ts',
// The repo-root base every package extends. test/create-in-repo-scaffold-typechecks.test.ts
// copies it into a sandbox laid out like this repo and type-checks an
// `os create plugin --in-repo` scaffold under it, so a strictness or
// `ignoreDeprecations` change in the base is exactly what must re-run
// this suite. It is a turbo `globalDependencies` entry as well, which
// this roster does not read.
'tsconfig.base.json',
],
heldBy: {
// The parity pin joins each barrel path from `SCAFFOLD_WIRED_BARRELS`, a
Expand Down
1 change: 1 addition & 0 deletions turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@
"!**/node_modules/**",
"$TURBO_ROOT$/scripts/nightly-tiers.mjs",
"$TURBO_ROOT$/scripts/nightly-tiers.d.mts",
"$TURBO_ROOT$/tsconfig.base.json",
"$TURBO_ROOT$/packages/verify/src/**",
"$TURBO_ROOT$/packages/plugins/plugin-security/src/**",
"$TURBO_ROOT$/packages/services/service-cluster/src/**",
Expand Down
Loading