create-cordisx-plugin 0.1.0-beta.0 → 0.1.0-beta.3

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/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)}`);