create-cordisx-plugin 0.1.0-beta.1 → 0.1.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # create-cordisx-plugin
2
2
 
3
- Create a minimal trusted-local CordisX plugin project.
3
+ Create trusted-local CordisX plugins in the project shape that fits their use.
4
4
 
5
5
  ```bash
6
6
  npm create cordisx-plugin@beta my-plugin
@@ -11,20 +11,76 @@ npx create-cordisx-plugin@beta my-plugin
11
11
  The `@beta` qualifier is required while npm `latest` remains the non-functional
12
12
  `0.0.0` package-name reservation.
13
13
 
14
- The generated project includes a version-1 manifest, a structured toolbar
15
- route, a React page body, TypeScript build, manifest test, and
16
- `cordisx dev --dry-run` script. The plugin installs only `cordisx`: React,
17
- React DOM, their types, JSX runtimes, and the initial CordisX component set are
18
- provided by the Host through `cordisx/react` and `cordisx/ui`. Plugin artifacts
19
- that bundle a private React copy are rejected.
14
+ The positional command remains the short path for one standalone plugin. Two
15
+ explicit modes cover larger project shapes:
16
+
17
+ ```bash
18
+ # A dedicated repository containing several independent plugin packages.
19
+ npx create-cordisx-plugin@beta --mode workspace my-suite \
20
+ --plugin chatroom --plugin calendar
21
+
22
+ # Add a CordisX development package to an existing business project.
23
+ npx create-cordisx-plugin@beta --mode embedded ./my-business-project \
24
+ --plugin incident-room
25
+ ```
26
+
27
+ `workspace` creates one `cordisx.config.json`, one development server command,
28
+ and independently addressable packages under `plugins/<id>`. Use
29
+ `--package-manager pnpm` to also create `pnpm-workspace.yaml`; npm, Yarn, and Bun
30
+ can use the generated `package.json#workspaces` declaration.
31
+
32
+ `embedded` creates `.cordisx/config.json`, `.cordisx/package.json`, an
33
+ independent `.cordisx/tsconfig.json`, and `.cordisx/plugins/<id>`. Existing
34
+ business files are not replaced. Repeating the command with a new plugin id
35
+ appends that plugin while preserving the existing CordisX package and config
36
+ fields. The config entries are relative to `.cordisx/config.json`.
37
+
38
+ Embedded mode defaults to `--integration auto`: it joins a detected pnpm,
39
+ npm, Yarn, or Bun workspace, while retaining `.cordisx` as its own package and
40
+ TypeScript boundary. pnpm workspace integration updates the existing
41
+ `pnpm-workspace.yaml`; the other supported workspace shapes update the root
42
+ `package.json#workspaces`. Use `--integration isolated` to keep installation
43
+ inside `.cordisx`, or `--integration workspace` to require an existing
44
+ supported workspace. `--package-manager` makes non-interactive automation
45
+ deterministic. Existing pnpm comments, quoted keys, and block or flow sequence
46
+ styles are preserved. If the workspace YAML cannot be parsed or its `packages`
47
+ value cannot be updated without changing its meaning, creation stops and rolls
48
+ back without changing the business project.
49
+
50
+ Every generated plugin includes a version-1 manifest, a structured toolbar
51
+ route, and a component-only React page module. Its standalone, workspace, or
52
+ embedded environment supplies typecheck, production Vite build, manifest test,
53
+ and `cordisx dev --dry-run` commands. Production output uses a stable
54
+ `dist/runtime/module.js` entry plus content-addressed `chunks/`, `assets/`, and formal
55
+ `artifact.json`. Declarations remain outside that closed graph in `dist/types/`.
56
+ CSS and static assets remain external files, and dynamic
57
+ imports load their graph only when reached. Workspaces and embedded projects
58
+ build each plugin separately so independently replaceable generations never
59
+ share output chunks.
60
+
61
+ Every generated production config calls the public
62
+ `cordisx/vite` `cordisXPluginViteConfig()` helper. That reusable author/Host
63
+ pipeline owns the deterministic output rules, virtualizes only the closed Host
64
+ singleton imports, and emits the formal `dist/runtime/artifact.json`. A portable
65
+ CordisX package manifest points its browser entry at the adjacent prebuilt
66
+ `dist/runtime/module.js`; the Host validates and retains that complete indexed graph.
67
+
68
+ Development remains one CordisX-owned Vite server and HMR graph using the
69
+ original source entries. The environment installs no private React runtime:
70
+ React, React DOM, their types, JSX runtimes, and the initial CordisX component
71
+ set are provided by the Host through `cordisx/react` and `cordisx/ui`. Plugin
72
+ artifacts that bundle a private React copy are rejected.
20
73
 
21
74
  The generated route and page use closed route-v2/page-v3 documents with real
22
75
  localized title and description dictionaries. Canonical ids, path, outlet,
23
76
  params, and chrome remain untranslated machine fields. CordisX owns route and
24
77
  page chrome, the React root, theme, error boundary, and lifecycle cleanup; the
25
- plugin owns only the controlled body component. The generator does not install
26
- to a marketplace, sign the plugin, provide a permission sandbox, or promise
27
- hot reload.
78
+ plugin owns only the controlled body component. Local component modules are
79
+ laid out as Vite React Fast Refresh boundaries; plugin entry, manifest, and
80
+ `apply()` changes use Cordis lifecycle replacement. Installed production
81
+ packages update through normal package lifecycle rather than development HMR.
82
+ The generator does not install to a marketplace, sign the plugin, or provide a
83
+ permission sandbox.
28
84
 
29
85
  The `create-cordisx-plugin` tool itself is licensed under
30
86
  `AGPL-3.0-or-later`. Files under its marked `template` directory and projects
@@ -33,3 +89,12 @@ An independent plugin using only public, versioned CordisX plugin interfaces
33
89
  may be commercial, sold, distributed through a marketplace, and licensed under
34
90
  terms chosen by its author. The custom Exception is not a standard SPDX
35
91
  exception and should receive legal review before stable.
92
+
93
+ ## CSS authoring
94
+
95
+ All three modes generate `src/vite-env.d.ts` in each plugin source tree.
96
+ Use side-effect CSS imports in the owning component and keep the complete
97
+ Vite output graph. Explicit `?inline` imports produce text; CSS Modules produce
98
+ class maps only with a compatible Host development transform. Consult the
99
+ [maintained CSS guidance](https://github.com/cordisx/cordisx/blob/main/skills/cordisx-plugin-development/references/css-and-lifecycle.md)
100
+ for ownership, current limitations, and separate HMR/unload verification.
package/dist/cli.js CHANGED
@@ -1,24 +1,125 @@
1
1
  #!/usr/bin/env node
2
- import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
2
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
5
6
  const HELP = `Usage:
6
7
  npm create cordisx-plugin <directory>
7
8
  npx create-cordisx-plugin <directory>
9
+ create-cordisx-plugin --mode workspace <directory> --plugin <id> [--plugin <id> ...]
10
+ create-cordisx-plugin --mode embedded <project> --plugin <id> [options]
8
11
 
9
- Creates a minimal trusted-local CordisX plugin project.`;
12
+ Modes:
13
+ single Create one standalone plugin project (default)
14
+ workspace Create a dedicated multi-plugin workspace
15
+ embedded Add one or more plugins under <project>/.cordisx
16
+
17
+ Options:
18
+ --plugin <id> Plugin id; repeat for multiple plugins
19
+ --integration <mode> embedded mode: auto, workspace, or isolated
20
+ --package-manager <manager> auto, npm, pnpm, yarn, or bun
21
+ -h, --help Show this help
22
+
23
+ Embedded mode always creates an independent .cordisx package.json and
24
+ tsconfig.json. With --integration auto it joins an existing workspace when one
25
+ is detected; otherwise it remains an isolated Node project.`;
10
26
  function packageName(directory) {
11
27
  const normalized = path.basename(path.resolve(directory))
12
28
  .normalize('NFKD')
13
29
  .toLowerCase()
14
30
  .replaceAll(/[^a-z0-9._-]+/g, '-')
15
31
  .replace(/^[._-]+|[._-]+$/g, '')
16
- .slice(0, 214);
32
+ .slice(0, 180);
17
33
  return normalized === '' ? 'cordisx-plugin' : normalized;
18
34
  }
19
- function pluginId(name) {
35
+ function derivedPluginId(name) {
20
36
  return name === 'host' || name.startsWith('cordisx.') ? `local-${name}` : name;
21
37
  }
38
+ function explicitPluginId(value) {
39
+ const id = value.trim();
40
+ if (!/^[a-z0-9][a-z0-9._-]{0,95}$/.test(id))
41
+ throw new Error(`invalid plugin id: ${value}`);
42
+ if (id === 'host' || id.startsWith('cordisx.'))
43
+ throw new Error(`reserved plugin id: ${id}`);
44
+ return id;
45
+ }
46
+ function optionValue(argv, index, name) {
47
+ const current = argv[index];
48
+ const inline = current?.startsWith(`${name}=`) === true ? current.slice(name.length + 1) : undefined;
49
+ if (inline !== undefined) {
50
+ if (inline === '')
51
+ throw new Error(`${name} requires a value`);
52
+ return [inline, index];
53
+ }
54
+ const next = argv[index + 1];
55
+ if (next === undefined || next.startsWith('-'))
56
+ throw new Error(`${name} requires a value`);
57
+ return [next, index + 1];
58
+ }
59
+ function choice(value, values, name) {
60
+ if (!values.includes(value))
61
+ throw new Error(`${name} must be one of: ${values.join(', ')}`);
62
+ return value;
63
+ }
64
+ function parseArguments(argv) {
65
+ if (argv.includes('--help') || argv.includes('-h'))
66
+ return 'help';
67
+ let mode = 'single';
68
+ let integration = 'auto';
69
+ let packageManager;
70
+ const plugins = [];
71
+ const positionals = [];
72
+ for (let index = 0; index < argv.length; index += 1) {
73
+ const argument = argv[index];
74
+ if (argument === '--mode' || argument.startsWith('--mode=')) {
75
+ const [value, nextIndex] = optionValue(argv, index, '--mode');
76
+ mode = choice(value, ['single', 'workspace', 'embedded'], '--mode');
77
+ index = nextIndex;
78
+ continue;
79
+ }
80
+ if (argument === '--plugin' || argument.startsWith('--plugin=')) {
81
+ const [value, nextIndex] = optionValue(argv, index, '--plugin');
82
+ plugins.push(explicitPluginId(value));
83
+ index = nextIndex;
84
+ continue;
85
+ }
86
+ if (argument === '--integration' || argument.startsWith('--integration=')) {
87
+ const [value, nextIndex] = optionValue(argv, index, '--integration');
88
+ integration = choice(value, ['auto', 'workspace', 'isolated'], '--integration');
89
+ index = nextIndex;
90
+ continue;
91
+ }
92
+ if (argument === '--package-manager' || argument.startsWith('--package-manager=')) {
93
+ const [value, nextIndex] = optionValue(argv, index, '--package-manager');
94
+ if (value !== 'auto') {
95
+ packageManager = choice(value, ['npm', 'pnpm', 'yarn', 'bun'], '--package-manager');
96
+ }
97
+ index = nextIndex;
98
+ continue;
99
+ }
100
+ if (argument.startsWith('-'))
101
+ throw new Error(`unknown option: ${argument}\n\n${HELP}`);
102
+ positionals.push(argument);
103
+ }
104
+ if (positionals.length !== 1)
105
+ throw new Error(HELP);
106
+ if (mode === 'single' && plugins.length > 0) {
107
+ throw new Error('--plugin is only valid with --mode workspace or --mode embedded');
108
+ }
109
+ if (mode !== 'embedded' && integration !== 'auto')
110
+ throw new Error('--integration is only valid with --mode embedded');
111
+ if (mode !== 'single' && plugins.length === 0)
112
+ throw new Error(`${mode} mode requires at least one --plugin <id>`);
113
+ if (new Set(plugins).size !== plugins.length)
114
+ throw new Error('plugin ids must be unique');
115
+ return {
116
+ directory: positionals[0],
117
+ mode,
118
+ plugins,
119
+ integration,
120
+ ...(packageManager === undefined ? {} : { packageManager }),
121
+ };
122
+ }
22
123
  function replaceTokens(value, replacements) {
23
124
  let output = value;
24
125
  for (const [token, replacement] of Object.entries(replacements)) {
@@ -26,15 +127,12 @@ function replaceTokens(value, replacements) {
26
127
  }
27
128
  return output;
28
129
  }
29
- async function targetState(target) {
130
+ async function pathState(target) {
30
131
  try {
31
132
  const targetStat = await stat(target);
32
133
  if (!targetStat.isDirectory())
33
- throw new Error(`target exists and is not a directory: ${target}`);
34
- const entries = await readdir(target);
35
- if (entries.length > 0)
36
- throw new Error(`target directory is not empty: ${target}`);
37
- return 'empty';
134
+ return 'file';
135
+ return (await readdir(target)).length === 0 ? 'empty' : 'nonempty';
38
136
  }
39
137
  catch (error) {
40
138
  if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
@@ -42,6 +140,16 @@ async function targetState(target) {
42
140
  throw error;
43
141
  }
44
142
  }
143
+ async function readOptional(file) {
144
+ try {
145
+ return await readFile(file, 'utf8');
146
+ }
147
+ catch (error) {
148
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
149
+ return undefined;
150
+ throw error;
151
+ }
152
+ }
45
153
  async function renderTemplate(source, destination, replacements) {
46
154
  await mkdir(destination, { recursive: true });
47
155
  for (const entry of await readdir(source, { withFileTypes: true })) {
@@ -54,28 +162,145 @@ async function renderTemplate(source, destination, replacements) {
54
162
  }
55
163
  if (!entry.isFile())
56
164
  throw new Error(`unsupported template entry: ${input}`);
57
- const contents = replaceTokens(await readFile(input, 'utf8'), replacements);
58
- await writeFile(output, contents, { encoding: 'utf8', flag: 'wx' });
165
+ await writeFile(output, replaceTokens(await readFile(input, 'utf8'), replacements), {
166
+ encoding: 'utf8',
167
+ flag: 'wx',
168
+ });
59
169
  }
60
170
  }
61
- async function main(argv) {
62
- if (argv.includes('--help') || argv.includes('-h')) {
63
- console.log(HELP);
64
- return;
65
- }
66
- if (argv.length !== 1 || argv[0]?.startsWith('-'))
67
- throw new Error(HELP);
68
- const directory = argv[0];
69
- if (directory === undefined)
70
- throw new Error(HELP);
71
- const target = path.resolve(process.cwd(), directory);
72
- const state = await targetState(target);
73
- const packageRoot = fileURLToPath(new URL('..', import.meta.url));
74
- const templateRoot = path.join(packageRoot, 'template');
75
- const creatorManifest = JSON.parse(await readFile(path.join(packageRoot, 'package.json'), 'utf8'));
76
- if (typeof creatorManifest.version !== 'string' || creatorManifest.version.length === 0) {
77
- throw new Error('create-cordisx-plugin package version is invalid');
78
- }
171
+ function json(value) {
172
+ return `${JSON.stringify(value, null, 2)}\n`;
173
+ }
174
+ function commonCompilerOptions() {
175
+ return {
176
+ target: 'ES2022',
177
+ module: 'NodeNext',
178
+ moduleResolution: 'NodeNext',
179
+ jsx: 'react-jsx',
180
+ jsxImportSource: 'cordisx/react',
181
+ lib: ['ES2023', 'DOM'],
182
+ strict: true,
183
+ declaration: true,
184
+ forceConsistentCasingInFileNames: true,
185
+ skipLibCheck: true,
186
+ };
187
+ }
188
+ function pluginConfig(ids) {
189
+ return json({
190
+ version: 1,
191
+ plugins: ids.map(id => ({ id, entry: `./plugins/${id}/src/index.tsx`, enabled: true, config: {} })),
192
+ });
193
+ }
194
+ function manifestTest(ids, modulePath) {
195
+ const imports = ids.map((id, index) => `const plugin${index} = await import('${modulePath(id)}')`).join('\n');
196
+ const cases = ids.map((id, index) => ` ['${id}', plugin${index}],`).join('\n');
197
+ return `import assert from 'node:assert/strict'
198
+ import test from 'node:test'
199
+
200
+ const component = () => null
201
+ const noop = () => undefined
202
+ const React = new Proxy({ Fragment: Symbol('Fragment'), Suspense: component, lazy: () => component }, {
203
+ get(target, property) { return Reflect.get(target, property) ?? noop },
204
+ })
205
+ globalThis.__cordisxSharedReactRuntime = {
206
+ React,
207
+ defineReactPage: page => page,
208
+ jsxRuntime: { Fragment: React.Fragment, jsx: component, jsxs: component },
209
+ jsxDevRuntime: { Fragment: React.Fragment, jsxDEV: component },
210
+ ui: new Proxy({}, { get: () => component }),
211
+ }
212
+
213
+ ${imports}
214
+
215
+ const plugins = [
216
+ ${cases}
217
+ ]
218
+
219
+ test('all configured CordisX plugins export matching manifests', () => {
220
+ for (const [id, plugin] of plugins) {
221
+ assert.equal(plugin.manifest.schemaVersion, 1)
222
+ assert.equal(plugin.manifest.id, id)
223
+ assert.equal(typeof plugin.apply, 'function')
224
+ }
225
+ })
226
+ `;
227
+ }
228
+ function projectBuildScript(configName) {
229
+ return `import { readFile } from 'node:fs/promises'
230
+ import path from 'node:path'
231
+ import { fileURLToPath } from 'node:url'
232
+ import { build } from 'vite'
233
+
234
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
235
+ const config = JSON.parse(await readFile(path.join(root, '${configName}'), 'utf8'))
236
+ if (config.version !== 1 || !Array.isArray(config.plugins)) throw new Error('CordisX project config must have version 1 and a plugins array')
237
+ const ids = new Set()
238
+ for (const plugin of config.plugins) {
239
+ if (plugin === null || typeof plugin !== 'object' || typeof plugin.id !== 'string'
240
+ || !/^[a-z0-9][a-z0-9._-]{0,95}$/.test(plugin.id) || ids.has(plugin.id)) {
241
+ throw new Error('CordisX project config contains an invalid or duplicated plugin id')
242
+ }
243
+ ids.add(plugin.id)
244
+ await build({ configFile: path.join(root, 'plugins', plugin.id, 'vite.config.ts') })
245
+ }
246
+ `;
247
+ }
248
+ async function renderPluginSource(templateRoot, destination, id, outDir = 'dist/runtime') {
249
+ const replacements = { packageName: id, pluginId: id, sourceEntry: 'src/index.tsx', outDir };
250
+ const source = replaceTokens(await readFile(path.join(templateRoot, 'src', '{{packageName}}.tsx'), 'utf8'), replacements);
251
+ const page = replaceTokens(await readFile(path.join(templateRoot, 'src', 'overview-page.tsx'), 'utf8'), replacements);
252
+ const viteConfig = replaceTokens(await readFile(path.join(templateRoot, 'vite.config.ts'), 'utf8'), replacements);
253
+ await mkdir(path.join(destination, 'src'), { recursive: true });
254
+ await writeFile(path.join(destination, 'src', 'index.tsx'), source, { encoding: 'utf8', flag: 'wx' });
255
+ await writeFile(path.join(destination, 'src', 'overview-page.tsx'), page, { encoding: 'utf8', flag: 'wx' });
256
+ await writeFile(path.join(destination, 'src', 'vite-env.d.ts'), await readFile(path.join(templateRoot, 'src', 'vite-env.d.ts'), 'utf8'), { encoding: 'utf8', flag: 'wx' });
257
+ await writeFile(path.join(destination, 'vite.config.ts'), viteConfig, { encoding: 'utf8', flag: 'wx' });
258
+ await writeFile(path.join(destination, 'README.md'), `# ${id}\n\nThe local-development lifecycle entry is \`src/index.tsx\`. React page components live in component-only modules such as \`src/overview-page.tsx\` so Vite can apply React Fast Refresh. Production config calls the public \`cordisx/vite\` helper to write one immutable Vite ESM graph with \`module.js\`, content-addressed chunks, CSS, assets, and formal \`artifact.json\` under \`${outDir}/\` relative to this directory. Declarations are emitted separately according to the owning tsconfig. A portable package entry points at the adjacent \`${outDir}/module.js\`; the Host validates and retains the indexed graph.\n\nKeep \`src/vite-env.d.ts\` for Vite CSS types. Ordinary CSS is a side-effect import in its owning component; explicit \`?inline\` yields text. CSS HMR does not prove plugin-unload cleanup. See [CSS guidance](https://github.com/cordisx/cordisx/blob/main/skills/cordisx-plugin-development/references/css-and-lifecycle.md) before changing import forms.\n\nThis plugin starts private and \`UNLICENSED\`. Its generated source is Marked Template Material under the CordisX Independent Plugin Exception; choose a license before distribution.\n`, { encoding: 'utf8', flag: 'wx' });
259
+ await writeFile(path.join(destination, 'README.zh-Hans.md'), `# ${id}\n\n本地开发的生命周期入口为 \`src/index.tsx\`。React 页面组件位于 \`src/overview-page.tsx\` 等纯组件模块中,因此 Vite 可以应用 React Fast Refresh。生产配置调用公开的 \`cordisx/vite\` helper,在相对于此目录的 \`${outDir}/\` 下生成独立、不可变的 Vite ESM graph,包括 \`module.js\`、带内容摘要的 chunk、CSS、静态资源和正式的 \`artifact.json\`;类型声明按所属 tsconfig 单独输出。可移植 package 的入口指向相邻的 \`${outDir}/module.js\`;Host 会验证并保留该索引 graph。\n\n保留 \`src/vite-env.d.ts\` 提供的 Vite CSS 类型。普通 CSS 使用副作用导入;显式 \`?inline\` 返回文本。CSS HMR 不等于插件卸载回收。更改导入前阅读 [CSS 指导](https://github.com/cordisx/cordisx/blob/main/skills/cordisx-plugin-development/references/css-and-lifecycle.md)。\n\n此插件默认私有且使用 \`UNLICENSED\`。生成源码属于 CordisX 独立插件例外中的已标记模板材料;分发前请选择许可证。\n`, { encoding: 'utf8', flag: 'wx' });
260
+ }
261
+ function pluginPackage(workspaceName, id, cordisxVersion) {
262
+ return json({
263
+ name: `${workspaceName}-${id}`.slice(0, 214),
264
+ version: '0.1.0',
265
+ private: true,
266
+ license: 'UNLICENSED',
267
+ type: 'module',
268
+ files: ['dist', 'README.md', 'README.zh-Hans.md'],
269
+ main: './dist/runtime/module.js',
270
+ types: './dist/types/index.d.ts',
271
+ exports: {
272
+ '.': {
273
+ types: './dist/types/index.d.ts',
274
+ import: './dist/runtime/module.js',
275
+ default: './dist/runtime/module.js',
276
+ },
277
+ },
278
+ scripts: {
279
+ build: 'vite build && tsc -p tsconfig.json --emitDeclarationOnly',
280
+ typecheck: 'tsc -p tsconfig.json --noEmit',
281
+ },
282
+ devDependencies: {
283
+ '@deepseek-ai/cordis': '4.0.1',
284
+ cordisx: cordisxVersion,
285
+ typescript: '^5.9.2',
286
+ vite: '8.2.2',
287
+ },
288
+ });
289
+ }
290
+ function pluginTsconfig() {
291
+ return json({
292
+ extends: '../../tsconfig.base.json',
293
+ compilerOptions: { rootDir: 'src', outDir: 'dist/types', composite: true },
294
+ include: ['src/**/*.ts', 'src/**/*.tsx'],
295
+ exclude: ['dist', 'node_modules'],
296
+ });
297
+ }
298
+ async function createSingle(target, directory, manager, templateRoot, cordisxVersion) {
299
+ const state = await pathState(target);
300
+ if (state === 'file')
301
+ throw new Error(`target exists and is not a directory: ${target}`);
302
+ if (state === 'nonempty')
303
+ throw new Error(`target directory is not empty: ${target}`);
79
304
  let created = false;
80
305
  try {
81
306
  if (state === 'missing') {
@@ -83,19 +308,426 @@ async function main(argv) {
83
308
  created = true;
84
309
  }
85
310
  const name = packageName(directory);
311
+ const id = derivedPluginId(name);
86
312
  await renderTemplate(templateRoot, target, {
87
313
  packageName: name,
88
- pluginId: pluginId(name),
89
- cordisxVersion: creatorManifest.version,
314
+ pluginId: id,
315
+ cordisxVersion,
316
+ packageManager: manager,
317
+ sourceEntry: `src/${id}.tsx`,
318
+ outDir: 'dist/runtime',
90
319
  });
320
+ if (name !== id)
321
+ await rename(path.join(target, 'src', `${name}.tsx`), path.join(target, 'src', `${id}.tsx`));
91
322
  }
92
323
  catch (error) {
93
- if (created)
94
- await rm(target, { recursive: true, force: true });
324
+ await rm(target, { recursive: true, force: true });
325
+ if (!created)
326
+ await mkdir(target);
95
327
  throw error;
96
328
  }
97
- console.log(`Created CordisX plugin in ${target}`);
98
- console.log(`\nNext steps:\n cd ${directory}\n npm install\n npm run check\n npm run dev:dry-run`);
329
+ }
330
+ async function createWorkspace(target, directory, ids, manager, templateRoot, cordisxVersion) {
331
+ const state = await pathState(target);
332
+ if (state === 'file')
333
+ throw new Error(`target exists and is not a directory: ${target}`);
334
+ if (state === 'nonempty')
335
+ throw new Error(`target directory is not empty: ${target}`);
336
+ let created = false;
337
+ try {
338
+ if (state === 'missing') {
339
+ await mkdir(target, { recursive: false });
340
+ created = true;
341
+ }
342
+ const name = packageName(directory);
343
+ await writeFile(path.join(target, 'package.json'), json({
344
+ name,
345
+ version: '0.1.0',
346
+ private: true,
347
+ license: 'UNLICENSED',
348
+ type: 'module',
349
+ workspaces: ['plugins/*'],
350
+ scripts: {
351
+ build: 'node scripts/build-plugins.mjs && tsc -b --emitDeclarationOnly --force',
352
+ check: 'tsc -b && node scripts/build-plugins.mjs && tsc -b --emitDeclarationOnly --force && node --test test/plugins.mjs',
353
+ dev: 'cordisx dev --config ./cordisx.config.json',
354
+ 'dev:dry-run': 'cordisx dev --config ./cordisx.config.json --dry-run',
355
+ test: 'node --test test/plugins.mjs',
356
+ typecheck: 'tsc -b',
357
+ },
358
+ devDependencies: { cordisx: cordisxVersion, typescript: '^5.9.2', vite: '8.2.2' },
359
+ engines: { node: '>=22.19' },
360
+ }), { encoding: 'utf8', flag: 'wx' });
361
+ await writeFile(path.join(target, 'tsconfig.base.json'), json({ compilerOptions: commonCompilerOptions() }), {
362
+ encoding: 'utf8',
363
+ flag: 'wx',
364
+ });
365
+ await writeFile(path.join(target, 'tsconfig.json'), json({
366
+ files: [],
367
+ references: ids.map(id => ({ path: `./plugins/${id}` })),
368
+ }), { encoding: 'utf8', flag: 'wx' });
369
+ await writeFile(path.join(target, 'cordisx.config.json'), pluginConfig(ids), { encoding: 'utf8', flag: 'wx' });
370
+ await mkdir(path.join(target, 'scripts'), { recursive: true });
371
+ await writeFile(path.join(target, 'scripts', 'build-plugins.mjs'), projectBuildScript('cordisx.config.json'), {
372
+ encoding: 'utf8',
373
+ flag: 'wx',
374
+ });
375
+ await writeFile(path.join(target, '.gitignore'), 'node_modules/\ndist/\n*.tsbuildinfo\n', {
376
+ encoding: 'utf8',
377
+ flag: 'wx',
378
+ });
379
+ await writeFile(path.join(target, 'README.md'), `# ${name}\n\nA dedicated CordisX multi-plugin workspace. Each plugin builds into its own Vite ESM graph so chunks and assets never couple independently replaceable plugin generations.\n\n\`\`\`bash\n${manager} install\n${manager} run check\n${manager} run dev:dry-run\n${manager} run dev\n\`\`\`\n`, { encoding: 'utf8', flag: 'wx' });
380
+ if (manager === 'pnpm') {
381
+ await writeFile(path.join(target, 'pnpm-workspace.yaml'), "packages:\n - 'plugins/*'\n", {
382
+ encoding: 'utf8',
383
+ flag: 'wx',
384
+ });
385
+ }
386
+ for (const id of ids) {
387
+ const pluginRoot = path.join(target, 'plugins', id);
388
+ await renderPluginSource(templateRoot, pluginRoot, id);
389
+ await writeFile(path.join(pluginRoot, 'package.json'), pluginPackage(name, id, cordisxVersion), {
390
+ encoding: 'utf8',
391
+ flag: 'wx',
392
+ });
393
+ await writeFile(path.join(pluginRoot, 'tsconfig.json'), pluginTsconfig(), { encoding: 'utf8', flag: 'wx' });
394
+ }
395
+ await mkdir(path.join(target, 'test'), { recursive: true });
396
+ await writeFile(path.join(target, 'test', 'plugins.mjs'), manifestTest(ids, id => `../plugins/${id}/dist/runtime/module.js`), { encoding: 'utf8', flag: 'wx' });
397
+ }
398
+ catch (error) {
399
+ await rm(target, { recursive: true, force: true });
400
+ if (!created)
401
+ await mkdir(target);
402
+ throw error;
403
+ }
404
+ }
405
+ function packageManagerFromManifest(contents) {
406
+ if (contents === undefined)
407
+ return undefined;
408
+ const manifest = JSON.parse(contents);
409
+ if (typeof manifest.packageManager !== 'string')
410
+ return undefined;
411
+ const name = manifest.packageManager.split('@')[0];
412
+ return name === 'npm' || name === 'pnpm' || name === 'yarn' || name === 'bun' ? name : undefined;
413
+ }
414
+ async function detectedPackageManager(projectRoot, manifest) {
415
+ const declared = packageManagerFromManifest(manifest);
416
+ if (declared !== undefined)
417
+ return declared;
418
+ for (const [file, manager] of [
419
+ ['pnpm-lock.yaml', 'pnpm'],
420
+ ['yarn.lock', 'yarn'],
421
+ ['bun.lock', 'bun'],
422
+ ['bun.lockb', 'bun'],
423
+ ['package-lock.json', 'npm'],
424
+ ]) {
425
+ if (await readOptional(path.join(projectRoot, file)) !== undefined)
426
+ return manager;
427
+ }
428
+ return 'npm';
429
+ }
430
+ async function detectWorkspace(projectRoot) {
431
+ for (const file of ['pnpm-workspace.yaml', 'pnpm-workspace.yml']) {
432
+ const manifestPath = path.join(projectRoot, file);
433
+ if (await readOptional(manifestPath) !== undefined)
434
+ return { manager: 'pnpm', kind: 'pnpm-yaml', manifestPath };
435
+ }
436
+ const manifestPath = path.join(projectRoot, 'package.json');
437
+ const contents = await readOptional(manifestPath);
438
+ if (contents === undefined)
439
+ return undefined;
440
+ const manifest = JSON.parse(contents);
441
+ const workspaces = Array.isArray(manifest.workspaces)
442
+ ? manifest.workspaces
443
+ : manifest.workspaces !== null && typeof manifest.workspaces === 'object'
444
+ ? manifest.workspaces.packages
445
+ : undefined;
446
+ if (!Array.isArray(workspaces))
447
+ return undefined;
448
+ const manager = await detectedPackageManager(projectRoot, contents);
449
+ // pnpm intentionally ignores package.json#workspaces; only its YAML file is
450
+ // evidence that the project is a pnpm workspace.
451
+ if (manager === 'pnpm')
452
+ return undefined;
453
+ return { manager, kind: 'package-json', manifestPath };
454
+ }
455
+ function addPnpmWorkspace(contents, entry) {
456
+ const normalized = entry.replaceAll('\\', '/');
457
+ const verifyUpdate = (candidate) => {
458
+ const updated = parseDocument(candidate, { prettyErrors: true });
459
+ if (updated.errors.length > 0) {
460
+ throw new Error('cannot safely update pnpm-workspace.yaml: the updated document did not validate');
461
+ }
462
+ const updatedPackages = updated.get('packages', true);
463
+ if (!isSeq(updatedPackages) || !updatedPackages.items.some(item => isScalar(item) && item.value === normalized)) {
464
+ throw new Error('cannot safely update pnpm-workspace.yaml: the updated document did not validate');
465
+ }
466
+ return candidate;
467
+ };
468
+ const document = parseDocument(contents, { keepSourceTokens: true, prettyErrors: true });
469
+ if (document.errors.length > 0) {
470
+ throw new Error(`cannot safely update pnpm-workspace.yaml: ${document.errors[0].message}`);
471
+ }
472
+ if (!isMap(document.contents)) {
473
+ throw new Error('cannot safely update pnpm-workspace.yaml: the document root must be a mapping');
474
+ }
475
+ const packages = document.get('packages', true);
476
+ if (packages === undefined && !document.contents.has('packages')) {
477
+ const range = document.contents.range;
478
+ if (range === null || range === undefined) {
479
+ throw new Error('cannot safely update pnpm-workspace.yaml: the root mapping has no editable source range');
480
+ }
481
+ const newline = contents.includes('\r\n') ? '\r\n' : '\n';
482
+ const insertion = `${contents.slice(0, range[1]).endsWith('\n') ? '' : newline}packages:${newline} - '${normalized.replaceAll("'", "''")}'${newline}`;
483
+ return verifyUpdate(`${contents.slice(0, range[1])}${insertion}${contents.slice(range[1])}`);
484
+ }
485
+ if (!isSeq(packages)) {
486
+ throw new Error('cannot safely update pnpm-workspace.yaml: packages must be a sequence');
487
+ }
488
+ if (packages.anchor !== undefined || packages.tag !== undefined) {
489
+ throw new Error('cannot safely update pnpm-workspace.yaml: anchored or tagged packages sequences are not supported');
490
+ }
491
+ const values = packages.items.map(item => {
492
+ if (!isScalar(item) || typeof item.value !== 'string') {
493
+ throw new Error('cannot safely update pnpm-workspace.yaml: every packages entry must be a string');
494
+ }
495
+ return item.value;
496
+ });
497
+ if (values.includes(normalized))
498
+ return contents;
499
+ const range = packages.range;
500
+ if (range === null || range === undefined) {
501
+ throw new Error('cannot safely update pnpm-workspace.yaml: packages has no editable source range');
502
+ }
503
+ const quoted = `'${normalized.replaceAll("'", "''")}'`;
504
+ const [start, end] = range;
505
+ if (packages.flow === true) {
506
+ const closing = end - 1;
507
+ if (contents[closing] !== ']') {
508
+ throw new Error('cannot safely update pnpm-workspace.yaml: packages flow sequence has no closing bracket');
509
+ }
510
+ const inner = contents.slice(start + 1, closing);
511
+ const trailingWhitespace = inner.match(/\s*$/)?.[0] ?? '';
512
+ const body = inner.slice(0, inner.length - trailingWhitespace.length);
513
+ const separator = body.trim() === '' || body.trimEnd().endsWith(',') ? '' : ',';
514
+ const spacing = body === '' || /\s$/.test(body) ? '' : ' ';
515
+ return verifyUpdate(`${contents.slice(0, start + 1)}${body}${separator}${spacing}${quoted}${trailingWhitespace}${contents.slice(closing)}`);
516
+ }
517
+ const source = contents.slice(start, end);
518
+ const newline = contents.includes('\r\n') ? '\r\n' : '\n';
519
+ const lineStart = contents.lastIndexOf('\n', start - 1) + 1;
520
+ const indent = contents.slice(lineStart, start);
521
+ if (!/^\s*$/.test(indent)) {
522
+ throw new Error('cannot safely update pnpm-workspace.yaml: packages sequence indentation is ambiguous');
523
+ }
524
+ const insertion = source.endsWith('\n') ? `${indent}- ${quoted}${newline}` : `${newline}${indent}- ${quoted}`;
525
+ return verifyUpdate(`${contents.slice(0, end)}${insertion}${contents.slice(end)}`);
526
+ }
527
+ function addPackageWorkspace(contents, entry) {
528
+ const manifest = JSON.parse(contents);
529
+ const current = manifest.workspaces;
530
+ if (Array.isArray(current)) {
531
+ if (!current.includes(entry))
532
+ manifest.workspaces = [...current, entry];
533
+ }
534
+ else if (current !== null && typeof current === 'object' && Array.isArray(current.packages)) {
535
+ const packages = current.packages;
536
+ if (!packages.includes(entry))
537
+ manifest.workspaces = { ...current, packages: [...packages, entry] };
538
+ }
539
+ else {
540
+ throw new Error('project package.json has an unsupported workspaces declaration');
541
+ }
542
+ return json(manifest);
543
+ }
544
+ function embeddedPackage(projectName, cordisxVersion, packageManagerSpec) {
545
+ return json({
546
+ name: `${projectName}-cordisx-development`.slice(0, 214),
547
+ private: true,
548
+ license: 'UNLICENSED',
549
+ type: 'module',
550
+ ...(packageManagerSpec === undefined ? {} : { packageManager: packageManagerSpec }),
551
+ scripts: {
552
+ build: 'node scripts/build-plugins.mjs && tsc -p tsconfig.json --emitDeclarationOnly',
553
+ check: 'tsc -p tsconfig.json --noEmit && node scripts/build-plugins.mjs && tsc -p tsconfig.json --emitDeclarationOnly && node --test test/*.mjs',
554
+ dev: 'cordisx dev --config ./config.json',
555
+ 'dev:dry-run': 'cordisx dev --config ./config.json --dry-run',
556
+ test: 'node --test test/*.mjs',
557
+ typecheck: 'tsc -p tsconfig.json --noEmit',
558
+ },
559
+ devDependencies: {
560
+ '@deepseek-ai/cordis': '4.0.1',
561
+ cordisx: cordisxVersion,
562
+ typescript: '^5.9.2',
563
+ vite: '8.2.2',
564
+ },
565
+ engines: { node: '>=22.19' },
566
+ });
567
+ }
568
+ function embeddedTsconfig() {
569
+ return json({
570
+ compilerOptions: { ...commonCompilerOptions(), rootDir: 'plugins', outDir: 'dist/types' },
571
+ include: ['plugins/**/*.ts', 'plugins/**/*.tsx'],
572
+ exclude: ['dist', 'node_modules'],
573
+ });
574
+ }
575
+ function embeddedReadme(manager, integrated) {
576
+ const install = integrated
577
+ ? `Run \`${manager} install\` from the containing workspace root.`
578
+ : manager === 'pnpm'
579
+ ? 'Run `pnpm install --ignore-workspace` in this directory.'
580
+ : `Run \`${manager} install\` in this directory.`;
581
+ return `# Project CordisX development\n\nThis directory is an independent Node and TypeScript boundary for CordisX plugins embedded in the containing business project. Each configured plugin builds into its own Vite ESM graph under \`dist/runtime/<plugin-id>/\`, while declarations stay in \`dist/types/\`; production chunks and assets do not cross plugin generation boundaries.\n\n${install}\n\nThen run \`${manager} run check\`, \`${manager} run dev:dry-run\`, or \`${manager} run dev\` here.\n`;
582
+ }
583
+ async function existingEmbeddedConfig(file) {
584
+ const contents = await readOptional(file);
585
+ if (contents === undefined)
586
+ return { value: { version: 1, plugins: [] }, ids: [] };
587
+ const value = JSON.parse(contents);
588
+ if (value.version !== 1 || !Array.isArray(value.plugins)) {
589
+ throw new Error(`${file} is not a version-1 CordisX project config`);
590
+ }
591
+ const ids = value.plugins.map((plugin, index) => {
592
+ if (plugin === null || typeof plugin !== 'object' || typeof plugin.id !== 'string') {
593
+ throw new Error(`${file} plugins[${index}] is invalid`);
594
+ }
595
+ return plugin.id;
596
+ });
597
+ return { value, ids };
598
+ }
599
+ async function createEmbedded(target, ids, requestedIntegration, requestedManager, templateRoot, cordisxVersion) {
600
+ const state = await pathState(target);
601
+ if (state === 'missing')
602
+ throw new Error(`embedded project does not exist: ${target}`);
603
+ if (state === 'file')
604
+ throw new Error(`embedded target is not a directory: ${target}`);
605
+ const projectManifest = await readOptional(path.join(target, 'package.json'));
606
+ const workspace = await detectWorkspace(target);
607
+ const integrated = requestedIntegration === 'workspace'
608
+ || (requestedIntegration === 'auto' && workspace !== undefined);
609
+ if (integrated && workspace === undefined) {
610
+ throw new Error('workspace integration requested, but the project has no supported workspace declaration');
611
+ }
612
+ if (integrated && requestedManager !== undefined && requestedManager !== workspace.manager) {
613
+ throw new Error(`detected ${workspace.manager} workspace does not match --package-manager ${requestedManager}`);
614
+ }
615
+ const manager = requestedManager ?? workspace?.manager ?? await detectedPackageManager(target, projectManifest);
616
+ const declaredManagerSpec = projectManifest === undefined
617
+ ? undefined
618
+ : JSON.parse(projectManifest).packageManager;
619
+ const isolatedManagerSpec = !integrated
620
+ && typeof declaredManagerSpec === 'string'
621
+ && declaredManagerSpec.startsWith(`${manager}@`)
622
+ ? declaredManagerSpec
623
+ : undefined;
624
+ const cordisxRoot = path.join(target, '.cordisx');
625
+ const cordisxRootWasMissing = await pathState(cordisxRoot) === 'missing';
626
+ const configPath = path.join(cordisxRoot, 'config.json');
627
+ const existing = await existingEmbeddedConfig(configPath);
628
+ for (const id of ids) {
629
+ if (existing.ids.includes(id))
630
+ throw new Error(`plugin already exists in .cordisx/config.json: ${id}`);
631
+ if (await pathState(path.join(cordisxRoot, 'plugins', id)) !== 'missing') {
632
+ throw new Error(`plugin directory already exists: .cordisx/plugins/${id}`);
633
+ }
634
+ if (await readOptional(path.join(cordisxRoot, 'test', `${id}.mjs`)) !== undefined) {
635
+ throw new Error(`plugin test already exists: .cordisx/test/${id}.mjs`);
636
+ }
637
+ }
638
+ const previousFiles = new Map();
639
+ const pluginRoots = ids.map(id => path.join(cordisxRoot, 'plugins', id));
640
+ const remember = async (file) => {
641
+ if (!previousFiles.has(file))
642
+ previousFiles.set(file, await readOptional(file));
643
+ };
644
+ const put = async (file, contents, onlyWhenMissing = false) => {
645
+ await remember(file);
646
+ if (onlyWhenMissing && previousFiles.get(file) !== undefined)
647
+ return;
648
+ await mkdir(path.dirname(file), { recursive: true });
649
+ await writeFile(file, contents, 'utf8');
650
+ };
651
+ try {
652
+ const nextPlugins = [
653
+ ...existing.value.plugins,
654
+ ...ids.map(id => ({ id, entry: `./plugins/${id}/src/index.tsx`, enabled: true, config: {} })),
655
+ ];
656
+ await put(configPath, json({ ...existing.value, plugins: nextPlugins }));
657
+ await put(path.join(cordisxRoot, 'package.json'), embeddedPackage(packageName(target), cordisxVersion, isolatedManagerSpec), true);
658
+ await put(path.join(cordisxRoot, 'tsconfig.json'), embeddedTsconfig(), true);
659
+ await put(path.join(cordisxRoot, 'scripts', 'build-plugins.mjs'), projectBuildScript('config.json'), true);
660
+ await put(path.join(cordisxRoot, '.gitignore'), 'node_modules/\ndist/\ncache/\n*.tsbuildinfo\n', true);
661
+ await put(path.join(cordisxRoot, 'README.md'), embeddedReadme(manager, integrated), true);
662
+ if (!integrated && manager === 'yarn')
663
+ await put(path.join(cordisxRoot, 'yarn.lock'), '', true);
664
+ for (const id of ids) {
665
+ await renderPluginSource(templateRoot, path.join(cordisxRoot, 'plugins', id), id, `../../dist/runtime/${id}`);
666
+ }
667
+ for (const id of ids) {
668
+ await put(path.join(cordisxRoot, 'test', `${id}.mjs`), manifestTest([id], pluginId => `../dist/runtime/${pluginId}/module.js`));
669
+ }
670
+ if (integrated) {
671
+ const workspaceEntry = path.relative(target, cordisxRoot).replaceAll('\\', '/') || '.cordisx';
672
+ await remember(workspace.manifestPath);
673
+ const contents = previousFiles.get(workspace.manifestPath);
674
+ await writeFile(workspace.manifestPath, workspace.kind === 'pnpm-yaml'
675
+ ? addPnpmWorkspace(contents, workspaceEntry)
676
+ : addPackageWorkspace(contents, workspaceEntry), 'utf8');
677
+ }
678
+ }
679
+ catch (error) {
680
+ for (const pluginRoot of pluginRoots)
681
+ await rm(pluginRoot, { recursive: true, force: true });
682
+ for (const [file, previous] of [...previousFiles].reverse()) {
683
+ if (previous === undefined)
684
+ await rm(file, { force: true });
685
+ else
686
+ await writeFile(file, previous, 'utf8');
687
+ }
688
+ if (cordisxRootWasMissing)
689
+ await rm(cordisxRoot, { recursive: true, force: true });
690
+ throw error;
691
+ }
692
+ return { manager, integrated };
693
+ }
694
+ async function main(argv) {
695
+ const parsed = parseArguments(argv);
696
+ if (parsed === 'help') {
697
+ console.log(HELP);
698
+ return;
699
+ }
700
+ const target = path.resolve(process.cwd(), parsed.directory);
701
+ const packageRoot = fileURLToPath(new URL('..', import.meta.url));
702
+ const templateRoot = path.join(packageRoot, 'template');
703
+ const creatorManifest = JSON.parse(await readFile(path.join(packageRoot, 'package.json'), 'utf8'));
704
+ if (typeof creatorManifest.version !== 'string' || creatorManifest.version.length === 0) {
705
+ throw new Error('create-cordisx-plugin package version is invalid');
706
+ }
707
+ if (parsed.mode === 'single') {
708
+ const manager = parsed.packageManager ?? 'npm';
709
+ await createSingle(target, parsed.directory, manager, templateRoot, creatorManifest.version);
710
+ console.log(`Created CordisX plugin in ${target}`);
711
+ console.log(`\nNext steps:\n cd ${parsed.directory}\n ${manager} install\n ${manager} run check\n ${manager} run dev:dry-run`);
712
+ return;
713
+ }
714
+ if (parsed.mode === 'workspace') {
715
+ const manager = parsed.packageManager ?? 'npm';
716
+ await createWorkspace(target, parsed.directory, parsed.plugins, manager, templateRoot, creatorManifest.version);
717
+ console.log(`Created CordisX plugin workspace in ${target}`);
718
+ console.log(`\nNext steps:\n cd ${parsed.directory}\n ${manager} install\n ${manager} run check\n ${manager} run dev:dry-run`);
719
+ return;
720
+ }
721
+ const result = await createEmbedded(target, parsed.plugins, parsed.integration, parsed.packageManager, templateRoot, creatorManifest.version);
722
+ console.log(`Added CordisX plugin${parsed.plugins.length === 1 ? '' : 's'} to ${path.join(target, '.cordisx')}`);
723
+ console.log(`Environment: ${result.integrated ? `${result.manager} workspace` : `isolated ${result.manager}`}`);
724
+ if (result.integrated) {
725
+ console.log(`\nNext steps:\n cd ${parsed.directory}\n ${result.manager} install\n cd .cordisx\n ${result.manager} run check\n ${result.manager} run dev:dry-run`);
726
+ }
727
+ else {
728
+ const install = result.manager === 'pnpm' ? 'pnpm install --ignore-workspace' : `${result.manager} install`;
729
+ console.log(`\nNext steps:\n cd ${path.join(parsed.directory, '.cordisx')}\n ${install}\n ${result.manager} run check\n ${result.manager} run dev:dry-run`);
730
+ }
99
731
  }
100
732
  main(process.argv.slice(2)).catch((error) => {
101
733
  console.error(`[create-cordisx-plugin] ${error instanceof Error ? error.message : String(error)}`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-cordisx-plugin",
3
- "version": "0.1.0-beta.1",
4
- "description": "Create a minimal CordisX plugin project",
3
+ "version": "0.1.0-beta.4",
4
+ "description": "Create standalone, workspace, or embedded CordisX plugin projects",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "dist",
@@ -15,10 +15,11 @@
15
15
  "create-cordisx-plugin": "dist/cli.js"
16
16
  },
17
17
  "scripts": {
18
- "build": "tsc -p tsconfig.json",
19
- "check": "npm run typecheck",
18
+ "build": "tsc -p tsconfig.json && node scripts/normalize-bin-mode.mjs",
19
+ "check": "npm run typecheck && npm run build && npm test",
20
20
  "check:package": "node scripts/check-package-contents.mjs",
21
21
  "prepack": "npm run build",
22
+ "test": "node --test test/*.node-test.mjs",
22
23
  "typecheck": "tsc -p tsconfig.json --noEmit"
23
24
  },
24
25
  "engines": {
@@ -42,11 +43,13 @@
42
43
  ],
43
44
  "publishConfig": {
44
45
  "access": "public",
45
- "tag": "beta",
46
46
  "provenance": true
47
47
  },
48
48
  "devDependencies": {
49
49
  "@types/node": "^22.18.1",
50
50
  "typescript": "^5.9.2"
51
+ },
52
+ "dependencies": {
53
+ "yaml": "2.8.1"
51
54
  }
52
55
  }
@@ -1,17 +1,36 @@
1
1
  # {{packageName}}
2
2
 
3
3
  A minimal trusted-local CordisX React plugin. Its manifest is exported from
4
- `src/{{pluginId}}.tsx`, which is also the runtime entry used by CordisX.
4
+ `src/{{pluginId}}.tsx`, which is also CordisX's local-development entry.
5
+ The page component lives in `src/overview-page.tsx` as a named component-only
6
+ module so local development can preserve its state with React Fast Refresh.
5
7
 
6
8
  ```bash
7
- npm install
8
- npm run check
9
- npm run dev:dry-run
10
- npm run dev
9
+ {{packageManager}} install
10
+ {{packageManager}} run check
11
+ {{packageManager}} run dev:dry-run
12
+ {{packageManager}} run dev
11
13
  ```
12
14
 
13
- `npm run dev:dry-run` bundles the plugin without launching Codex Desktop.
14
- `npm run dev` launches the separate CordisX development host.
15
+ `{{packageManager}} run dev:dry-run` validates the plugin module graph without
16
+ launching Codex Desktop. `{{packageManager}} run dev` launches the separate
17
+ CordisX development host. Component-only modules use React Fast Refresh;
18
+ manifest, entry, and `apply()` changes replace the plugin generation.
19
+
20
+ `{{packageManager}} run build` creates a production Vite ESM graph in `dist/runtime/`.
21
+ Its stable `module.js` entry may load content-addressed JavaScript chunks, CSS,
22
+ and static assets only when an `import()` reaches them. The generated formal
23
+ `artifact.json` records that graph for packaging; declarations are kept outside
24
+ the closed graph in `dist/types/`, and `package.json#files` includes the complete
25
+ `dist/` tree. Production generations remain immutable package
26
+ artifacts and do not use the development HMR connection.
27
+
28
+ The generated config calls the public `cordisx/vite`
29
+ `cordisXPluginViteConfig()` helper. That shared author/Host pipeline owns the
30
+ output rules, virtualizes only the closed Host singleton imports, and emits the
31
+ formal `dist/runtime/artifact.json`. A portable CordisX package manifest points its
32
+ browser entry at the adjacent prebuilt `dist/runtime/module.js`; the Host validates and
33
+ retains the complete indexed graph.
15
34
 
16
35
  The template intentionally requests no platform capabilities. It contributes a
17
36
  structured toolbar route and a React page body. Import React from
@@ -32,3 +51,25 @@ CordisX plugin interfaces, it may be commercial and use a license you choose.
32
51
  Replace `UNLICENSED` in `package.json` with that license before distribution.
33
52
  The Exception does not cover copying or modifying CordisX host/runtime/CLI code
34
53
  or using private interfaces.
54
+
55
+ Plugin-owned styles use `import './details.css'` in the owning component (or
56
+ lazy page module). Keep `src/vite-env.d.ts`: Vite types distinguish side-effect
57
+ CSS, CSS Module class maps, and explicit `?inline` text. Ordinary CSS imports
58
+ do not return strings. Component unmount is not CSS-module disposal; verify
59
+ plugin replacement separately from CSS HMR. See the maintained
60
+ [CSS guidance](https://github.com/cordisx/cordisx/blob/main/skills/cordisx-plugin-development/references/css-and-lifecycle.md)
61
+ for lifecycle boundaries and installed-Host CSS Modules limitations.
62
+
63
+ For operation feedback, use `ctx.notifications.show()` and declare
64
+ `notifications` in plugin injection. Host supplies plugin identity, cards and
65
+ user suppression rules. Do not create a Toast provider, manually positioned
66
+ alert or page-wide operation error; keep field validation and durable business
67
+ state next to the relevant control. See the
68
+ [notification guide](https://github.com/cordisx/cordisx/blob/efbff656d84b482d51598bc5ba303d24134e0c62/.agents/docs/notifications.md).
69
+ Verify SDK/Host availability first: an older version without this service is a
70
+ capability gap, not a reason to build a private DOM fallback.
71
+
72
+ For modals, use Host `Dialog`/`DialogProvider` from `cordisx/ui` or `ctx.dialogs`.
73
+ Keep full JSX in the body and use structured header/footer actions. The Host
74
+ owns the rightmost close button, focus, theme and lifecycle. See the
75
+ [dialog guide](https://github.com/cordisx/cordisx/blob/main/.agents/docs/dialogs.md).
@@ -0,0 +1,39 @@
1
+ # {{packageName}}
2
+
3
+ 一个最小的 CordisX 本地可信插件。插件清单由
4
+ `src/{{pluginId}}.tsx` 导出;该文件同时也是 CordisX 本地开发使用的入口。
5
+
6
+ ```bash
7
+ {{packageManager}} install
8
+ {{packageManager}} run check
9
+ {{packageManager}} run dev:dry-run
10
+ {{packageManager}} run dev
11
+ ```
12
+
13
+ `{{packageManager}} run dev:dry-run` 会在不启动 Codex Desktop 的情况下打包插件;
14
+ `{{packageManager}} run dev` 会启动独立的 CordisX 开发宿主。
15
+
16
+ `{{packageManager}} run build` 会在 `dist/runtime/` 中生成生产用 Vite ESM graph。固定入口
17
+ `module.js` 可以在 `import()` 实际触发后再加载带内容摘要的 JavaScript chunk、
18
+ CSS 和静态资源;正式的 `artifact.json` 会记录完整产物图,类型声明则隔离在
19
+ `dist/types/`,
20
+ `package.json#files` 会将整个 `dist/` 纳入打包。生产 generation 仍是不可变包
21
+ 产物,不使用开发 HMR 连接。
22
+
23
+ 生成的配置会调用公开的 `cordisx/vite` `cordisXPluginViteConfig()` helper。
24
+ 这个作者侧与 Host 共用的流水线负责产物规则,只虚拟化封闭的 Host 单例导入,并
25
+ 生成正式的 `dist/runtime/artifact.json`。可移植 CordisX package manifest 应把浏览器入口
26
+ 指向相邻的预构建 `dist/runtime/module.js`;Host 会验证并保留该完整索引 graph。
27
+
28
+ 这个生成项目属于 [CordisX 独立插件例外](https://github.com/cordisx/cordisx/blob/main/CORDISX-INDEPENDENT-PLUGIN-EXCEPTION.md)
29
+ 定义的“已标记模板材料”。只要它保持为独立插件,并且只使用有文档、带版本的
30
+ CordisX 公共插件接口,就可以用于商业用途并采用你选择的许可证。发布前请将
31
+ `package.json` 中的 `UNLICENSED` 替换为所选许可证。该例外不包括复制或修改
32
+ CordisX Host、Runtime、CLI 代码,也不包括使用私有接口。
33
+
34
+ 插件自有样式在所属组件或 lazy 页面模块中使用 `import './details.css'`。
35
+ 保留 `src/vite-env.d.ts`:Vite 类型区分普通 CSS 副作用导入、CSS Modules
36
+ 类名映射和显式 `?inline` 文本,普通 CSS 不返回字符串。组件卸载不等于
37
+ CSS 模块回收;CSS HMR 与插件替换需要分别验证。具体生命周期和已安装
38
+ Host 的 CSS Modules 限制见
39
+ [CSS 指导](https://github.com/cordisx/cordisx/blob/main/skills/cordisx-plugin-development/references/css-and-lifecycle.md)。
@@ -5,11 +5,23 @@
5
5
  "license": "UNLICENSED",
6
6
  "description": "A CordisX plugin",
7
7
  "type": "module",
8
- "main": "./dist/{{pluginId}}.js",
9
- "types": "./dist/{{pluginId}}.d.ts",
8
+ "files": [
9
+ "dist",
10
+ "README.md",
11
+ "README.zh-Hans.md"
12
+ ],
13
+ "main": "./dist/runtime/module.js",
14
+ "types": "./dist/types/{{pluginId}}.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/types/{{pluginId}}.d.ts",
18
+ "import": "./dist/runtime/module.js",
19
+ "default": "./dist/runtime/module.js"
20
+ }
21
+ },
10
22
  "scripts": {
11
- "build": "tsc -p tsconfig.json",
12
- "check": "npm run typecheck && npm run build && npm test",
23
+ "build": "vite build && tsc -p tsconfig.json --emitDeclarationOnly",
24
+ "check": "tsc -p tsconfig.json --noEmit && vite build && tsc -p tsconfig.json --emitDeclarationOnly && node --test test/manifest.mjs",
13
25
  "dev": "cordisx dev ./src/{{pluginId}}.tsx",
14
26
  "dev:dry-run": "cordisx dev ./src/{{pluginId}}.tsx --dry-run",
15
27
  "test": "node --test test/manifest.mjs",
@@ -18,7 +30,8 @@
18
30
  "devDependencies": {
19
31
  "@deepseek-ai/cordis": "4.0.1",
20
32
  "cordisx": "{{cordisxVersion}}",
21
- "typescript": "^5.9.2"
33
+ "typescript": "^5.9.2",
34
+ "vite": "8.2.2"
22
35
  },
23
36
  "engines": {
24
37
  "node": ">=22.19"
@@ -0,0 +1,30 @@
1
+ import { useState } from 'cordisx/react'
2
+ import { Button, Card, Heading, Stack, Text } from 'cordisx/ui'
3
+ import type { CordisXReactPageProps } from 'cordisx/contracts'
4
+
5
+ export type Messages = {
6
+ 'command.open': undefined
7
+ 'page.title': undefined
8
+ 'page.description': undefined
9
+ 'route.title': undefined
10
+ 'route.description': undefined
11
+ 'counter.label': { count: number }
12
+ }
13
+
14
+ /** A named component-only module forms a stable React Fast Refresh boundary. */
15
+ export function OverviewPage({ t }: CordisXReactPageProps<Messages>) {
16
+ const [count, setCount] = useState(0)
17
+ return (
18
+ <Stack gap="large">
19
+ <Heading>{t('page.title')}</Heading>
20
+ <Card>
21
+ <Stack gap="medium" align="flex-start">
22
+ <Text tone="muted">{t('page.description')}</Text>
23
+ <Button variant="primary" onClick={() => setCount(value => value + 1)}>
24
+ {t('counter.label', { count })}
25
+ </Button>
26
+ </Stack>
27
+ </Card>
28
+ </Stack>
29
+ )
30
+ }
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -1,22 +1,13 @@
1
1
  import type { Context } from '@deepseek-ai/cordis'
2
- import { defineReactPage, useState } from 'cordisx/react'
3
- import { Button, Card, Heading, Stack, Text } from 'cordisx/ui'
2
+ import { defineReactPage, lazy, Suspense } from 'cordisx/react'
4
3
  import {
5
4
  CORDISX_PAGE_SCHEMA_V3,
6
5
  CORDISX_PLUGIN_MANIFEST_SCHEMA_V1,
7
6
  CORDISX_ROUTE_SCHEMA_V2,
8
7
  type CordisXPluginManifestV1,
8
+ type CordisXReactPageProps,
9
9
  } from 'cordisx/contracts'
10
- import type {} from 'cordisx/contracts'
11
-
12
- type Messages = {
13
- 'command.open': undefined
14
- 'page.title': undefined
15
- 'page.description': undefined
16
- 'route.title': undefined
17
- 'route.description': undefined
18
- 'counter.label': { count: number }
19
- }
10
+ import type { Messages } from './overview-page.js'
20
11
 
21
12
  export const manifest = {
22
13
  $schema: CORDISX_PLUGIN_MANIFEST_SCHEMA_V1,
@@ -48,22 +39,20 @@ const route = {
48
39
  description: { key: 'route.description', fallback: 'Open the plugin React example.' },
49
40
  } as const
50
41
 
51
- const mountOverview = defineReactPage<Messages>(({ t }) => {
52
- const [count, setCount] = useState(0)
42
+ const OverviewPage = lazy(async () => {
43
+ const module = await import('./overview-page.js')
44
+ return { default: module.OverviewPage }
45
+ })
46
+
47
+ function OverviewPageBoundary(props: CordisXReactPageProps<Messages>) {
53
48
  return (
54
- <Stack gap="large">
55
- <Heading>{t('page.title')}</Heading>
56
- <Card>
57
- <Stack gap="medium" align="flex-start">
58
- <Text tone="muted">{t('page.description')}</Text>
59
- <Button variant="primary" onClick={() => setCount(value => value + 1)}>
60
- {t('counter.label', { count })}
61
- </Button>
62
- </Stack>
63
- </Card>
64
- </Stack>
49
+ <Suspense fallback={null}>
50
+ <OverviewPage {...props} />
51
+ </Suspense>
65
52
  )
66
- })
53
+ }
54
+
55
+ const mountOverview = defineReactPage<Messages>(OverviewPageBoundary)
67
56
 
68
57
  export function apply(ctx: Context): void {
69
58
  ctx.i18n.define<Messages>({
@@ -1,6 +1,22 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import test from 'node:test'
3
- import { apply, inject, manifest } from '../dist/{{pluginId}}.js'
3
+
4
+ const component = () => null
5
+ const noop = () => undefined
6
+ const React = new Proxy({ Fragment: Symbol('Fragment'), Suspense: component, lazy: () => component }, {
7
+ get(target, property) {
8
+ return Reflect.get(target, property) ?? noop
9
+ },
10
+ })
11
+ globalThis.__cordisxSharedReactRuntime = {
12
+ React,
13
+ defineReactPage: page => page,
14
+ jsxRuntime: { Fragment: React.Fragment, jsx: component, jsxs: component },
15
+ jsxDevRuntime: { Fragment: React.Fragment, jsxDEV: component },
16
+ ui: new Proxy({}, { get: () => component }),
17
+ }
18
+
19
+ const { apply, inject, manifest } = await import('../dist/runtime/module.js')
4
20
 
5
21
  test('exports a minimal CordisX plugin module', () => {
6
22
  assert.equal(manifest.schemaVersion, 1)
@@ -7,7 +7,7 @@
7
7
  "jsxImportSource": "cordisx/react",
8
8
  "lib": ["ES2023", "DOM"],
9
9
  "rootDir": "src",
10
- "outDir": "dist",
10
+ "outDir": "dist/types",
11
11
  "strict": true,
12
12
  "declaration": true,
13
13
  "forceConsistentCasingInFileNames": true,
@@ -0,0 +1,13 @@
1
+ import { fileURLToPath } from 'node:url'
2
+ import { cordisXPluginViteConfig } from 'cordisx/vite'
3
+
4
+ const projectRoot = fileURLToPath(new URL('.', import.meta.url))
5
+
6
+ const config: ReturnType<typeof cordisXPluginViteConfig> = cordisXPluginViteConfig({
7
+ root: projectRoot,
8
+ entry: fileURLToPath(new URL('./{{sourceEntry}}', import.meta.url)),
9
+ outDir: fileURLToPath(new URL('./{{outDir}}', import.meta.url)),
10
+ entryFileName: 'module.js',
11
+ })
12
+
13
+ export default config