v8scli 0.2.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/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # v8scli
2
+
3
+ CLI (`v8s`) for building and publishing V8 `point_script` mods.
4
+ Companion of the `v8_scripting` SDK.
5
+
6
+ The first call runs through the package name — `npx v8s` would look for a
7
+ package called `v8s`, which does not exist. Inside the scaffolded project the
8
+ CLI is a devDependency, so plain `npx v8s` works from there on:
9
+
10
+ ```bash
11
+ npx v8scli init my_mod # scaffold (inside a project plain `npx v8s` works)
12
+ cd my_mod && npm i
13
+ npx v8s build # bundle + dist/my_mod.vjs_c для теста на своём сервере
14
+ npx v8s login # portal url + api token (dpt_...)
15
+ npx v8s upload # pack sources, create a version on the portal
16
+ npx v8s status # versions + review status
17
+ npx v8s deps # download typings of library dependencies
18
+ ```
19
+
20
+ Rules enforced at build time (same as on the portal):
21
+ only your files and the `v8_scripting` SDK can be imported;
22
+ `cs_script/point_script` is provided by the runtime.
23
+
24
+ Before the npm release the packages are installed from checkouts —
25
+ `init --local` writes the SDK and the CLI into the project as `file:` deps,
26
+ so `npm install` never touches the registry:
27
+
28
+ ```bash
29
+ cd dev_portal/cli && npm install
30
+ cd ~/mods && node /path/to/dev_portal/cli/bin/v8s.js init my_mod --local
31
+ cd my_mod && npm install && npx v8s build
32
+ ```
33
+
34
+ The SDK is looked up next to the CLI checkout (`dev_portal/sdk`);
35
+ override with `V8S_LOCAL_SDK=/path/to/sdk`.
36
+
37
+ Dev portals behind basic auth: pass credentials inside the url on login,
38
+ e.g. `https://user:pass@developer.example.com` — the API token then goes
39
+ in the `X-Api-Token` header automatically.
package/bin/v8s.js ADDED
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ // v8s — CLI разработчика модов. Команды: init, build, login, upload, status, deps.
3
+ import { init } from '../src/commands/init.js';
4
+ import { build } from '../src/commands/build.js';
5
+ import { login } from '../src/commands/login.js';
6
+ import { upload } from '../src/commands/upload.js';
7
+ import { status } from '../src/commands/status.js';
8
+ import { deps } from '../src/commands/deps.js';
9
+ import { pull } from '../src/commands/pull.js';
10
+ import { search } from '../src/commands/search.js';
11
+ import { add, remove, update } from '../src/commands/add.js';
12
+
13
+ const [, , command, ...args] = process.argv;
14
+
15
+ const commands = { init, build, login, upload, status, deps, pull, search, add, remove, update };
16
+
17
+ const usage = `v8s <command>
18
+
19
+ init <slug> [--local] scaffold a new mod in ./<slug> (--local: SDK/CLI from checkouts)
20
+ build bundle the mod locally (self-check, dist/<slug>.js)
21
+ login save portal URL and API token (~/.config/v8s/config.json)
22
+ upload pack sources and upload a new version to the portal
23
+ status show versions and review status of the current mod
24
+ deps download typings of library dependencies (./.v8s_types)
25
+ pull <slug> fetch the latest uploaded sources of your mod from the portal
26
+ search [q] browse published libraries on the portal
27
+ add <slug>[@ver] pin a library release as a dependency (+ typings)
28
+ remove <slug> drop a library from dependencies (+ its typings)
29
+ update [slug] bump pinned library releases to the latest published
30
+ `;
31
+
32
+ if (!command || !commands[command]) {
33
+ console.log(usage);
34
+ process.exit(command ? 1 : 0);
35
+ }
36
+
37
+ try {
38
+ await commands[command](args);
39
+ } catch (error) {
40
+ console.error(`error: ${error?.message ?? error}`);
41
+ process.exit(1);
42
+ }
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "v8scli",
3
+ "version": "0.2.0",
4
+ "description": "CLI for building and publishing V8 point_script mods (companion of v8_scripting)",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "v8s": "bin/v8s.js",
9
+ "v8scli": "bin/v8s.js"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "src",
14
+ "templates",
15
+ "README.md"
16
+ ],
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
20
+ "dependencies": {
21
+ "esbuild": "^0.25.9",
22
+ "tar": "^7.4.3"
23
+ }
24
+ }
@@ -0,0 +1,114 @@
1
+ import { readFile, writeFile, rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ import { api, readConfig, readManifest } from '../common.js';
5
+ import { deps } from './deps.js';
6
+
7
+ /**
8
+ * v8s add <slug>[@version] — подключить библиотеку точным пином релиза.
9
+ * Без версии берётся последний published; typings скачиваются сразу.
10
+ *
11
+ * v8s update [slug] — поднять пин(ы) до последнего published-релиза.
12
+ */
13
+
14
+ async function resolveLibrary(config, slug) {
15
+ const { libraries } = await api(
16
+ config, `/api/registry/libraries?query=${encodeURIComponent(slug)}`);
17
+ const library = libraries.find((lib) => lib.slug === slug);
18
+ if (!library) throw new Error(`library "${slug}" not found on the portal`);
19
+ return library;
20
+ }
21
+
22
+ async function writeDependency(slug, version) {
23
+ const raw = JSON.parse(await readFile('mod.json', 'utf-8'));
24
+ const dependencies = raw.dependencies || [];
25
+
26
+ const next = `${slug}@${version}`;
27
+ const index = dependencies.findIndex((dep) => dep.split('@')[0] === slug);
28
+ const previous = index >= 0 ? dependencies[index] : null;
29
+
30
+ if (index >= 0) dependencies[index] = next;
31
+ else dependencies.push(next);
32
+
33
+ raw.dependencies = dependencies;
34
+ await writeFile('mod.json', `${JSON.stringify(raw, null, 2)}\n`);
35
+
36
+ return previous;
37
+ }
38
+
39
+ export async function add(args) {
40
+ const spec = args[0];
41
+ if (!spec) throw new Error('usage: v8s add <slug>[@version]');
42
+
43
+ const [slug, requested] = spec.split('@');
44
+ if (!/^[a-z][a-z0-9_]{2,31}$/.test(slug)) throw new Error(`invalid slug "${slug}"`);
45
+
46
+ const config = await readConfig();
47
+ const manifest = await readManifest();
48
+ if (manifest.slug === slug) throw new Error('a mod cannot depend on itself');
49
+
50
+ const library = await resolveLibrary(config, slug);
51
+
52
+ const version = requested || library.latest;
53
+ if (!library.versions.includes(version))
54
+ throw new Error(
55
+ `library "${slug}" has no release ${version} (available: ${library.versions.join(', ')})`);
56
+
57
+ const previous = await writeDependency(slug, version);
58
+ console.log(previous
59
+ ? `updated: ${previous} -> ${slug}@${version}`
60
+ : `added: ${slug}@${version}`);
61
+
62
+ await deps();
63
+ }
64
+
65
+ export async function remove(args) {
66
+ const slug = args[0];
67
+ if (!slug) throw new Error('usage: v8s remove <slug>');
68
+
69
+ const raw = JSON.parse(await readFile('mod.json', 'utf-8'));
70
+ const dependencies = raw.dependencies || [];
71
+ const index = dependencies.findIndex((dep) => dep.split('@')[0] === slug);
72
+ if (index < 0) throw new Error(`"${slug}" is not in mod.json dependencies`);
73
+
74
+ const removed = dependencies.splice(index, 1)[0];
75
+ raw.dependencies = dependencies;
76
+ await writeFile('mod.json', `${JSON.stringify(raw, null, 2)}\n`);
77
+
78
+ // тайпинги больше не нужны
79
+ await rm(join('.v8s_types', `${slug}.d.ts`), { force: true });
80
+
81
+ console.log(`removed: ${removed}`);
82
+ }
83
+
84
+ export async function update(args) {
85
+ const only = args[0] || null;
86
+
87
+ const config = await readConfig();
88
+ const manifest = await readManifest();
89
+ const dependencies = manifest.dependencies || [];
90
+
91
+ if (!dependencies.length) {
92
+ console.log('no dependencies in mod.json');
93
+ return;
94
+ }
95
+
96
+ let changed = 0;
97
+
98
+ for (const dep of dependencies) {
99
+ const slug = dep.split('@')[0];
100
+ if (only && slug !== only) continue;
101
+
102
+ const library = await resolveLibrary(config, slug);
103
+ const previous = await writeDependency(slug, library.latest);
104
+
105
+ if (previous !== `${slug}@${library.latest}`) {
106
+ console.log(`updated: ${previous} -> ${slug}@${library.latest}`);
107
+ changed += 1;
108
+ } else {
109
+ console.log(`up to date: ${slug}@${library.latest}`);
110
+ }
111
+ }
112
+
113
+ if (changed) await deps();
114
+ }
@@ -0,0 +1,53 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { readManifest } from '../common.js';
4
+
5
+ // Локальная самопроверка: тот же контракт, что на портале —
6
+ // только файлы мода + SDK, cs_script/point_script остаётся external.
7
+ export async function build() {
8
+ const esbuild = await import('esbuild');
9
+ const manifest = await readManifest();
10
+ const disallowed = new Set();
11
+
12
+ const result = await esbuild.build({
13
+ entryPoints: [manifest.entry],
14
+ bundle: true,
15
+ write: false,
16
+ format: 'esm',
17
+ target: 'es2020',
18
+ platform: 'neutral',
19
+ logLevel: 'warning',
20
+ plugins: [{
21
+ name: 'import-guard',
22
+ setup(build) {
23
+ build.onResolve({ filter: /.*/ }, (args) => {
24
+ if (args.path === 'cs_script/point_script') return { path: args.path, external: true };
25
+ if (args.path.startsWith('.') || args.path.startsWith('/')) return null;
26
+ if (args.path === 'v8_scripting' || args.path.startsWith('v8_scripting/')) return null; // SDK из node_modules
27
+ disallowed.add(args.path);
28
+ return { path: args.path, external: true };
29
+ });
30
+ },
31
+ }],
32
+ });
33
+
34
+ if (disallowed.size)
35
+ throw new Error(`disallowed imports: ${[...disallowed].join(', ')} — only mod files and the v8_scripting SDK are allowed`);
36
+
37
+ await mkdir('dist', { recursive: true });
38
+
39
+ const bundle = Buffer.from(result.outputFiles[0].contents);
40
+ const jsOut = join('dist', `${manifest.slug}.js`);
41
+ await writeFile(jsOut, bundle);
42
+
43
+ // .vjs_c рядом с бандлом: его можно сразу положить на свой сервер в
44
+ // addons/scripts_dev и проверить мод, не дожидаясь сборки на портале
45
+ const { buildVjsC } = await import('../vjs.js');
46
+ const resource = buildVjsC(bundle);
47
+ const resourceOut = join('dist', `${manifest.slug}.vjs_c`);
48
+ await writeFile(resourceOut, resource);
49
+
50
+ console.log(`ok: ${jsOut} (${bundle.length} bytes)`);
51
+ console.log(`ok: ${resourceOut} (${resource.length} bytes) — для локального теста: положить в game/csgo/addons/scripts_dev/`);
52
+ console.log('релизный артефакт портал собирает сам из исходников');
53
+ }
@@ -0,0 +1,26 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { api, readConfig, readManifest } from '../common.js';
4
+
5
+ // Скачивает typings модов-библиотек из dependencies в ./.v8s_types/<slug>.d.ts.
6
+ // Рантайм-объект библиотеки берётся через Instance.GetInterface("<slug>") —
7
+ // typings дают только типы для import type.
8
+ export async function deps() {
9
+ const config = await readConfig();
10
+ const manifest = await readManifest();
11
+ const dependencies = manifest.dependencies ?? [];
12
+ if (!dependencies.length) {
13
+ console.log('no dependencies in mod.json');
14
+ return;
15
+ }
16
+
17
+ await mkdir('.v8s_types', { recursive: true });
18
+ for (const dep of dependencies) {
19
+ const [slug] = dep.split('@');
20
+ const text = await api(config, `/api/registry/typings/${slug}/latest`);
21
+ const content = typeof text === 'string' ? text : text.raw;
22
+ await writeFile(join('.v8s_types', `${slug}.d.ts`), content);
23
+ console.log(`.v8s_types/${slug}.d.ts`);
24
+ }
25
+ console.log('add ".v8s_types" to tsconfig "include" (template already has it)');
26
+ }
@@ -0,0 +1,61 @@
1
+ import { cp, mkdir, readFile, realpath, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const CLI_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
7
+ const TEMPLATES = join(CLI_ROOT, 'templates');
8
+
9
+ /**
10
+ * Пути до локальных чекаутов SDK и CLI для режима --local (пакеты ещё не в npm).
11
+ * SDK ищем в V8S_LOCAL_SDK, иначе рядом с CLI (dev_portal/sdk).
12
+ */
13
+ async function resolveLocalPackages() {
14
+ const sdk = process.env.V8S_LOCAL_SDK ?? join(CLI_ROOT, '..', 'sdk');
15
+ if (!existsSync(join(sdk, 'package.json')))
16
+ throw new Error(
17
+ `--local: SDK not found at ${sdk} — pass the checkout path via V8S_LOCAL_SDK=/path/to/dev_portal/sdk`,
18
+ );
19
+ return { sdk: await realpath(sdk), cli: await realpath(CLI_ROOT) };
20
+ }
21
+
22
+ export async function init(args) {
23
+ const isLocal = args.includes('--local');
24
+ const slug = args.find((arg) => !arg.startsWith('-'));
25
+ if (!slug || !/^[a-z][a-z0-9_]{2,31}$/.test(slug))
26
+ throw new Error('usage: v8s init <slug> [--local] (slug: [a-z][a-z0-9_]{2,31})');
27
+
28
+ const local = isLocal ? await resolveLocalPackages() : null;
29
+
30
+ const dir = join(process.cwd(), slug);
31
+ if (existsSync(dir)) throw new Error(`directory ${slug} already exists`);
32
+
33
+ await mkdir(dir, { recursive: true });
34
+ await cp(TEMPLATES, dir, { recursive: true });
35
+
36
+ // подстановка slug в шаблоны
37
+ for (const file of ['mod.json', 'package.json']) {
38
+ const path = join(dir, file);
39
+ await writeFile(path, (await readFile(path, 'utf-8')).replaceAll('__SLUG__', slug));
40
+ }
41
+
42
+ // локальный режим: SDK и CLI ставятся из чекаутов (file:), реестр не нужен
43
+ if (local) {
44
+ const path = join(dir, 'package.json');
45
+ const manifest = JSON.parse(await readFile(path, 'utf-8'));
46
+ manifest.devDependencies = {
47
+ ...manifest.devDependencies,
48
+ v8_scripting: `file:${local.sdk}`,
49
+ v8scli: `file:${local.cli}`,
50
+ };
51
+ await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`);
52
+ }
53
+
54
+ console.log(`created ./${slug}${local ? ' (local SDK/CLI from checkouts)' : ''}
55
+ next steps:
56
+ cd ${slug}
57
+ npm install
58
+ npx v8s build # self-check bundle
59
+ npx v8s login # portal url + api token
60
+ npx v8s upload # create a version on the portal`);
61
+ }
@@ -0,0 +1,28 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { api, readConfig, writeConfig, CONFIG_PATH } from '../common.js';
3
+
4
+ // v8s login [portalUrl] [token] — с аргументами работает без интерактива (CI-friendly).
5
+ export async function login(args = []) {
6
+ const config = await readConfig();
7
+ let [portalUrl, token] = args;
8
+
9
+ if (!portalUrl || !token) {
10
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
11
+ try {
12
+ portalUrl =
13
+ portalUrl ||
14
+ (await rl.question(`portal url [${config.portalUrl ?? 'https://developer.xplay.gg'}]: `)).trim() ||
15
+ config.portalUrl || 'https://developer.xplay.gg';
16
+ token = token || (await rl.question('api token (portal -> API Tokens): ')).trim();
17
+ } finally {
18
+ rl.close();
19
+ }
20
+ }
21
+
22
+ if (!token?.startsWith('dpt_')) throw new Error('token must start with dpt_');
23
+
24
+ const next = { portalUrl, token };
25
+ await api(next, '/api/registry/health'); // живость портала (и basic auth из url)
26
+ await writeConfig(next);
27
+ console.log(`saved to ${CONFIG_PATH}`);
28
+ }
@@ -0,0 +1,67 @@
1
+ import { mkdir, readFile, writeFile, cp } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ import { api, apiBinary, readConfig } from '../common.js';
7
+
8
+ const TEMPLATES = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates');
9
+
10
+ /**
11
+ * v8s pull <slug> — скачивает исходники последней версии мода с портала.
12
+ *
13
+ * В папке существующего мода (mod.json с тем же slug) обновляет исходники
14
+ * на месте; иначе создаёт ./<slug> с полной обвязкой проекта (package.json,
15
+ * tsconfig из шаблона) — «git clone» для мода одной командой.
16
+ */
17
+ export async function pull(args) {
18
+ const slug = args[0];
19
+ if (!slug || !/^[a-z][a-z0-9_]{2,31}$/.test(slug))
20
+ throw new Error('usage: v8s pull <slug>');
21
+
22
+ const config = await readConfig();
23
+
24
+ const { mods } = await api(config, '/api/mods/my');
25
+ const mod = mods.find((m) => m.slug === slug);
26
+ if (!mod) throw new Error(`mod "${slug}" not found among your mods on the portal`);
27
+
28
+ const { versions } = await api(config, `/api/mods/${mod.id}`);
29
+ if (!versions.length) throw new Error(`mod "${slug}" has no uploaded versions yet`);
30
+ const latest = versions[0];
31
+
32
+ const archive = await apiBinary(
33
+ config, `/api/mods/${mod.id}/versions/${latest.id}/sources`);
34
+
35
+ // куда разворачивать: текущая папка, если это уже этот мод, иначе ./<slug>
36
+ let target = process.cwd();
37
+ let created = false;
38
+
39
+ const localManifest = join(target, 'mod.json');
40
+ const isSameMod = existsSync(localManifest)
41
+ && JSON.parse(await readFile(localManifest, 'utf-8')).slug === slug;
42
+
43
+ if (!isSameMod) {
44
+ target = join(process.cwd(), slug);
45
+ if (existsSync(target)) throw new Error(`directory ${slug} already exists`);
46
+ await mkdir(target, { recursive: true });
47
+ await cp(TEMPLATES, target, { recursive: true });
48
+
49
+ const pkgPath = join(target, 'package.json');
50
+ await writeFile(pkgPath,
51
+ (await readFile(pkgPath, 'utf-8')).replaceAll('__SLUG__', slug));
52
+ created = true;
53
+ }
54
+
55
+ const tar = await import('tar');
56
+ await new Promise((resolve, reject) => {
57
+ const stream = tar.extract({ cwd: target, gzip: true });
58
+ stream.on('error', reject);
59
+ stream.on('finish', resolve);
60
+ stream.end(archive);
61
+ });
62
+
63
+ console.log(`pulled ${slug}@${latest.version} -> ${created ? `./${slug}` : '.'}`);
64
+ if (created) console.log(`next: cd ${slug} && npm install`);
65
+ if ((JSON.parse(await readFile(join(target, 'mod.json'), 'utf-8')).dependencies || []).length)
66
+ console.log('dependencies present — run `npx v8s deps` to fetch typings');
67
+ }
@@ -0,0 +1,20 @@
1
+ import { api, readConfig } from '../common.js';
2
+
3
+ /** v8s search [query] — публичный каталог библиотек портала */
4
+ export async function search(args) {
5
+ const config = await readConfig();
6
+ const query = encodeURIComponent(args.join(' ').trim());
7
+
8
+ const { libraries } = await api(config, `/api/registry/libraries?query=${query}`);
9
+
10
+ if (!libraries.length) {
11
+ console.log('no libraries found');
12
+ return;
13
+ }
14
+
15
+ for (const lib of libraries) {
16
+ console.log(`${lib.slug}@${lib.latest} ${lib.name} — ${lib.description || 'no description'}`);
17
+ console.log(` by ${lib.ownerNickname || 'unknown'} | versions: ${lib.versions.join(', ')}`);
18
+ console.log(` add: npx v8s add ${lib.slug}`);
19
+ }
20
+ }
@@ -0,0 +1,17 @@
1
+ import { api, readConfig, readManifest } from '../common.js';
2
+
3
+ export async function status() {
4
+ const config = await readConfig();
5
+ const manifest = await readManifest();
6
+
7
+ const { mods } = await api(config, '/api/mods/my');
8
+ const mod = mods.find((m) => m.slug === manifest.slug);
9
+ if (!mod) throw new Error(`mod "${manifest.slug}" not found on the portal`);
10
+
11
+ const { versions } = await api(config, `/api/mods/${mod.id}`);
12
+ console.log(`${mod.slug} (#${mod.id}) — ${mod.status}${mod.isLibrary ? ', library' : ''}`);
13
+ for (const v of versions) {
14
+ const comment = v.reviewComment ? ` # ${v.reviewComment}` : '';
15
+ console.log(` ${v.version.padEnd(10)} ${v.status}${comment}`);
16
+ }
17
+ }
@@ -0,0 +1,70 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { api, readConfig, readManifest } from '../common.js';
3
+
4
+ // Пакует исходники (mod.json + src/ + typings) и создаёт версию на портале.
5
+ // Финальный артефакт собирает сам портал — загружаются только исходники.
6
+ async function packSources(manifest) {
7
+ const tar = await import('tar');
8
+
9
+ const files = ['mod.json', 'src'];
10
+ if (manifest.typings) files.push(manifest.typings);
11
+
12
+ const chunks = [];
13
+ await new Promise((resolve, reject) => {
14
+ tar.create({ gzip: true, cwd: process.cwd(), portable: true }, files)
15
+ .on('data', (chunk) => chunks.push(chunk))
16
+ .on('end', resolve)
17
+ .on('error', reject);
18
+ });
19
+
20
+ const archive = Buffer.concat(chunks);
21
+ console.log(`packed ${files.join(', ')} -> ${archive.length} bytes`);
22
+
23
+ return archive;
24
+ }
25
+
26
+ // Портал требует версию строго выше последней загруженной. Если локальная не выше
27
+ // (забыли поднять после прошлой загрузки) — поднимаем patch сами и пишем в mod.json.
28
+ async function bumpVersion(manifest, lastVersion) {
29
+ const [major, minor, patch] = lastVersion.split('.').map((part) => parseInt(part, 10));
30
+ const next = `${major}.${minor}.${patch + 1}`;
31
+
32
+ const raw = JSON.parse(await readFile('mod.json', 'utf-8'));
33
+ raw.version = next;
34
+ await writeFile('mod.json', `${JSON.stringify(raw, null, 2)}\n`);
35
+ manifest.version = next;
36
+
37
+ console.log(`version ${lastVersion} is already on the portal — bumped mod.json to ${next}`);
38
+ }
39
+
40
+ export async function upload() {
41
+ const config = await readConfig();
42
+ const manifest = await readManifest();
43
+
44
+ // modId по slug
45
+ const { mods } = await api(config, '/api/mods/my');
46
+ const mod = mods.find((m) => m.slug === manifest.slug);
47
+ if (!mod)
48
+ throw new Error(`mod "${manifest.slug}" not found on the portal — create it in the web UI first`);
49
+
50
+ for (let attempt = 0; attempt < 2; attempt++) {
51
+ const archive = await packSources(manifest);
52
+
53
+ const form = new FormData();
54
+ form.append('file', new Blob([archive], { type: 'application/gzip' }), `${manifest.slug}.tar.gz`);
55
+
56
+ try {
57
+ const { version } = await api(config, `/api/mods/${mod.id}/versions/upload`, {
58
+ method: 'POST',
59
+ body: form,
60
+ });
61
+ console.log(`uploaded: ${manifest.slug}@${version.version} status=${version.status}`);
62
+ console.log('next: submit it for review from the web UI or wait for CLI action support');
63
+ return;
64
+ } catch (error) {
65
+ const match = String(error?.message ?? '').match(/version_not_incremented: last is (\d+\.\d+\.\d+)/);
66
+ if (!match || attempt > 0) throw error;
67
+ await bumpVersion(manifest, match[1]);
68
+ }
69
+ }
70
+ }
package/src/common.js ADDED
@@ -0,0 +1,77 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ export const CONFIG_DIR = join(homedir(), '.config', 'v8s');
6
+ export const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
7
+
8
+ export async function readConfig() {
9
+ try {
10
+ return JSON.parse(await readFile(CONFIG_PATH, 'utf-8'));
11
+ } catch {
12
+ return {};
13
+ }
14
+ }
15
+
16
+ export async function writeConfig(config) {
17
+ await mkdir(CONFIG_DIR, { recursive: true });
18
+ await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2));
19
+ }
20
+
21
+ export async function readManifest(dir = process.cwd()) {
22
+ let raw;
23
+ try {
24
+ raw = await readFile(join(dir, 'mod.json'), 'utf-8');
25
+ } catch {
26
+ throw new Error('mod.json not found — run from the mod root (or v8s init first)');
27
+ }
28
+ const manifest = JSON.parse(raw);
29
+ if (!manifest.slug || !manifest.entry) throw new Error('mod.json must contain slug and entry');
30
+ return manifest;
31
+ }
32
+
33
+ /**
34
+ * Авторизованный запрос к порталу.
35
+ * API-токен уходит в X-Api-Token: заголовок Authorization может быть занят
36
+ * basic auth стенда (дев за traefik) — его CLI берёт из user:pass в portalUrl.
37
+ */
38
+ /** Как api(), но возвращает Buffer — для скачивания архивов исходников */
39
+ export async function apiBinary(config, path) {
40
+ if (!config.portalUrl) throw new Error('not logged in — run `v8s login` first');
41
+ const url = new URL(config.portalUrl);
42
+ const headers = {};
43
+ if (config.token) headers['X-Api-Token'] = config.token;
44
+ if (url.username) {
45
+ headers.Authorization =
46
+ 'Basic ' + Buffer.from(`${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`).toString('base64');
47
+ url.username = '';
48
+ url.password = '';
49
+ }
50
+ const res = await fetch(new URL(path, url), { headers });
51
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
52
+ return Buffer.from(await res.arrayBuffer());
53
+ }
54
+
55
+ export async function api(config, path, options = {}) {
56
+ if (!config.portalUrl) throw new Error('not logged in — run `v8s login` first');
57
+ const url = new URL(config.portalUrl);
58
+ const headers = { ...(options.headers ?? {}) };
59
+ if (config.token) headers['X-Api-Token'] = config.token;
60
+ if (url.username) {
61
+ headers.Authorization =
62
+ 'Basic ' + Buffer.from(`${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`).toString('base64');
63
+ url.username = '';
64
+ url.password = '';
65
+ }
66
+ const target = new URL(path, url);
67
+ const res = await fetch(target, { ...options, headers });
68
+ const text = await res.text();
69
+ let body;
70
+ try {
71
+ body = JSON.parse(text);
72
+ } catch {
73
+ body = { raw: text };
74
+ }
75
+ if (!res.ok) throw new Error(body?.message ?? `${res.status} ${res.statusText}`);
76
+ return body;
77
+ }
package/src/vjs.js ADDED
@@ -0,0 +1,80 @@
1
+ // Упаковка JS в ресурс .vjs_c — копия портального src/versions/vjs.ts (единственное
2
+ // место знания о формате). Нужна для локальных тестов на своём сервере: релизный
3
+ // артефакт всё равно собирает портал из исходников.
4
+ const RED2_HEX ='0533564b7c161274e9069846aff2e63eb59037e7010000000000004071020000' +
5
+ '010000000000000027000000070007008f0300008e0200000000000000000000' +
6
+ '000000000000000078020000cd01000017010000c10000000300000000000000' +
7
+ '330000000000000024000000070000000700000009000000f02a6d5f496e7075' +
8
+ '74446570656e64656e63696573006d5f52656c617469766546696c656e616d65' +
9
+ '00736372697074732f6b616b6173686b612e6a2700f01a536561726368506174' +
10
+ '68006373676f5f6164646f6e732f62686f705f656d657661656c7833006d5f6e' +
11
+ '4800f000435243006d5f624f7074696f6e616c0c00001700604578697374730e' +
12
+ '0051497347616d73000e6f001176700042416464693e000fb500017c41726775' +
13
+ '6d656e1700a0506172616d657465724ec900b25f5f5f4f766572726964654400' +
14
+ '686174615f5f5f2800f203547970650042696e617279426c6f62417267c3009b' +
15
+ '6e6765727072696e740f007044656661756c7410016b70656369616c8100c153' +
16
+ '7472696e67004a61766153d800f40520436f6d70696c65722056657273696f6e' +
17
+ '006d5f1300b34964656e7469666965720013000638000078004055736572b400' +
18
+ 'f0070050616e6f72616d612050726570726f636573736f720d006673656e6365' +
19
+ '2f5a0006350101d601206564620110737300f00068696c645265736f75726365' +
20
+ '4c69736000b55765616b5265666572656e140002e6014561626c657d00294973' +
21
+ '3d0000250075756261737365743d001673150000220121696e85009073000000' +
22
+ '0020000000530a00000006040017040400930100000002010200000c00000a00' +
23
+ '13031c0080050000004b0120b6340053070000000824001b092400082000220a' +
24
+ '000100f31e0b0000000c0000000d0000000e0000000f00000010000000110000' +
25
+ '0012000000130000001400000015000000161c0000500013171c001b181c0000' +
26
+ '180013195c00131a0800131b0800d01c0000001d0000001e0000001fa000f000' +
27
+ '180906060c0e0d0e06060f0d0e0e080f00210f0f1500100f0a00b0080808090f' +
28
+ '010100ddeeff';
29
+
30
+ const RED2_BUFFER = Buffer.from(RED2_HEX, 'hex');
31
+ const HEADER_VERSION = 12;
32
+ const RESOURCE_VERSION = 4;
33
+
34
+ export function buildVjsC(jsBuffer) {
35
+ const blocks = [
36
+ { type: 'RED2', data: RED2_BUFFER },
37
+ { type: 'DATA', data: jsBuffer },
38
+ ];
39
+
40
+ const headerSize = 4 + 2 + 2 + 4 + 4;
41
+ const blockMetaSize = 12 * blocks.length;
42
+ const totalHeaderSize = headerSize + blockMetaSize;
43
+
44
+ const header = Buffer.alloc(totalHeaderSize);
45
+ header.writeUInt32LE(0, 0); // fileSize, патчится в конце
46
+ header.writeUInt16LE(HEADER_VERSION, 4);
47
+ header.writeUInt16LE(RESOURCE_VERSION, 6);
48
+ header.writeUInt32LE(8, 8);
49
+ header.writeUInt32LE(blocks.length, 12);
50
+
51
+ for (let i = 0; i < blocks.length; i++) {
52
+ const pos = headerSize + i * 12;
53
+ header.write(blocks[i].type, pos, 4, 'ascii');
54
+ header.writeUInt32LE(0xdeadbeef, pos + 4);
55
+ header.writeUInt32LE(0xdeadbeef, pos + 8);
56
+ }
57
+
58
+ const blocksStart = headerSize + 4;
59
+ let currentPos = totalHeaderSize;
60
+ const chunks = [];
61
+
62
+ for (let i = 0; i < blocks.length; i++) {
63
+ const blockData = blocks[i].data;
64
+ const pad = (16 - (currentPos % 16)) % 16; // выравнивание как в Resource.Serialize
65
+ if (pad > 0) {
66
+ chunks.push(Buffer.alloc(pad, 0));
67
+ currentPos += pad;
68
+ }
69
+ const blockOffset = currentPos;
70
+ chunks.push(blockData);
71
+ currentPos += blockData.length;
72
+
73
+ const blockMetaOffset = blocksStart + i * 12;
74
+ header.writeUInt32LE(blockOffset - blockMetaOffset, blockMetaOffset);
75
+ header.writeUInt32LE(blockData.length, blockMetaOffset + 4);
76
+ }
77
+
78
+ header.writeUInt32LE(currentPos, 0);
79
+ return Buffer.concat([header, ...chunks], currentPos);
80
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "slug": "__SLUG__",
3
+ "name": "__SLUG__",
4
+ "description": "",
5
+ "version": "0.1.0",
6
+ "apiVersion": 1,
7
+ "entry": "src/index.ts",
8
+ "environments": ["lobby"],
9
+ "sessionTypes": [],
10
+ "settings": [],
11
+ "dependencies": [],
12
+ "conflicts": [],
13
+ "capabilities": []
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "mod-__SLUG__",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "build": "v8s build",
7
+ "upload": "v8s upload"
8
+ },
9
+ "devDependencies": {
10
+ "v8_scripting": "^0.2.1",
11
+ "v8scli": "^0.2.0",
12
+ "typescript": "^5.9.2"
13
+ }
14
+ }
@@ -0,0 +1,26 @@
1
+ import { Instance } from "cs_script/point_script";
2
+ import { runScheduler, setInterval } from "v8_scripting/scheduler";
3
+
4
+ // Конфиг мода: настройки из лобби (ключи из mod.json settings[]).
5
+ // До запуска через платформу GetModConfig может отсутствовать — работаем на дефолтах.
6
+ const config = Instance.GetModConfig?.();
7
+ Instance.Msg(`mod started, environment: ${config?.environment ?? "dev"}`);
8
+
9
+ // В рантайме нет своего цикла событий: таймеры и отложенные вызовы двигаются
10
+ // отсюда. Без этой строки setInterval/setTimeout/nextFrame молча не сработают.
11
+ Instance.OnGameFrame(() => runScheduler());
12
+
13
+ // У ботов (и в момент подключения до создания контроллера) player приходит
14
+ // undefined, хотя движковые типы обещают его всегда — проверяйте перед доступом,
15
+ // иначе колбэк упадёт на каждом заходе бота.
16
+ Instance.OnPlayerConnect(({ player }) => {
17
+ if (!player) {
18
+ return;
19
+ }
20
+
21
+ Instance.PrintToChat(player.GetPlayerSlot(), "Welcome!");
22
+ });
23
+
24
+ setInterval(() => {
25
+ Instance.Msg("heartbeat");
26
+ }, 60_000);
@@ -0,0 +1,11 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "types": ["v8_scripting"]
9
+ },
10
+ "include": ["src", ".v8s_types"]
11
+ }