@s2script/sdk 0.3.0 → 0.5.0

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
@@ -4,12 +4,12 @@
4
4
  import * as esbuild from "esbuild";
5
5
  import AdmZip from "adm-zip";
6
6
  import { readFileSync as readFileSync4, mkdirSync } from "node:fs";
7
- import { resolve as resolve4, join as join4 } from "node:path";
7
+ import { resolve as resolve5, join as join6 } from "node:path";
8
8
 
9
9
  // src/typecheck/typecheck.ts
10
10
  import ts from "typescript";
11
- import { existsSync as existsSync2, readdirSync, readFileSync, writeFileSync, rmSync, mkdtempSync } from "node:fs";
12
- import { join as join2, resolve as resolve2 } from "node:path";
11
+ import { existsSync as existsSync3, readdirSync, readFileSync, writeFileSync, rmSync, mkdtempSync } from "node:fs";
12
+ import { join as join3, resolve as resolve2 } from "node:path";
13
13
  import { tmpdir } from "node:os";
14
14
 
15
15
  // src/packages-resolve.ts
@@ -55,11 +55,47 @@ function resolvePackagesDir(opts) {
55
55
  );
56
56
  }
57
57
 
58
+ // src/tsconfig-shared.ts
59
+ var sharedCompilerOptionsJson = {
60
+ strict: true,
61
+ noEmit: true,
62
+ moduleResolution: "bundler",
63
+ module: "ESNext",
64
+ target: "ES2020",
65
+ lib: ["ES2020"],
66
+ types: [],
67
+ skipLibCheck: true,
68
+ allowImportingTsExtensions: true
69
+ };
70
+ function sharedProgramOptions(tsMod) {
71
+ return {
72
+ strict: sharedCompilerOptionsJson.strict,
73
+ noEmit: sharedCompilerOptionsJson.noEmit,
74
+ allowImportingTsExtensions: sharedCompilerOptionsJson.allowImportingTsExtensions,
75
+ moduleResolution: tsMod.ModuleResolutionKind.Bundler,
76
+ module: tsMod.ModuleKind.ESNext,
77
+ target: tsMod.ScriptTarget.ES2020,
78
+ lib: sharedCompilerOptionsJson.lib.map((l) => `lib.${l.toLowerCase()}.d.ts`),
79
+ types: [...sharedCompilerOptionsJson.types],
80
+ skipLibCheck: sharedCompilerOptionsJson.skipLibCheck
81
+ };
82
+ }
83
+
84
+ // src/contracts.ts
85
+ import { existsSync as existsSync2 } from "node:fs";
86
+ import { join as join2 } from "node:path";
87
+ function localContractPath(pluginDir, dep) {
88
+ const segs = dep.split("/");
89
+ if (segs.some((s) => s === "" || s === "." || s === "..")) return null;
90
+ const p = join2(pluginDir, ".s2script", "types", ...segs, "index.d.ts");
91
+ return existsSync2(p) ? p : null;
92
+ }
93
+
58
94
  // src/typecheck/typecheck.ts
59
95
  function localDeclarationFiles(pluginDir) {
60
- const srcDir = join2(pluginDir, "src");
61
- if (!existsSync2(srcDir)) return [];
62
- return readdirSync(srcDir).filter((f) => f.endsWith(".d.ts")).map((f) => join2(srcDir, f));
96
+ const srcDir = join3(pluginDir, "src");
97
+ if (!existsSync3(srcDir)) return [];
98
+ return readdirSync(srcDir).filter((f) => f.endsWith(".d.ts")).map((f) => join3(srcDir, f));
63
99
  }
64
100
  function declaredModules(dtsFiles) {
65
101
  const out = /* @__PURE__ */ new Set();
@@ -72,28 +108,33 @@ function declaredModules(dtsFiles) {
72
108
  function typecheckPlugin(pluginDir, opts) {
73
109
  const absDir = resolve2(pluginDir);
74
110
  const packagesDir = opts?.packagesDir ? resolve2(opts.packagesDir) : resolvePackagesDir({ pluginDir: absDir });
75
- const pkg = JSON.parse(readFileSync(join2(absDir, "package.json"), "utf8"));
111
+ const pkg = JSON.parse(readFileSync(join3(absDir, "package.json"), "utf8"));
76
112
  const s2 = pkg.s2script ?? {};
77
113
  const entryRel = s2.main ?? pkg.main;
78
- if (!entryRel) throw new Error(`typecheckPlugin: no entry point in ${join2(absDir, "package.json")}`);
114
+ if (!entryRel) throw new Error(`typecheckPlugin: no entry point in ${join3(absDir, "package.json")}`);
79
115
  const entry = resolve2(absDir, entryRel);
80
116
  const isAlwaysResolved = (d) => d.startsWith("@s2script/sdk/") || d === "@s2script/cs2" || d.startsWith("@s2script/cs2/");
81
117
  const localDts = localDeclarationFiles(absDir);
82
118
  const locallyDeclared = declaredModules(localDts);
119
+ const allDeclaredDeps = [
120
+ ...Object.keys(s2.pluginDependencies ?? {}),
121
+ ...Object.keys(s2.optionalPluginDependencies ?? {})
122
+ ];
123
+ const contractPaths = {};
124
+ for (const d of allDeclaredDeps) {
125
+ const p = localContractPath(absDir, d);
126
+ if (p !== null) contractPaths[d] = [p];
127
+ }
83
128
  const deps = [
84
129
  ...Object.keys(s2.pluginDependencies ?? {}),
85
130
  ...Object.keys(s2.optionalPluginDependencies ?? {})
86
131
  // Never stub a module the plugin declares itself — a shorthand `declare module "X";` and a
87
132
  // full `declare module "X" { … }` for the same X collide.
88
- ].filter((d) => !isAlwaysResolved(d) && !locallyDeclared.has(d));
133
+ ].filter((d) => !isAlwaysResolved(d) && !locallyDeclared.has(d) && contractPaths[d] === void 0);
89
134
  const options = {
90
- strict: true,
91
- noEmit: true,
92
- moduleResolution: ts.ModuleResolutionKind.Bundler,
93
- module: ts.ModuleKind.ESNext,
94
- target: ts.ScriptTarget.ES2020,
95
- lib: ["lib.es2020.d.ts"],
96
- types: [],
135
+ // Accept explicit `.ts` import extensions (node type-stripping requires them for source-to-source
136
+ // imports; esbuild strips them at bundle time). Backward-compatible — extensionless imports still resolve.
137
+ ...sharedProgramOptions(ts),
97
138
  baseUrl: packagesDir,
98
139
  paths: {
99
140
  // Builtins are `@s2script/sdk/<cap>` → packages/sdk/<cap>.d.ts. The `@s2script/*` fallback
@@ -101,15 +142,15 @@ function typecheckPlugin(pluginDir, opts) {
101
142
  // dirs are deleted). tsc picks the longest matching prefix, so `@s2script/sdk/*` wins for
102
143
  // sdk imports.
103
144
  "@s2script/sdk/*": ["sdk/*.d.ts"],
104
- "@s2script/*": ["*/index.d.ts"]
105
- },
106
- skipLibCheck: true
145
+ "@s2script/*": ["*/index.d.ts"],
146
+ ...contractPaths
147
+ }
107
148
  };
108
- const rootNames = [entry, join2(packagesDir, "sdk", "globals.d.ts"), ...localDts];
109
- const tmp = mkdtempSync(join2(tmpdir(), "s2tc-"));
149
+ const rootNames = [entry, join3(packagesDir, "sdk", "globals.d.ts"), ...localDts];
150
+ const tmp = mkdtempSync(join3(tmpdir(), "s2tc-"));
110
151
  try {
111
152
  if (deps.length) {
112
- const stub = join2(tmp, "ambient.d.ts");
153
+ const stub = join3(tmp, "ambient.d.ts");
113
154
  writeFileSync(stub, deps.map((d) => `declare module ${JSON.stringify(d)};`).join("\n") + "\n");
114
155
  rootNames.push(stub);
115
156
  }
@@ -129,7 +170,7 @@ function typecheckPlugin(pluginDir, opts) {
129
170
  }
130
171
  return { file, line, col, code: d.code, message: ts.flattenDiagnosticMessageText(d.messageText, "\n") };
131
172
  });
132
- return { ok: out.length === 0, diagnostics: out };
173
+ return { ok: out.length === 0, diagnostics: out, program };
133
174
  } finally {
134
175
  rmSync(tmp, { recursive: true, force: true });
135
176
  }
@@ -139,30 +180,105 @@ function formatDiagnostics(diags) {
139
180
  }
140
181
 
141
182
  // src/config-validate.ts
183
+ function isDeclType(t) {
184
+ return t === "string" || t === "int" || t === "float" || t === "bool";
185
+ }
186
+ function matchesType(v, t) {
187
+ switch (t) {
188
+ case "string":
189
+ return typeof v === "string";
190
+ case "int":
191
+ return typeof v === "number" && Number.isInteger(v);
192
+ case "float":
193
+ return typeof v === "number";
194
+ case "bool":
195
+ return typeof v === "boolean";
196
+ }
197
+ }
142
198
  function validateConfigBlock(config) {
143
199
  const errs = [];
144
200
  if (config == null) return errs;
145
- if (typeof config !== "object" || Array.isArray(config)) return ["s2script.config must be an object"];
146
- for (const [key, raw] of Object.entries(config)) {
147
- if (typeof raw !== "object" || raw === null) {
148
- errs.push(`config '${key}': must be { type, default }`);
201
+ validateEntries(config, "", errs);
202
+ return errs;
203
+ }
204
+ function validateEntries(node, prefix, errs) {
205
+ if (typeof node !== "object" || node === null || Array.isArray(node)) {
206
+ errs.push(`${prefix ? `config '${prefix}'` : "s2script.config"} must be an object`);
207
+ return;
208
+ }
209
+ for (const [key, raw] of Object.entries(node)) {
210
+ const full = prefix ? `${prefix}.${key}` : key;
211
+ if (key.includes(".")) {
212
+ errs.push(`config '${full}': key must not contain '.' (dotted names are reserved for section access)`);
149
213
  continue;
150
214
  }
151
- const decl = raw;
152
- const t = decl.type, d = decl.default;
153
- if (t !== "string" && t !== "int" && t !== "float" && t !== "bool") {
154
- errs.push(`config '${key}': unknown type ${JSON.stringify(t)} (want string|int|float|bool)`);
215
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
216
+ errs.push(`config '${full}': must be a declaration { type, default } or a section object`);
155
217
  continue;
156
218
  }
157
- const ok = t === "string" && typeof d === "string" || t === "int" && typeof d === "number" && Number.isInteger(d) || t === "float" && typeof d === "number" || t === "bool" && typeof d === "boolean";
158
- if (!ok) errs.push(`config '${key}': default ${JSON.stringify(d)} does not match type '${t}'`);
219
+ const obj = raw;
220
+ if (typeof obj.type === "string") {
221
+ validateDecl(full, obj, obj.type, errs);
222
+ } else {
223
+ validateEntries(obj, full, errs);
224
+ }
159
225
  }
160
- return errs;
226
+ }
227
+ function validateDecl(full, decl, typeStr, errs) {
228
+ if (!isDeclType(typeStr)) {
229
+ errs.push(`config '${full}': unknown type ${JSON.stringify(typeStr)} (want string|int|float|bool)`);
230
+ return;
231
+ }
232
+ const t = typeStr;
233
+ if (!("default" in decl)) {
234
+ errs.push(`config '${full}': missing 'default'`);
235
+ } else if (!matchesType(decl.default, t)) {
236
+ errs.push(`config '${full}': default ${JSON.stringify(decl.default)} does not match type '${t}'`);
237
+ }
238
+ const hasMin = decl.min !== void 0;
239
+ const hasMax = decl.max !== void 0;
240
+ const hasEnum = decl.enum !== void 0;
241
+ if (hasEnum && (hasMin || hasMax)) {
242
+ errs.push(`config '${full}': 'enum' is mutually exclusive with 'min'/'max'`);
243
+ }
244
+ if (hasMin || hasMax) {
245
+ if (t !== "int" && t !== "float") {
246
+ errs.push(`config '${full}': 'min'/'max' apply to int|float only (type '${t}')`);
247
+ }
248
+ if (hasMin && typeof decl.min !== "number") errs.push(`config '${full}': 'min' must be a number`);
249
+ if (hasMax && typeof decl.max !== "number") errs.push(`config '${full}': 'max' must be a number`);
250
+ if (typeof decl.min === "number" && typeof decl.max === "number" && decl.min > decl.max) {
251
+ errs.push(`config '${full}': 'min' (${decl.min}) exceeds 'max' (${decl.max})`);
252
+ }
253
+ if (typeof decl.default === "number") {
254
+ if (typeof decl.min === "number" && decl.default < decl.min) errs.push(`config '${full}': default ${decl.default} is below min ${decl.min}`);
255
+ if (typeof decl.max === "number" && decl.default > decl.max) errs.push(`config '${full}': default ${decl.default} is above max ${decl.max}`);
256
+ }
257
+ }
258
+ if (hasEnum) {
259
+ if (t !== "string" && t !== "int") {
260
+ errs.push(`config '${full}': 'enum' applies to string|int only (type '${t}')`);
261
+ }
262
+ if (!Array.isArray(decl.enum) || decl.enum.length === 0) {
263
+ errs.push(`config '${full}': 'enum' must be a non-empty array`);
264
+ } else {
265
+ for (const e of decl.enum) {
266
+ if (!matchesType(e, t)) errs.push(`config '${full}': enum value ${JSON.stringify(e)} does not match type '${t}'`);
267
+ }
268
+ if ("default" in decl && matchesType(decl.default, t) && !decl.enum.some((e) => e === decl.default)) {
269
+ errs.push(`config '${full}': default ${JSON.stringify(decl.default)} is not one of enum`);
270
+ }
271
+ }
272
+ }
273
+ if (decl.sensitive !== void 0 && typeof decl.sensitive !== "boolean") errs.push(`config '${full}': 'sensitive' must be a boolean`);
274
+ if (decl.group !== void 0 && typeof decl.group !== "string") errs.push(`config '${full}': 'group' must be a string`);
275
+ if (decl.label !== void 0 && typeof decl.label !== "string") errs.push(`config '${full}': 'label' must be a string`);
276
+ if (decl.description !== void 0 && typeof decl.description !== "string") errs.push(`config '${full}': 'description' must be a string`);
161
277
  }
162
278
 
163
279
  // src/publish-gate.ts
164
- import { existsSync as existsSync3, readFileSync as readFileSync2, statSync } from "node:fs";
165
- import { join as join3, resolve as resolve3 } from "node:path";
280
+ import { existsSync as existsSync4, readFileSync as readFileSync2, statSync } from "node:fs";
281
+ import { join as join4, resolve as resolve3 } from "node:path";
166
282
  function hasPublishes(publishes) {
167
283
  if (publishes == null) return false;
168
284
  if (typeof publishes === "string") return publishes.trim().length > 0;
@@ -188,7 +304,7 @@ function assertPublishesTypes(pkg, pluginDir) {
188
304
  };
189
305
  }
190
306
  const typesPath = resolve3(pluginDir, typesRel);
191
- if (!existsSync3(typesPath)) {
307
+ if (!existsSync4(typesPath)) {
192
308
  return { ok: false, error: `types file not found: ${typesRel}` };
193
309
  }
194
310
  const st = statSync(typesPath);
@@ -263,32 +379,160 @@ function derivePublishes(authored, pkgName, pkgVersion, typesPath) {
263
379
  return out;
264
380
  }
265
381
 
382
+ // src/api-version.ts
383
+ var HOST_API_VERSION_MAJOR = 2;
384
+ var STAMPED_API_VERSION = `${HOST_API_VERSION_MAJOR}.x`;
385
+
386
+ // src/publish-scan.ts
387
+ import ts2 from "typescript";
388
+ function isPluginContext(type) {
389
+ const sym = type.getSymbol() ?? type.aliasSymbol;
390
+ return sym?.getName() === "PluginContext";
391
+ }
392
+ function scanPluginProgram(program, pluginDir) {
393
+ const checker = program.getTypeChecker();
394
+ const out = { publishNames: [], dynamicPublishSites: [], useNames: [] };
395
+ const dirPrefix = pluginDir.replace(/\\/g, "/").replace(/\/+$/, "") + "/";
396
+ for (const sf of program.getSourceFiles()) {
397
+ if (sf.isDeclarationFile) continue;
398
+ if (!sf.fileName.replace(/\\/g, "/").startsWith(dirPrefix)) continue;
399
+ const visit = (node) => {
400
+ if (ts2.isCallExpression(node) && ts2.isPropertyAccessExpression(node.expression)) {
401
+ const method = node.expression.name.text;
402
+ if (method === "publish" || method === "use" || method === "tryUse") {
403
+ const recv = checker.getTypeAtLocation(node.expression.expression);
404
+ if (isPluginContext(recv)) {
405
+ const arg0 = node.arguments[0];
406
+ if (method === "publish") {
407
+ if (arg0 !== void 0 && ts2.isStringLiteralLike(arg0)) {
408
+ out.publishNames.push(arg0.text);
409
+ } else {
410
+ const { line } = sf.getLineAndCharacterOfPosition(node.getStart());
411
+ out.dynamicPublishSites.push(`${sf.fileName}:${line + 1}`);
412
+ }
413
+ } else if (arg0 !== void 0 && ts2.isStringLiteralLike(arg0)) {
414
+ out.useNames.push(arg0.text);
415
+ }
416
+ }
417
+ }
418
+ }
419
+ ts2.forEachChild(node, visit);
420
+ };
421
+ visit(sf);
422
+ }
423
+ out.publishNames = [...new Set(out.publishNames)];
424
+ out.useNames = [...new Set(out.useNames)];
425
+ return out;
426
+ }
427
+
428
+ // src/lint/lint.ts
429
+ import { ESLint } from "eslint";
430
+ import { existsSync as existsSync5 } from "node:fs";
431
+ import { join as join5, resolve as resolve4 } from "node:path";
432
+ import s2lint from "@s2script/eslint-plugin";
433
+ var CONFIG_FILES = ["eslint.config.js", "eslint.config.mjs", "eslint.config.cjs", "eslint.config.ts"];
434
+ async function lintPlugin(pluginDir, program) {
435
+ const absDir = resolve4(pluginDir);
436
+ const hasOwnConfig = CONFIG_FILES.some((f) => existsSync5(join5(absDir, f)));
437
+ const eslint = hasOwnConfig ? new ESLint({ cwd: absDir, errorOnUnmatchedPattern: false }) : new ESLint({
438
+ cwd: absDir,
439
+ overrideConfigFile: true,
440
+ overrideConfig: s2lint.configs.build([program]),
441
+ errorOnUnmatchedPattern: false
442
+ });
443
+ const dirPrefix = absDir.replace(/\\/g, "/").replace(/\/+$/, "") + "/";
444
+ const targets = hasOwnConfig ? ["**/*.ts"] : program.getSourceFiles().filter((sf) => !sf.isDeclarationFile && sf.fileName.replace(/\\/g, "/").startsWith(dirPrefix)).map((sf) => sf.fileName);
445
+ if (targets.length === 0) return { ok: true, output: "", errorCount: 0 };
446
+ const results = await eslint.lintFiles(targets);
447
+ const errorCount = results.reduce((n, r) => n + r.errorCount, 0);
448
+ const formatter = await eslint.loadFormatter("stylish");
449
+ const output2 = String(await formatter.format(results));
450
+ return { ok: errorCount === 0, output: output2, errorCount };
451
+ }
452
+
266
453
  // src/build.ts
267
454
  async function buildPlugin(dir, packagesDir) {
268
- const absDir = resolve4(dir);
269
- const pkgPath = join4(absDir, "package.json");
455
+ const absDir = resolve5(dir);
456
+ const pkgPath = join6(absDir, "package.json");
270
457
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
271
- const gate = assertPublishesTypes(pkg, absDir);
272
- if (!gate.ok) {
273
- throw new Error(`publish gate failed: ${gate.error}`);
274
- }
275
458
  const s2 = pkg.s2script ?? {};
276
- const derivedPublishes = derivePublishes(s2.publishes, pkg.name, pkg.version, gate.typesPath);
459
+ const config = s2.config ?? void 0;
460
+ if (config !== void 0) {
461
+ const cfgErrs = validateConfigBlock(config);
462
+ if (cfgErrs.length) throw new Error(`invalid s2script.config:
463
+ ${cfgErrs.join("\n ")}`);
464
+ }
277
465
  const tc = typecheckPlugin(absDir, packagesDir !== void 0 ? { packagesDir } : void 0);
278
466
  if (!tc.ok) {
279
467
  throw new Error(`typecheck failed (${tc.diagnostics.length} error(s)):
280
468
  ${formatDiagnostics(tc.diagnostics)}`);
281
469
  }
282
- const { name, version } = pkg;
283
- const apiVersion = s2.apiVersion ?? "";
470
+ const scan = scanPluginProgram(tc.program, absDir);
471
+ const lint = await lintPlugin(absDir, tc.program);
472
+ if (!lint.ok) {
473
+ throw new Error(`lint failed (${lint.errorCount} error(s)):
474
+ ${lint.output}`);
475
+ }
476
+ if (lint.output.trim().length > 0) console.warn(lint.output);
477
+ if (scan.dynamicPublishSites.length > 0) {
478
+ throw new Error(
479
+ `ctx.publish name must be a string literal (the manifest publishes block is derived from code):
480
+ ` + scan.dynamicPublishSites.join("\n ")
481
+ );
482
+ }
483
+ let effectivePublishes = s2.publishes;
484
+ if (!hasPublishes(effectivePublishes)) {
485
+ if (scan.publishNames.length === 1 && scan.publishNames[0] === pkg.name) {
486
+ effectivePublishes = "self";
487
+ } else if (scan.publishNames.length > 0) {
488
+ throw new Error(
489
+ `code publishes ${JSON.stringify(scan.publishNames)} but s2script.publishes is missing \u2014 a contract named differently from the package needs an authored entry with a concrete version`
490
+ );
491
+ }
492
+ } else {
493
+ const authoredNames = Object.keys(expandPublishes(effectivePublishes, pkg.name, pkg.version)).sort();
494
+ const codeNames = [...scan.publishNames].sort();
495
+ if (JSON.stringify(authoredNames) !== JSON.stringify(codeNames)) {
496
+ throw new Error(
497
+ `publishes drift: package.json declares ${JSON.stringify(authoredNames)} but the code's ctx.publish calls are ${JSON.stringify(codeNames)} \u2014 fix whichever is wrong (the manifest is generated from code; the loader re-verifies at Active)`
498
+ );
499
+ }
500
+ }
501
+ const gate = assertPublishesTypes({ ...pkg, s2script: { ...s2, publishes: effectivePublishes } }, absDir);
502
+ if (!gate.ok) {
503
+ throw new Error(`publish gate failed: ${gate.error}`);
504
+ }
505
+ const derivedPublishes = derivePublishes(
506
+ effectivePublishes,
507
+ pkg.name,
508
+ pkg.version,
509
+ gate.typesPath
510
+ );
284
511
  const pluginDependencies = s2.pluginDependencies ?? {};
285
512
  const optionalPluginDependencies = s2.optionalPluginDependencies ?? {};
286
- const config = s2.config ?? void 0;
287
- if (config !== void 0) {
288
- const cfgErrs = validateConfigBlock(config);
289
- if (cfgErrs.length) throw new Error(`invalid s2script.config:
290
- ${cfgErrs.join("\n ")}`);
513
+ const declaredDeps = /* @__PURE__ */ new Set([
514
+ ...Object.keys(pluginDependencies),
515
+ ...Object.keys(optionalPluginDependencies)
516
+ ]);
517
+ for (const used of scan.useNames) {
518
+ if (!declaredDeps.has(used)) {
519
+ console.warn(
520
+ `WARN: ctx.use/tryUse(${JSON.stringify(used)}) is not declared under s2script.pluginDependencies/optionalPluginDependencies \u2014 it will throw at runtime`
521
+ );
522
+ }
523
+ }
524
+ for (const dep of declaredDeps) {
525
+ if (!scan.useNames.includes(dep)) {
526
+ console.warn(`WARN: dependency ${JSON.stringify(dep)} is declared but never ctx.use()d`);
527
+ }
291
528
  }
529
+ const { name, version } = pkg;
530
+ if (s2.apiVersion !== void 0) {
531
+ console.warn(
532
+ `WARN: ${pkgPath}: s2script.apiVersion is ignored \u2014 s2s build derives apiVersion from the SDK (stamping ${JSON.stringify(STAMPED_API_VERSION)}). Remove the field.`
533
+ );
534
+ }
535
+ const apiVersion = STAMPED_API_VERSION;
292
536
  const external = Array.from(/* @__PURE__ */ new Set([
293
537
  "@s2script/*",
294
538
  ...Object.keys(pluginDependencies),
@@ -300,7 +544,7 @@ ${formatDiagnostics(tc.diagnostics)}`);
300
544
  `buildPlugin: no entry point found in ${pkgPath} (set s2script.main or main)`
301
545
  );
302
546
  }
303
- const entryPoint = join4(absDir, entryRelative);
547
+ const entryPoint = join6(absDir, entryRelative);
304
548
  const result = await esbuild.build({
305
549
  entryPoints: [entryPoint],
306
550
  bundle: true,
@@ -323,6 +567,15 @@ ${formatDiagnostics(tc.diagnostics)}`);
323
567
  manifest.publishes = derivedPublishes;
324
568
  }
325
569
  if (config !== void 0) manifest.config = config;
570
+ const compiledAgainst = {};
571
+ for (const dep of [
572
+ ...Object.keys(pluginDependencies),
573
+ ...Object.keys(optionalPluginDependencies)
574
+ ]) {
575
+ const contractPath = localContractPath(absDir, dep);
576
+ if (contractPath !== null) compiledAgainst[dep] = hashContract(contractPath);
577
+ }
578
+ if (Object.keys(compiledAgainst).length > 0) manifest.compiledAgainst = compiledAgainst;
326
579
  const zip = new AdmZip();
327
580
  zip.addFile("manifest.json", Buffer.from(JSON.stringify(manifest, null, 2)));
328
581
  zip.addFile("plugin.js", Buffer.from(pluginJs));
@@ -334,16 +587,16 @@ ${formatDiagnostics(tc.diagnostics)}`);
334
587
  }
335
588
  }
336
589
  const sanitizedId = name.replace(/[^a-zA-Z0-9._-]/g, "_");
337
- const outDir = join4(absDir, "dist");
590
+ const outDir = join6(absDir, "dist");
338
591
  mkdirSync(outDir, { recursive: true });
339
- const outPath = join4(outDir, `${sanitizedId}.s2sp`);
592
+ const outPath = join6(outDir, `${sanitizedId}.s2sp`);
340
593
  zip.writeZip(outPath);
341
594
  return outPath;
342
595
  }
343
596
 
344
597
  // src/schemagen/gen.ts
345
598
  import { readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "node:fs";
346
- import { join as join5 } from "node:path";
599
+ import { join as join7 } from "node:path";
347
600
 
348
601
  // src/schemagen/model.ts
349
602
  var READ = {
@@ -535,21 +788,21 @@ function emitJs(model) {
535
788
  for (const c of model.classes) {
536
789
  out.push(` A[${S(c.className)}] = {`);
537
790
  for (const f of flattenedFields(model, c.className)) {
538
- const resolve7 = `off(${S(f.declaringClass)},${S(f.rawName)})`;
791
+ const resolve8 = `off(${S(f.declaringClass)},${S(f.rawName)})`;
539
792
  let entry;
540
793
  if (f.accessorKind === "str") {
541
- entry = `get: function () { return this.ref.readString(${resolve7}, ${f.strLen}); }`;
794
+ entry = `get: function () { return this.ref.readString(${resolve8}, ${f.strLen}); }`;
542
795
  } else if (f.accessorKind === "u64" || f.accessorKind === "i64") {
543
- entry = `get: function () { var v = this.ref.${READ[f.accessorKind]}(${resolve7}); return v === null ? null : v.toString(); }`;
796
+ entry = `get: function () { var v = this.ref.${READ[f.accessorKind]}(${resolve8}); return v === null ? null : v.toString(); }`;
544
797
  } else if (VEC_INFO[f.accessorKind]) {
545
798
  const vi = VEC_INFO[f.accessorKind];
546
- entry = `get: function () { var a = this.ref.readFloats(${resolve7}, ${vi.count}); return a === null ? null : new ${vi.cls}(a[0], a[1], a[2]); }`;
799
+ entry = `get: function () { var a = this.ref.readFloats(${resolve8}, ${vi.count}); return a === null ? null : new ${vi.cls}(a[0], a[1], a[2]); }`;
547
800
  } else {
548
- entry = `get: function () { return this.ref.${READ[f.accessorKind]}(${resolve7}); }`;
801
+ entry = `get: function () { return this.ref.${READ[f.accessorKind]}(${resolve8}); }`;
549
802
  }
550
803
  if (f.writable) {
551
804
  const wm = WRITE[f.accessorKind];
552
- entry += `, set: function (v) { var o = ${resolve7}; if (this.ref.${wm}(o, v)) this.ref.notifyStateChanged(o); }`;
805
+ entry += `, set: function (v) { var o = ${resolve8}; if (this.ref.${wm}(o, v)) this.ref.notifyStateChanged(o); }`;
553
806
  }
554
807
  out.push(` ${S(f.propName)}: { ${entry} },`);
555
808
  }
@@ -575,8 +828,8 @@ var LIST_PATH = "games/cs2/codegen-classes.json";
575
828
  var JS_OUT = "games/cs2/js/schema.generated.js";
576
829
  var DTS_OUT = "packages/cs2/schema.generated.d.ts";
577
830
  function runGenSchema(repoRoot, opts) {
578
- const catalog = JSON.parse(readFileSync5(join5(repoRoot, CATALOG_PATH), "utf8"));
579
- const list = JSON.parse(readFileSync5(join5(repoRoot, LIST_PATH), "utf8"));
831
+ const catalog = JSON.parse(readFileSync5(join7(repoRoot, CATALOG_PATH), "utf8"));
832
+ const list = JSON.parse(readFileSync5(join7(repoRoot, LIST_PATH), "utf8"));
580
833
  const model = buildModel(catalog, list);
581
834
  const dts = emitDts(model);
582
835
  const js = emitJs(model);
@@ -585,7 +838,7 @@ function runGenSchema(repoRoot, opts) {
585
838
  const drift = [];
586
839
  const files = [[JS_OUT, js], [DTS_OUT, dts]];
587
840
  for (const [rel, content] of files) {
588
- const abs = join5(repoRoot, rel);
841
+ const abs = join7(repoRoot, rel);
589
842
  if (opts.check) {
590
843
  let cur = "";
591
844
  try {
@@ -605,7 +858,7 @@ function runGenSchema(repoRoot, opts) {
605
858
 
606
859
  // src/eventgen/gen.ts
607
860
  import { readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "node:fs";
608
- import { join as join6 } from "node:path";
861
+ import { join as join8 } from "node:path";
609
862
 
610
863
  // src/eventgen/model.ts
611
864
  var TYPE_TO_GETTER = {
@@ -690,11 +943,11 @@ function emitEventDts(model) {
690
943
  var CATALOG_PATH2 = "games/cs2/gamedata/event-catalog.json";
691
944
  var DTS_OUT2 = "packages/cs2/events.generated.d.ts";
692
945
  function runGenEvents(repoRoot, opts) {
693
- const catalog = JSON.parse(readFileSync6(join6(repoRoot, CATALOG_PATH2), "utf8"));
946
+ const catalog = JSON.parse(readFileSync6(join8(repoRoot, CATALOG_PATH2), "utf8"));
694
947
  const model = buildEventModel(catalog);
695
948
  const dts = emitEventDts(model);
696
949
  const drift = [];
697
- const abs = join6(repoRoot, DTS_OUT2);
950
+ const abs = join8(repoRoot, DTS_OUT2);
698
951
  if (opts.check) {
699
952
  let cur = "";
700
953
  try {
@@ -710,7 +963,7 @@ function runGenEvents(repoRoot, opts) {
710
963
 
711
964
  // src/navgen/gen.ts
712
965
  import { readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "node:fs";
713
- import { join as join7 } from "node:path";
966
+ import { join as join9 } from "node:path";
714
967
 
715
968
  // src/navgen/model.ts
716
969
  var SUPPORTED_NAV_KINDS = /* @__PURE__ */ new Set([
@@ -785,22 +1038,22 @@ function emitNavJs(model) {
785
1038
  out.push(` function ${w.wrapper}(root, path) { this.root = root; this.path = path; }`);
786
1039
  out.push(` Object.defineProperties(${w.wrapper}.prototype, {`);
787
1040
  for (const f of w.fields) {
788
- const resolve7 = `off(${S2(f.declaringClass)},${S2(f.rawName)})`;
1041
+ const resolve8 = `off(${S2(f.declaringClass)},${S2(f.rawName)})`;
789
1042
  let getter;
790
1043
  const kind = f.accessorKind;
791
1044
  if (kind === "u64") {
792
- getter = `get: function () { var v = this.root.readUInt64Via(this.path, ${resolve7}); return v === null ? null : v.toString(); }`;
1045
+ getter = `get: function () { var v = this.root.readUInt64Via(this.path, ${resolve8}); return v === null ? null : v.toString(); }`;
793
1046
  } else if (kind === "i64") {
794
- getter = `get: function () { var v = this.root.readInt64Via(this.path, ${resolve7}); return v === null ? null : v.toString(); }`;
1047
+ getter = `get: function () { var v = this.root.readInt64Via(this.path, ${resolve8}); return v === null ? null : v.toString(); }`;
795
1048
  } else if (VEC_INFO[kind]) {
796
1049
  const vi = VEC_INFO[kind];
797
- getter = `get: function () { var a = this.root.readFloatsChain(this.path, ${resolve7}, ${vi.count}); return a === null ? null : new ${vi.cls}(a[0], a[1], a[2]); }`;
1050
+ getter = `get: function () { var a = this.root.readFloatsChain(this.path, ${resolve8}, ${vi.count}); return a === null ? null : new ${vi.cls}(a[0], a[1], a[2]); }`;
798
1051
  } else if (kind === "handle") {
799
- getter = `get: function () { return this.root.readHandleVia(this.path, ${resolve7}); }`;
1052
+ getter = `get: function () { return this.root.readHandleVia(this.path, ${resolve8}); }`;
800
1053
  } else {
801
1054
  const method = READ_VIA[kind];
802
1055
  if (!method) continue;
803
- getter = `get: function () { return this.root.${method}(this.path, ${resolve7}); }`;
1056
+ getter = `get: function () { return this.root.${method}(this.path, ${resolve8}); }`;
804
1057
  }
805
1058
  out.push(` ${S2(f.propName)}: { ${getter} },`);
806
1059
  }
@@ -872,8 +1125,8 @@ var CATALOG_PATH3 = "games/cs2/gamedata/schema-catalog.json";
872
1125
  var JS_OUT2 = "games/cs2/js/nav.generated.js";
873
1126
  var DTS_OUT3 = "packages/cs2/nav.generated.d.ts";
874
1127
  function runGenNav(repoRoot, opts) {
875
- const config = JSON.parse(readFileSync7(join7(repoRoot, TARGETS_PATH), "utf8"));
876
- const catalog = JSON.parse(readFileSync7(join7(repoRoot, CATALOG_PATH3), "utf8"));
1128
+ const config = JSON.parse(readFileSync7(join9(repoRoot, TARGETS_PATH), "utf8"));
1129
+ const catalog = JSON.parse(readFileSync7(join9(repoRoot, CATALOG_PATH3), "utf8"));
877
1130
  const model = buildNavModel(config, catalog);
878
1131
  const js = emitNavJs(model);
879
1132
  const dts = emitNavDts(model);
@@ -881,7 +1134,7 @@ function runGenNav(repoRoot, opts) {
881
1134
  const drift = [];
882
1135
  const files = [[JS_OUT2, js], [DTS_OUT3, dts]];
883
1136
  for (const [rel, content] of files) {
884
- const abs = join7(repoRoot, rel);
1137
+ const abs = join9(repoRoot, rel);
885
1138
  if (opts.check) {
886
1139
  let cur = "";
887
1140
  try {
@@ -900,11 +1153,12 @@ function runGenNav(repoRoot, opts) {
900
1153
  import {
901
1154
  mkdirSync as mkdirSync2,
902
1155
  writeFileSync as writeFileSync5,
903
- existsSync as existsSync4,
1156
+ existsSync as existsSync6,
904
1157
  readdirSync as readdirSync2,
905
- readFileSync as readFileSync8
1158
+ readFileSync as readFileSync8,
1159
+ symlinkSync
906
1160
  } from "node:fs";
907
- import { dirname as dirname2, join as join8, resolve as resolve5, basename } from "node:path";
1161
+ import { dirname as dirname2, join as join10, resolve as resolve6, basename } from "node:path";
908
1162
  import { createInterface } from "node:readline/promises";
909
1163
  import { stdin as input, stdout as output } from "node:process";
910
1164
  import { spawnSync } from "node:child_process";
@@ -912,13 +1166,13 @@ import { fileURLToPath as fileURLToPath2 } from "node:url";
912
1166
  function readCliVersion() {
913
1167
  const here = dirname2(fileURLToPath2(import.meta.url));
914
1168
  const candidates = [
915
- join8(here, "..", "package.json"),
1169
+ join10(here, "..", "package.json"),
916
1170
  // dist/cli.js → packages/sdk/package.json
917
- join8(here, "..", "..", "package.json")
1171
+ join10(here, "..", "..", "package.json")
918
1172
  // src/create → packages/sdk/package.json
919
1173
  ];
920
1174
  for (const p of candidates) {
921
- if (!existsSync4(p)) continue;
1175
+ if (!existsSync6(p)) continue;
922
1176
  try {
923
1177
  const pkg = JSON.parse(readFileSync8(p, "utf8"));
924
1178
  if (pkg.name === "@s2script/sdk" && pkg.version) return pkg.version;
@@ -930,18 +1184,18 @@ function readCliVersion() {
930
1184
  function findLocalPackagesDir() {
931
1185
  const here = dirname2(fileURLToPath2(import.meta.url));
932
1186
  const candidates = [
933
- join8(here, "..", ".."),
1187
+ join10(here, "..", ".."),
934
1188
  // dist/cli.js → packages
935
- join8(here, "..", "..", "..")
1189
+ join10(here, "..", "..", "..")
936
1190
  // src/create → packages
937
1191
  ];
938
1192
  for (const c of candidates) {
939
- if (isPackagesDir(c)) return resolve5(c);
1193
+ if (isPackagesDir(c)) return resolve6(c);
940
1194
  }
941
1195
  return void 0;
942
1196
  }
943
1197
  function defaultNameFromPath(dir) {
944
- const base = basename(resolve5(dir));
1198
+ const base = basename(resolve6(dir));
945
1199
  const slug = base.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "plugin";
946
1200
  return `@plugin/${slug}`;
947
1201
  }
@@ -978,9 +1232,9 @@ function assertInstall(v) {
978
1232
  }
979
1233
  function createPackageNames(game) {
980
1234
  if (game === "cs2") {
981
- return ["sdk", "cs2"];
1235
+ return ["sdk", "cs2", "eslint-plugin"];
982
1236
  }
983
- return ["sdk"];
1237
+ return ["sdk", "eslint-plugin"];
984
1238
  }
985
1239
  function resolvePublishedVersion(pkg) {
986
1240
  const r = spawnSync("npm", ["view", pkg, "version"], { encoding: "utf8", timeout: 5e3 });
@@ -990,68 +1244,62 @@ function versionSpecFrom(status, stdout) {
990
1244
  const v = (stdout ?? "").trim();
991
1245
  return status === 0 && /^\d+\.\d+\.\d+/.test(v) ? `^${v}` : "latest";
992
1246
  }
993
- function registryDevDeps(game, sdkVersion, resolve7 = resolvePublishedVersion) {
1247
+ function registryDevDeps(game, sdkVersion, resolve8 = resolvePublishedVersion) {
994
1248
  const deps = {};
995
1249
  for (const n of createPackageNames(game)) {
996
- deps[`@s2script/${n}`] = n === "sdk" ? `^${sdkVersion}` : resolve7(`@s2script/${n}`);
1250
+ deps[`@s2script/${n}`] = n === "sdk" ? `^${sdkVersion}` : resolve8(`@s2script/${n}`);
997
1251
  }
998
1252
  return deps;
999
1253
  }
1000
1254
  function fileDevDeps(packagesDir, game) {
1001
1255
  const deps = {};
1002
1256
  for (const n of createPackageNames(game)) {
1003
- const abs = join8(packagesDir, n);
1004
- if (!existsSync4(join8(abs, "package.json"))) return void 0;
1257
+ const abs = join10(packagesDir, n);
1258
+ if (!existsSync6(join10(abs, "package.json"))) return void 0;
1005
1259
  deps[`@s2script/${n}`] = `file:${abs}`;
1006
1260
  }
1007
1261
  return deps;
1008
1262
  }
1009
1263
  function pluginSource(game) {
1010
1264
  if (game === "cs2") {
1011
- return `import { Commands } from "@s2script/sdk/commands";
1265
+ return `import { plugin } from "@s2script/sdk/plugin";
1012
1266
  import { Chat } from "@s2script/sdk/chat";
1013
1267
 
1014
- export function onLoad(): void {
1015
- Commands.register("hello", (ctx) => {
1016
- ctx.reply("hello from s2script");
1017
- if (ctx.callerSlot >= 0) {
1018
- Chat.toSlot(ctx.callerSlot, "hello from s2script");
1268
+ export default plugin((ctx) => {
1269
+ ctx.commands.register("hello", (cmd) => {
1270
+ cmd.reply("hello from s2script");
1271
+ if (cmd.callerSlot >= 0) {
1272
+ Chat.toSlot(cmd.callerSlot, "hello from s2script");
1019
1273
  }
1020
1274
  });
1021
- }
1022
-
1023
- export function onUnload(): void {}
1275
+ });
1024
1276
  `;
1025
1277
  }
1026
- return `import { OnGameFrame } from "@s2script/sdk/frame";
1278
+ return `import { plugin } from "@s2script/sdk/plugin";
1027
1279
  import { delay } from "@s2script/sdk/timers";
1028
1280
 
1029
- export function onLoad(): void {
1281
+ export default plugin((ctx) => {
1030
1282
  let n = 0;
1031
- OnGameFrame.subscribe(() => {
1283
+ ctx.server.onGameFrame(() => {
1032
1284
  n += 1;
1033
1285
  });
1034
- void delay(1000).then(() => {
1035
- console.log("s2script plugin alive; frames so far:", n);
1036
- });
1286
+ void delay(1000).then(() => console.log("s2script plugin alive; frames so far:", n));
1287
+ });
1288
+ `;
1037
1289
  }
1290
+ var ESLINT_RANGE = "^10.7.0";
1291
+ function eslintConfig() {
1292
+ return `import s2script from "@s2script/eslint-plugin";
1038
1293
 
1039
- export function onUnload(): void {}
1294
+ // The SAME pinned rules \`s2s build\` enforces \u2014 the editor's ESLint extension picks this up,
1295
+ // so a violation is a red squiggle before you ever build (green editor => green build).
1296
+ export default s2script.configs.recommended({ tsconfigRootDir: import.meta.dirname });
1040
1297
  `;
1041
1298
  }
1042
1299
  function tsconfigJson() {
1043
1300
  return JSON.stringify(
1044
1301
  {
1045
- compilerOptions: {
1046
- strict: true,
1047
- noEmit: true,
1048
- moduleResolution: "bundler",
1049
- module: "ESNext",
1050
- target: "ES2020",
1051
- lib: ["ES2020"],
1052
- types: [],
1053
- skipLibCheck: true
1054
- },
1302
+ compilerOptions: sharedCompilerOptionsJson,
1055
1303
  include: ["src", "node_modules/@s2script/sdk/globals.d.ts"]
1056
1304
  },
1057
1305
  null,
@@ -1060,7 +1308,10 @@ function tsconfigJson() {
1060
1308
  }
1061
1309
  function packageJsonContent(name, game, version, localPackagesDir) {
1062
1310
  const fileDeps = localPackagesDir ? fileDevDeps(localPackagesDir, game) : void 0;
1063
- const devDependencies = fileDeps ?? registryDevDeps(game, version);
1311
+ const devDependencies = {
1312
+ ...fileDeps ?? registryDevDeps(game, version),
1313
+ eslint: ESLINT_RANGE
1314
+ };
1064
1315
  return JSON.stringify(
1065
1316
  {
1066
1317
  name,
@@ -1070,10 +1321,7 @@ function packageJsonContent(name, game, version, localPackagesDir) {
1070
1321
  scripts: {
1071
1322
  build: "s2s build ."
1072
1323
  },
1073
- devDependencies,
1074
- s2script: {
1075
- apiVersion: "1.x"
1076
- }
1324
+ devDependencies
1077
1325
  },
1078
1326
  null,
1079
1327
  2
@@ -1086,6 +1334,12 @@ dist/
1086
1334
  .DS_Store
1087
1335
  `;
1088
1336
  }
1337
+ function linkLocalPackagesForNoInstall(targetPath, localPackagesDir) {
1338
+ const nm = join10(targetPath, "node_modules");
1339
+ mkdirSync2(nm, { recursive: true });
1340
+ const dest = join10(nm, "@s2script");
1341
+ if (!existsSync6(dest)) symlinkSync(localPackagesDir, dest);
1342
+ }
1089
1343
  function runInstall(dir, pm) {
1090
1344
  if (pm === "none") return;
1091
1345
  const r = spawnSync(pm, ["install"], {
@@ -1097,7 +1351,7 @@ function runInstall(dir, pm) {
1097
1351
  if (r.status !== 0) throw new Error(`${pm} install failed (exit ${r.status})`);
1098
1352
  }
1099
1353
  async function createPlugin(opts = {}) {
1100
- const targetPath = resolve5(opts.path ?? ".");
1354
+ const targetPath = resolve6(opts.path ?? ".");
1101
1355
  const interactive = Boolean(input.isTTY && output.isTTY && !opts.yes);
1102
1356
  let game = assertGame(opts.game);
1103
1357
  let name = opts.name;
@@ -1106,7 +1360,7 @@ async function createPlugin(opts = {}) {
1106
1360
  if (template !== "minimal") {
1107
1361
  throw new Error(`unknown template ${JSON.stringify(template)} (v1 supports: minimal)`);
1108
1362
  }
1109
- if (existsSync4(targetPath)) {
1363
+ if (existsSync6(targetPath)) {
1110
1364
  const kids = readdirSync2(targetPath);
1111
1365
  const meaningful = kids.filter((k) => k !== ".git");
1112
1366
  if (meaningful.length) {
@@ -1153,18 +1407,21 @@ async function createPlugin(opts = {}) {
1153
1407
  install = install ?? (opts.noInstall ? "none" : "npm");
1154
1408
  const version = readCliVersion();
1155
1409
  const localPackagesDir = findLocalPackagesDir();
1156
- mkdirSync2(join8(targetPath, "src"), { recursive: true });
1410
+ mkdirSync2(join10(targetPath, "src"), { recursive: true });
1157
1411
  writeFileSync5(
1158
- join8(targetPath, "package.json"),
1412
+ join10(targetPath, "package.json"),
1159
1413
  packageJsonContent(name, game, version, localPackagesDir)
1160
1414
  );
1161
- writeFileSync5(join8(targetPath, "tsconfig.json"), tsconfigJson());
1162
- writeFileSync5(join8(targetPath, "src", "plugin.ts"), pluginSource(game));
1163
- writeFileSync5(join8(targetPath, ".gitignore"), gitignore());
1415
+ writeFileSync5(join10(targetPath, "tsconfig.json"), tsconfigJson());
1416
+ writeFileSync5(join10(targetPath, "eslint.config.mjs"), eslintConfig());
1417
+ writeFileSync5(join10(targetPath, "src", "plugin.ts"), pluginSource(game));
1418
+ writeFileSync5(join10(targetPath, ".gitignore"), gitignore());
1164
1419
  let installed = false;
1165
1420
  if (install !== "none") {
1166
1421
  runInstall(targetPath, install);
1167
1422
  installed = true;
1423
+ } else if (localPackagesDir) {
1424
+ linkLocalPackagesForNoInstall(targetPath, localPackagesDir);
1168
1425
  }
1169
1426
  return {
1170
1427
  dir: targetPath,
@@ -1176,13 +1433,85 @@ async function createPlugin(opts = {}) {
1176
1433
  };
1177
1434
  }
1178
1435
 
1436
+ // src/config/gen.ts
1437
+ import AdmZip2 from "adm-zip";
1438
+ import { writeFileSync as writeFileSync6, mkdirSync as mkdirSync3 } from "node:fs";
1439
+ import { join as join11 } from "node:path";
1440
+
1441
+ // src/config/config-path.ts
1442
+ function configFileName(id) {
1443
+ return id.replace(/[^A-Za-z0-9._-]/g, "_") + ".json";
1444
+ }
1445
+
1446
+ // src/config/gen.ts
1447
+ function isDecl(v) {
1448
+ return typeof v.type === "string";
1449
+ }
1450
+ function generateDefaultJsonc(config) {
1451
+ return "{\n" + genEntries(config, 1) + "}\n";
1452
+ }
1453
+ function genEntries(entries, indent) {
1454
+ const pad = " ".repeat(indent);
1455
+ const keys = Object.keys(entries).filter((k) => !k.includes(".")).sort();
1456
+ let out = "";
1457
+ keys.forEach((key, i) => {
1458
+ const comma = i + 1 < keys.length ? "," : "";
1459
+ const raw = entries[key];
1460
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return;
1461
+ const obj = raw;
1462
+ if (isDecl(obj)) {
1463
+ const desc = typeof obj.description === "string" ? obj.description : "";
1464
+ out += `${pad}// ${String(obj.type)}${desc ? ` \u2014 ${desc}` : ""}
1465
+ `;
1466
+ out += `${pad}${JSON.stringify(key)}: ${JSON.stringify(obj.default)}${comma}
1467
+ `;
1468
+ } else {
1469
+ out += `${pad}${JSON.stringify(key)}: {
1470
+ `;
1471
+ out += genEntries(obj, indent + 1);
1472
+ out += `${pad}}${comma}
1473
+ `;
1474
+ }
1475
+ });
1476
+ return out;
1477
+ }
1478
+ function genConfigFromManifest(manifest, outDir) {
1479
+ const config = manifest.config;
1480
+ if (config == null || typeof config !== "object" || Array.isArray(config) || Object.keys(config).length === 0) {
1481
+ return null;
1482
+ }
1483
+ const id = typeof manifest.id === "string" ? manifest.id : "";
1484
+ if (!id) throw new Error("config gen: manifest declares config but has no id");
1485
+ mkdirSync3(outDir, { recursive: true });
1486
+ const path = join11(outDir, configFileName(id));
1487
+ writeFileSync6(path, generateDefaultJsonc(config));
1488
+ return path;
1489
+ }
1490
+ function genConfigForS2sp(s2spPath, outDir) {
1491
+ const zip = new AdmZip2(s2spPath);
1492
+ const entry = zip.getEntry("manifest.json");
1493
+ if (!entry) throw new Error(`config gen: ${s2spPath} has no manifest.json`);
1494
+ const manifest = JSON.parse(entry.getData().toString("utf8"));
1495
+ return genConfigFromManifest(manifest, outDir);
1496
+ }
1497
+ function runConfigGen(s2sps, outDir) {
1498
+ const written = [];
1499
+ const skipped = [];
1500
+ for (const p of s2sps) {
1501
+ const r = genConfigForS2sp(p, outDir);
1502
+ if (r) written.push(r);
1503
+ else skipped.push(p);
1504
+ }
1505
+ return { written, skipped };
1506
+ }
1507
+
1179
1508
  // src/cli.ts
1180
1509
  import { fileURLToPath as fileURLToPath3 } from "node:url";
1181
- import { dirname as dirname3, join as join9, resolve as resolve6 } from "node:path";
1510
+ import { dirname as dirname3, join as join12, resolve as resolve7 } from "node:path";
1182
1511
  var argv = process.argv.slice(2);
1183
1512
  var command = argv[0];
1184
1513
  function repoRootFromCli() {
1185
- return join9(dirname3(fileURLToPath3(import.meta.url)), "..", "..", "..");
1514
+ return join12(dirname3(fileURLToPath3(import.meta.url)), "..", "..", "..");
1186
1515
  }
1187
1516
  function parseFlag(args, name) {
1188
1517
  const eq = args.find((a) => a.startsWith(`${name}=`));
@@ -1194,6 +1523,20 @@ function parseFlag(args, name) {
1194
1523
  function hasFlag(args, name) {
1195
1524
  return args.includes(name);
1196
1525
  }
1526
+ function positionals(args, flagsWithValue) {
1527
+ const out = [];
1528
+ for (let i = 0; i < args.length; i++) {
1529
+ const a = args[i];
1530
+ if (a.startsWith("--")) {
1531
+ const name = a.split("=")[0];
1532
+ if (flagsWithValue.includes(name) && !a.includes("=")) i++;
1533
+ continue;
1534
+ }
1535
+ if (a.startsWith("-")) continue;
1536
+ out.push(a);
1537
+ }
1538
+ return out;
1539
+ }
1197
1540
  if (command === "gen-schema") {
1198
1541
  const repoRoot = repoRootFromCli();
1199
1542
  const check = argv[1] === "--check";
@@ -1257,6 +1600,22 @@ if (command === "gen-schema") {
1257
1600
  console.error(String(e instanceof Error ? e.message : e));
1258
1601
  process.exit(1);
1259
1602
  }
1603
+ } else if (command === "config" && argv[1] === "gen") {
1604
+ const args = argv.slice(2);
1605
+ const outDir = parseFlag(args, "--out") ?? process.cwd();
1606
+ const s2sps = positionals(args, ["--out"]);
1607
+ if (s2sps.length === 0) {
1608
+ console.error("Usage: s2s config gen <plugin.s2sp...> --out <dir>");
1609
+ process.exit(1);
1610
+ }
1611
+ try {
1612
+ const { written, skipped } = runConfigGen(s2sps, outDir);
1613
+ for (const w of written) console.log(`config gen: wrote ${w}`);
1614
+ for (const s of skipped) console.log(`config gen: ${s} declares no config \u2014 skipped`);
1615
+ } catch (e) {
1616
+ console.error(String(e instanceof Error ? e.message : e));
1617
+ process.exit(1);
1618
+ }
1260
1619
  } else if (command === "build" && argv[1]) {
1261
1620
  const args = argv.slice(1);
1262
1621
  const dir = args.find((a) => !a.startsWith("-"));
@@ -1264,7 +1623,7 @@ if (command === "gen-schema") {
1264
1623
  try {
1265
1624
  const packagesDir = resolvePackagesDir({
1266
1625
  explicit: packagesDirFlag,
1267
- pluginDir: resolve6(dir),
1626
+ pluginDir: resolve7(dir),
1268
1627
  fromCliUrl: import.meta.url
1269
1628
  });
1270
1629
  console.log(await buildPlugin(dir, packagesDir));
@@ -1274,7 +1633,7 @@ if (command === "gen-schema") {
1274
1633
  }
1275
1634
  } else {
1276
1635
  console.error(
1277
- "Usage:\n s2s create [path] [--game cs2|none] [--name <pkg>] [--template minimal]\n [--install npm|pnpm|yarn|bun|none] [--no-install] [-y]\n s2s build <dir> [--packages-dir <path>]\n s2s gen-schema [--check]\n s2s gen-events [--check]\n s2s gen-nav [--check]"
1636
+ "Usage:\n s2s create [path] [--game cs2|none] [--name <pkg>] [--template minimal]\n [--install npm|pnpm|yarn|bun|none] [--no-install] [-y]\n s2s build <dir> [--packages-dir <path>]\n s2s config gen <plugin.s2sp...> --out <dir>\n s2s gen-schema [--check]\n s2s gen-events [--check]\n s2s gen-nav [--check]"
1278
1637
  );
1279
1638
  process.exit(1);
1280
1639
  }