@dzhechkov/cloudru-hub 0.1.13 → 0.1.15

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/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@dzhechkov/cloudru-hub",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "licenseHold": {
5
5
  "adr": "features/hermes-claude-adaptation/03_adr/001-licence-and-provenance-precondition.md",
6
- "note": "SATISFIED hold (ADR-001 AM-2, grant recorded 2026-08-12 — see grant-record-2026-08-12.md). The field deliberately STAYS: the dz guard `licence-hold` rule passes a satisfied hold with the trigger in place and keeps verifying LICENSE (no PENDING placeholder, Grant-Confirmation URL), THIRD_PARTY_NOTICES and the SPDX license field on every publish. test/pack-holds.test.mjs goes RED if this field is dropped or the LICENSE grant record is degraded."
6
+ "note": "SATISFIED hold. Timur is credited for substantial Cloud.ru API testing. The 2026-10-20 correction is in the historical grant record. This field keeps the dz guard licence-hold rule checking LICENSE, THIRD_PARTY_NOTICES and SPDX on every publish."
7
7
  },
8
8
  "description": "Unofficial Cloud.ru Evolution launcher with a pinned cloud-write-blocking MCP proxy for Codex, Hermes, OpenCode and Pi, Claude Code permissions, and skill dialect compiler. Engine binary resolved at runtime, never bundled. NOT affiliated with Cloud.ru.",
9
9
  "main": "src/cli.js",
@@ -26,7 +26,7 @@
26
26
  ],
27
27
  "cloudruHub": {
28
28
  "binaryHashes": {
29
- "linux-x64": "89c5073418927ee71ac1de381eaba5ab69d599d615edc51b868d876e712dbf10"
29
+ "linux-x64": "4463b09323c9dfe0c32d618d335734a947e7fab0a75d07a7bea089b97a97c9b6"
30
30
  },
31
31
  "engineVersion": "cloudru-vm 0.2.6-20261020",
32
32
  "platformPackages": {
@@ -52,13 +52,16 @@
52
52
  "dependencies": {
53
53
  "yaml": "^2.9.0"
54
54
  },
55
+ "optionalDependencies": {
56
+ "@dzhechkov/cloudru-vm-linux-x64": "0.2.6"
57
+ },
55
58
  "scripts": {
56
59
  "test": "node --test 'test/*.test.mjs'"
57
60
  },
58
61
  "repository": {
59
62
  "type": "git",
60
63
  "url": "git+https://github.com/djd1m/dz-harness.git",
61
- "directory": "packages/@dzhechkov/cloudru-hub"
64
+ "directory": "packages/cloudru-hub"
62
65
  },
63
66
  "homepage": "https://aicoding.space",
64
67
  "bugs": {
package/sbom.json CHANGED
@@ -15,7 +15,7 @@
15
15
  "hashes": [
16
16
  {
17
17
  "alg": "SHA-256",
18
- "content": "d2be06e900ab3d02d367ff5d91129ef3faa9a16e1afac6fba8ae9cec4a51c322"
18
+ "content": "2b0dbd83deb813844a658814b9a6fa1640f1e5255c676210e61dceaa6b42195b"
19
19
  }
20
20
  ]
21
21
  },
@@ -25,7 +25,7 @@
25
25
  "hashes": [
26
26
  {
27
27
  "alg": "SHA-256",
28
- "content": "68da42b10dcc095cdfd7249557cee30285664e5dea00dc85475ff047d975616a"
28
+ "content": "78a14ae697528fa79451ea10725c5ca356f6658378bfb39849b51d05207ab3f3"
29
29
  }
30
30
  ]
31
31
  },
@@ -35,7 +35,7 @@
35
35
  "hashes": [
36
36
  {
37
37
  "alg": "SHA-256",
38
- "content": "c505a9bb7ab6f8d9fbe1cac64e22b12ec14f674f979af7120457b8ba49039484"
38
+ "content": "4f6a2d5928fec42f021eb10471677b902e831b99d53096661a821f6460bd55a6"
39
39
  }
40
40
  ]
41
41
  },
@@ -45,7 +45,7 @@
45
45
  "hashes": [
46
46
  {
47
47
  "alg": "SHA-256",
48
- "content": "109cffdaaf9402e88f6cc35a0497ba49db015b329fce87ba20762f34dc95c051"
48
+ "content": "1986a75d5f90631adea8fb40c9fc182371cb875c9d62d65d07fd479b32c876df"
49
49
  }
50
50
  ]
51
51
  },
@@ -85,7 +85,7 @@
85
85
  "hashes": [
86
86
  {
87
87
  "alg": "SHA-256",
88
- "content": "b1216e31ed28655ea99d9ef06d82d4d35d5499bad1ecadc8e7051e8c74bd7c97"
88
+ "content": "9abb3ca6347b0cd6cd51b23649c59e0a0c816e6c12a325f573b392affa350d0b"
89
89
  }
90
90
  ]
91
91
  },
@@ -95,7 +95,17 @@
95
95
  "hashes": [
96
96
  {
97
97
  "alg": "SHA-256",
98
- "content": "c9f53d02b7ee06791eac22214ca91e999aeec2ee4dbd53b6a27dc46d4e5bcbc2"
98
+ "content": "dff98bb859dfbbf8ed2fb6f16f17b767444cc2172ecc7684ca3df8188c95707c"
99
+ }
100
+ ]
101
+ },
102
+ {
103
+ "type": "file",
104
+ "name": "docs/COST-COMPARE.md",
105
+ "hashes": [
106
+ {
107
+ "alg": "SHA-256",
108
+ "content": "80b4d7ac3868f17d93565274cd6736fe5f2bd7fb059cbed0c2f0afe3f4924317"
99
109
  }
100
110
  ]
101
111
  },
@@ -105,7 +115,7 @@
105
115
  "hashes": [
106
116
  {
107
117
  "alg": "SHA-256",
108
- "content": "08e1e83985c31b51aa053811b1b6a76fb1960323c5ccb8ae1d5fdd8b91b8df11"
118
+ "content": "3605797972f9a7c79c13fbcce0cd9ac8b8fb3a8249abbab938f2b1a45f515420"
109
119
  }
110
120
  ]
111
121
  },
@@ -115,7 +125,17 @@
115
125
  "hashes": [
116
126
  {
117
127
  "alg": "SHA-256",
118
- "content": "f043a370b9f23b2efc6c64702ab358ee168b6c9cd1b5d9efe4a9da287274e42c"
128
+ "content": "de6f709e820fbb831f390e274238b28ba938644670a8db04fd50d1fa0d86da75"
129
+ }
130
+ ]
131
+ },
132
+ {
133
+ "type": "file",
134
+ "name": "docs/cost-compare.example.json",
135
+ "hashes": [
136
+ {
137
+ "alg": "SHA-256",
138
+ "content": "7eafb1ff555fb5443ded1a2c578b17eae6e73e3056efb9cbb5129b424c8c5097"
119
139
  }
120
140
  ]
121
141
  },
@@ -129,7 +149,7 @@
129
149
  },
130
150
  {
131
151
  "name": "dz:canonical-json-sha256-v2",
132
- "value": "671ebb618e9d0a939a047433791cd9a34e502339596d11a697b50fb32a0694ec"
152
+ "value": "1b355df670fb092da7ce8956141e702f9f78e1e2f2724c6aaba61bbab2acd8c8"
133
153
  }
134
154
  ]
135
155
  },
@@ -149,7 +169,17 @@
149
169
  "hashes": [
150
170
  {
151
171
  "alg": "SHA-256",
152
- "content": "af553e9261300eb46240212eb03b01ccc17234fed1a9f1587d5784f3bfacb5f4"
172
+ "content": "b1bd4a0ce7f1ca869c2873ca1979a74630483ee2909ff8b4f91189622f5aa2a7"
173
+ }
174
+ ]
175
+ },
176
+ {
177
+ "type": "file",
178
+ "name": "src/cost-compare.js",
179
+ "hashes": [
180
+ {
181
+ "alg": "SHA-256",
182
+ "content": "6084ecd7f7fc53bbd441fb9e91dcd7c7bd5c409d1d4e95613fad0fd0735b7b05"
153
183
  }
154
184
  ]
155
185
  },
@@ -219,7 +249,7 @@
219
249
  "hashes": [
220
250
  {
221
251
  "alg": "SHA-256",
222
- "content": "5cbd0861c2973fab62271269b10f748b9638e74f40e8ea034f78ac63bed58001"
252
+ "content": "d3eba14c23be5bfb7e73952e6ae27908667d535f91be9c43e11dbf7b7c4de49d"
223
253
  }
224
254
  ]
225
255
  },
@@ -273,6 +303,16 @@
273
303
  }
274
304
  ]
275
305
  },
306
+ {
307
+ "type": "file",
308
+ "name": "test/cost-compare.test.mjs",
309
+ "hashes": [
310
+ {
311
+ "alg": "SHA-256",
312
+ "content": "b354913220bf725ec6b8a708b7851e2f5779a3c594cd411732d19acad9001870"
313
+ }
314
+ ]
315
+ },
276
316
  {
277
317
  "type": "file",
278
318
  "name": "test/deploy-approval.test.mjs",
@@ -349,7 +389,7 @@
349
389
  "hashes": [
350
390
  {
351
391
  "alg": "SHA-256",
352
- "content": "a083ff619f1d97b3e0e1b95dec6e98b56c4296da5171e2eb41c713dd983c8d69"
392
+ "content": "f5e66cce7344efb22c40bba91b422ce84979948b66bb0305377f8c12eb078002"
353
393
  }
354
394
  ]
355
395
  },
@@ -359,7 +399,7 @@
359
399
  "hashes": [
360
400
  {
361
401
  "alg": "SHA-256",
362
- "content": "3b695381c2df4f5b592b377bd3ef5b3f3953af9f6f140600cfcddc975563bffc"
402
+ "content": "9ad83ba418dee86ef680575a0cc7da4af4be3ae995c00f23f84b336838fbda47"
363
403
  }
364
404
  ]
365
405
  },
package/src/cli.js CHANGED
@@ -3,21 +3,27 @@
3
3
  // Zero dependencies, CommonJS, boots from any cwd (dz release smoke contract).
4
4
 
5
5
  const { spawn, spawnSync } = require('child_process');
6
- const { resolveEngine } = require('./resolve');
7
- const { loadClassification, loadSnapshot, coverageGaps } = require('./classification');
8
- const { install } = require('./install');
9
- const { runSafeMcp } = require('./safe-mcp');
10
- const { issueApproval } = require('./deploy-approval');
11
- const { issueK8sApproval } = require('./k8s-approval');
12
- const { compileSkill, writeCompiled } = require('./dialects');
13
6
  const PKG = require('../package.json');
7
+ let resolveEngine, loadClassification, loadSnapshot, coverageGaps, install, runSafeMcp;
8
+ let issueApproval, issueK8sApproval, compileSkill, writeCompiled;
9
+ let legacyLoaded = false;
10
+ function loadLegacyCommands() {
11
+ if (legacyLoaded) return;
12
+ ({ resolveEngine } = require('./resolve'));
13
+ ({ loadClassification, loadSnapshot, coverageGaps } = require('./classification'));
14
+ ({ install } = require('./install'));
15
+ ({ runSafeMcp } = require('./safe-mcp'));
16
+ ({ issueApproval } = require('./deploy-approval'));
17
+ ({ issueK8sApproval } = require('./k8s-approval'));
18
+ ({ compileSkill, writeCompiled } = require('./dialects'));
19
+ legacyLoaded = true;
20
+ }
14
21
 
15
22
  function showHelp(write) {
16
23
  write(`cloudru-hub ${PKG.version} — unofficial Cloud.ru Evolution launcher (NOT affiliated with Cloud.ru)
17
24
 
18
25
  The cloudru-vm engine binary is resolved at RUNTIME, never bundled in this package.
19
- Engine additions authored by Тимур (publication approved — grant record in LICENSE Part 2);
20
- launcher, adapters and CLI foundation by Dmitry Zhechkov (MIT).
26
+ Тимур contributed extensive Cloud.ru API testing and identified API defects.
21
27
 
22
28
  Usage: cloudru-hub <command> [options]
23
29
 
@@ -33,6 +39,8 @@ Commands:
33
39
  Static sites require the artifact digest shown by deploy_plan.
34
40
  k8s-approve --cluster-id <uuid> --namespace <name> --manifest <file> --manifest-sha256 <digest>
35
41
  Authorize one exact three-resource Snake manifest on an existing cluster.
42
+ cost-compare --input <json> [--json]
43
+ Offline VM vs Kubernetes cost worksheet; no engine or credentials.
36
44
  install --target <t> claude-code: permissions + executed hook probe;
37
45
  codex|opencode|pi: project MCP config using mcp-safe;
38
46
  hermes: guarded $HERMES_HOME/config.yaml entry using mcp-safe;
@@ -140,6 +148,9 @@ function cmdMcp() {
140
148
  }
141
149
 
142
150
  function main(argv, write = (s) => process.stdout.write(s + '\n')) {
151
+ // This command validates its raw flags before the permissive legacy parser sees them.
152
+ if (argv[0] === 'cost-compare') return require('./cost-compare').run(argv.slice(1), write);
153
+ loadLegacyCommands();
143
154
  const { flags, opts, positional } = parseArgs(argv);
144
155
  const cmd = positional[0] || 'help';
145
156
  if (flags.has('help') || flags.has('h') || cmd === 'help') { showHelp(write); return 0; }
@@ -0,0 +1,348 @@
1
+ 'use strict';
2
+
3
+ // Offline worksheet. All money and resource divisions use integer decimal arithmetic.
4
+ const fs = require('fs');
5
+
6
+ const MAX_INPUT = 128 * 1024;
7
+ const MAX_MONEY = 10n ** 18n;
8
+ const CATEGORIES = Object.freeze({
9
+ compute: 'instance-hour',
10
+ controlPlane: 'instance-hour',
11
+ storage: 'GB-hour',
12
+ publicIp: 'IP-hour',
13
+ snat: 'gateway-hour',
14
+ loadBalancer: 'LB-hour',
15
+ traffic: 'traffic-GB',
16
+ });
17
+ const TIME_UNITS = new Set(['instance-hour', 'GB-hour', 'IP-hour', 'gateway-hour', 'LB-hour']);
18
+
19
+ function fail(path, message) { throw new Error(`${path}: ${message}`); }
20
+ function record(value, path, keys) {
21
+ if (!value || typeof value !== 'object' || Array.isArray(value)) fail(path, 'must be an object');
22
+ for (const key of Object.keys(value)) if (!keys.includes(key)) fail(`${path}.${key}`, 'unknown field');
23
+ return value;
24
+ }
25
+ function required(obj, key, path) {
26
+ if (!Object.hasOwn(obj, key)) fail(`${path}.${key}`, 'required');
27
+ return obj[key];
28
+ }
29
+ function positiveInteger(value, path, max = 1000000, zero = false) {
30
+ if (!Number.isSafeInteger(value) || value < (zero ? 0 : 1) || value > max) fail(path, `must be an integer from ${zero ? 0 : 1} to ${max}`);
31
+ return value;
32
+ }
33
+ function words(value, path, max = 500) {
34
+ if (typeof value !== 'string' || !value.trim() || value.length > max || /[\x00-\x1f\x7f]/.test(value)) fail(path, 'must be a nonempty bounded single-line string');
35
+ return value;
36
+ }
37
+ function decimal(value, path, { zero = false, max = 1000000000n } = {}) {
38
+ if (typeof value !== 'string' || !/^(?:0|[1-9]\d{0,12})(?:\.\d{1,9})?$/.test(value)) fail(path, 'must be a non-negative decimal string with at most 9 fractional places');
39
+ const [whole, fraction = ''] = value.split('.');
40
+ const coefficient = BigInt(whole + fraction);
41
+ const scale = 10n ** BigInt(fraction.length);
42
+ if ((!zero && coefficient === 0n) || coefficient > max * scale) fail(path, `must be ${zero ? 'non-negative' : 'positive'} and at most ${max}`);
43
+ return { coefficient, scale };
44
+ }
45
+ const integerDecimal = value => ({ coefficient: BigInt(value), scale: 1n });
46
+ const multiply = (a, b) => ({ coefficient: a.coefficient * b.coefficient, scale: a.scale * b.scale });
47
+ const subtract = (a, b) => ({ coefficient: a.coefficient * b.scale - b.coefficient * a.scale, scale: a.scale * b.scale });
48
+ const compare = (a, b) => a.coefficient * b.scale < b.coefficient * a.scale ? -1 : a.coefficient * b.scale > b.coefficient * a.scale ? 1 : 0;
49
+ function add(a, b) { return { coefficient: a.coefficient * b.scale + b.coefficient * a.scale, scale: a.scale * b.scale }; }
50
+ function exact(value) {
51
+ const whole = value.coefficient / value.scale;
52
+ const remainder = value.coefficient % value.scale;
53
+ if (!remainder) return String(whole);
54
+ return `${whole}.${String(remainder).padStart(String(value.scale).length - 1, '0').replace(/0+$/, '')}`;
55
+ }
56
+ function display(value) {
57
+ const cents = (value.coefficient * 200n + value.scale) / (2n * value.scale);
58
+ return `${cents / 100n}.${String(cents % 100n).padStart(2, '0')}`;
59
+ }
60
+ function monetary(value, path) {
61
+ if (value.coefficient < 0n || value.coefficient > MAX_MONEY * value.scale) fail(path, 'amount exceeds supported RUB magnitude');
62
+ return value;
63
+ }
64
+ function slots(usable, perReplica, path) {
65
+ const count = usable.coefficient * perReplica.scale / (usable.scale * perReplica.coefficient);
66
+ if (count > 1000000n) fail(path, 'per-node capacity exceeds supported bound');
67
+ return Number(count);
68
+ }
69
+
70
+ // JSON.parse discards duplicate members and rounds large/fractional numeric tokens.
71
+ // Inspect the bounded original text first, including decoded spellings of object keys.
72
+ function validateJsonLexemes(text) {
73
+ const number = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
74
+ let offset = 0, tokens = 0;
75
+ const bad = message => fail('--input', message);
76
+ const space = () => { while (offset < text.length && /[ \t\r\n]/.test(text[offset])) offset++; };
77
+ const count = () => { if (++tokens > 10000) bad('JSON has too many members or values'); };
78
+ const string = () => {
79
+ const start = offset++;
80
+ while (offset < text.length) {
81
+ if (text[offset] === '\\') { offset += 2; continue; }
82
+ if (text[offset++] === '"') {
83
+ try { return JSON.parse(text.slice(start, offset)); }
84
+ catch { bad('invalid JSON string'); }
85
+ }
86
+ }
87
+ bad('unterminated JSON string');
88
+ };
89
+ const value = depth => {
90
+ count();
91
+ if (depth > 64) bad('JSON nesting exceeds 64 levels');
92
+ space();
93
+ const marker = text[offset];
94
+ if (marker === '{') {
95
+ offset++; space();
96
+ const seen = new Set();
97
+ if (text[offset] === '}') { offset++; return; }
98
+ while (true) {
99
+ if (text[offset] !== '"') bad('object member must be a JSON string');
100
+ count();
101
+ const key = string();
102
+ if (seen.has(key)) bad('duplicate decoded object member');
103
+ seen.add(key);
104
+ space();
105
+ if (text[offset++] !== ':') bad('object member requires a colon');
106
+ value(depth + 1); space();
107
+ const separator = text[offset++];
108
+ if (separator === '}') return;
109
+ if (separator !== ',') bad('object member requires a comma or closing brace');
110
+ space();
111
+ }
112
+ }
113
+ if (marker === '[') {
114
+ offset++; space();
115
+ if (text[offset] === ']') { offset++; return; }
116
+ while (true) {
117
+ value(depth + 1); space();
118
+ const separator = text[offset++];
119
+ if (separator === ']') return;
120
+ if (separator !== ',') bad('array element requires a comma or closing bracket');
121
+ }
122
+ }
123
+ if (marker === '"') { string(); return; }
124
+ for (const literal of ['true', 'false', 'null']) if (text.startsWith(literal, offset)) { offset += literal.length; return; }
125
+ number.lastIndex = offset;
126
+ const match = number.exec(text);
127
+ if (!match) bad('invalid JSON value or number');
128
+ const lexeme = match[0];
129
+ offset = number.lastIndex;
130
+ if (!/^(?:0|[1-9]\d*)$/.test(lexeme) || lexeme.length > 16 || BigInt(lexeme) > BigInt(Number.MAX_SAFE_INTEGER)) {
131
+ bad('numeric tokens must be canonical non-negative safe integers; decimals must be strings');
132
+ }
133
+ };
134
+ space(); value(0); space();
135
+ if (offset !== text.length) bad('trailing or malformed JSON token');
136
+ }
137
+
138
+ function readInput(path) {
139
+ words(path, '--input', 4096);
140
+ const flags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK;
141
+ const fd = fs.openSync(path, flags);
142
+ try {
143
+ const stat = fs.fstatSync(fd);
144
+ if (!stat.isFile() || stat.size > MAX_INPUT) fail('--input', 'must be a regular file of at most 128 KiB');
145
+ const chunks = [], buffer = Buffer.alloc(8192);
146
+ let length = 0, read;
147
+ while ((read = fs.readSync(fd, buffer, 0, Math.min(buffer.length, MAX_INPUT + 1 - length), null)) > 0) {
148
+ chunks.push(Buffer.from(buffer.subarray(0, read)));
149
+ length += read;
150
+ if (length > MAX_INPUT) fail('--input', 'file exceeds 128 KiB');
151
+ }
152
+ const bytes = Buffer.concat(chunks), text = bytes.toString('utf8');
153
+ if (!Buffer.from(text).equals(bytes)) fail('--input', 'must be valid UTF-8');
154
+ validateJsonLexemes(text);
155
+ return JSON.parse(text);
156
+ } finally { fs.closeSync(fd); }
157
+ }
158
+
159
+ function tariff(value, path, vatBasis) {
160
+ record(value, path, ['url', 'version', 'asOf', 'vatBasis', 'pageOrRow', 'sha256']);
161
+ const url = words(required(value, 'url', path), `${path}.url`, 1000);
162
+ if (!/^https:\/\/[^\s/@?#]+(?:[/?#][^\s]*)?$/.test(url)) fail(`${path}.url`, 'must be an HTTPS tariff URL');
163
+ words(required(value, 'version', path), `${path}.version`, 100);
164
+ const asOf = required(value, 'asOf', path);
165
+ if (typeof asOf !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(asOf) || Number.isNaN(Date.parse(`${asOf}T00:00:00Z`)) || new Date(`${asOf}T00:00:00Z`).toISOString().slice(0, 10) !== asOf) fail(`${path}.asOf`, 'must be a valid YYYY-MM-DD date');
166
+ if (required(value, 'vatBasis', path) !== vatBasis) fail(`${path}.vatBasis`, 'must match the common VAT basis');
167
+ words(required(value, 'pageOrRow', path), `${path}.pageOrRow`, 100);
168
+ if (value.sha256 !== undefined && (typeof value.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.sha256))) fail(`${path}.sha256`, 'must be lowercase SHA-256');
169
+ return value;
170
+ }
171
+
172
+ function category(value, path, name, expected, horizon, vatBasis) {
173
+ record(value, path, ['status', 'reason', 'items']);
174
+ const status = required(value, 'status', path);
175
+ if (!['priced', 'not-used', 'unpriced'].includes(status)) fail(`${path}.status`, 'must be priced, not-used or unpriced');
176
+ if (status !== 'priced') {
177
+ if (value.items !== undefined) fail(`${path}.items`, 'allowed only when priced');
178
+ const reason = words(required(value, 'reason', path), `${path}.reason`);
179
+ if (status === 'not-used' && (expected === null || expected.coefficient !== 0n)) fail(path, 'not-used requires a declared zero quantity');
180
+ if (status === 'unpriced' && expected !== null && expected.coefficient === 0n) fail(path, 'declared zero quantity must be not-used');
181
+ return { status, reason, items: [], totalRubExact: null, totalRubDisplay: null };
182
+ }
183
+ if (value.reason !== undefined) fail(`${path}.reason`, 'allowed only when unpriced or not-used');
184
+ if (expected === null || expected.coefficient === 0n) fail(path, 'priced requires a positive declared quantity');
185
+ const items = required(value, 'items', path);
186
+ if (!Array.isArray(items) || !items.length || items.length > 16) fail(`${path}.items`, 'must have 1 to 16 items');
187
+ const seen = new Set();
188
+ let quantitySum = integerDecimal(0), total = integerDecimal(0);
189
+ const result = items.map((item, index) => {
190
+ const at = `${path}.items[${index}]`;
191
+ record(item, at, ['id', 'description', 'quantity', 'unit', 'rateRub', 'tariff']);
192
+ const id = words(required(item, 'id', at), `${at}.id`, 80);
193
+ if (!/^[a-z][a-z0-9-]*$/.test(id) || seen.has(id)) fail(`${at}.id`, 'must be a unique slug');
194
+ seen.add(id);
195
+ words(required(item, 'description', at), `${at}.description`);
196
+ const q = decimal(required(item, 'quantity', at), `${at}.quantity`);
197
+ if (required(item, 'unit', at) !== CATEGORIES[name]) fail(`${at}.unit`, `must be ${CATEGORIES[name]}`);
198
+ if (['compute', 'controlPlane', 'publicIp', 'snat', 'loadBalancer'].includes(name) && q.scale !== 1n) fail(`${at}.quantity`, 'must be an integer service count');
199
+ const rate = decimal(required(item, 'rateRub', at), `${at}.rateRub`);
200
+ tariff(required(item, 'tariff', at), `${at}.tariff`, vatBasis);
201
+ quantitySum = add(quantitySum, q);
202
+ const amount = monetary(multiply(multiply(q, rate), TIME_UNITS.has(item.unit) ? horizon : integerDecimal(1)), `${at}.amount`);
203
+ total = monetary(add(total, amount), `${path}.total`);
204
+ return { ...item, amountRubExact: exact(amount), amountRubDisplay: display(amount), formula: TIME_UNITS.has(item.unit) ? `${item.quantity} ${item.unit.slice(0, -5)} × ${item.rateRub} RUB/${item.unit} × ${exact(horizon)} hours` : `${item.quantity} ${item.unit} × ${item.rateRub} RUB/${item.unit} once` };
205
+ });
206
+ if (compare(quantitySum, expected) !== 0) fail(`${path}.items`, `quantities must sum to declared ${exact(expected)} ${CATEGORIES[name]}`);
207
+ return { status, items: result, totalRubExact: exact(total), totalRubDisplay: display(total), _total: total };
208
+ }
209
+
210
+ function scenario(value, path, workload, horizon, vatBasis) {
211
+ record(value, path, ['id', 'kind', 'nodes', 'controlPlaneCount', 'resources', 'categories']);
212
+ const id = words(required(value, 'id', path), `${path}.id`, 80);
213
+ if (!/^[a-z][a-z0-9-]*$/.test(id)) fail(`${path}.id`, 'must be a slug');
214
+ const kind = required(value, 'kind', path);
215
+ if (!['vm', 'k8s'].includes(kind)) fail(`${path}.kind`, 'must be vm or k8s');
216
+ const node = record(required(value, 'nodes', path), `${path}.nodes`, ['count', 'cpu', 'ramGB', 'reservedCpu', 'reservedRamGB', 'cpuShare']);
217
+ const count = positiveInteger(required(node, 'count', `${path}.nodes`), `${path}.nodes.count`, 1000);
218
+ if (workload.failedNodes > count) fail(`${path}.nodes.count`, 'must be at least the declared failedNodes');
219
+ const cpu = decimal(required(node, 'cpu', `${path}.nodes`), `${path}.nodes.cpu`, { max: 10000n });
220
+ const ram = decimal(required(node, 'ramGB', `${path}.nodes`), `${path}.nodes.ramGB`, { max: 1000000n });
221
+ const share = decimal(required(node, 'cpuShare', `${path}.nodes`), `${path}.nodes.cpuShare`, { max: 1n });
222
+ const reservedCpu = decimal(required(node, 'reservedCpu', `${path}.nodes`), `${path}.nodes.reservedCpu`, { zero: true, max: 10000n });
223
+ const reservedRam = decimal(required(node, 'reservedRamGB', `${path}.nodes`), `${path}.nodes.reservedRamGB`, { zero: true, max: 1000000n });
224
+ const guaranteedCpu = multiply(cpu, share);
225
+ if (compare(reservedCpu, guaranteedCpu) > 0 || compare(reservedRam, ram) > 0) fail(`${path}.nodes`, 'reservations exceed guaranteed CPU or RAM');
226
+ const controlPlaneCount = positiveInteger(required(value, 'controlPlaneCount', path), `${path}.controlPlaneCount`, 1000, kind === 'vm');
227
+ if (kind === 'vm' && controlPlaneCount !== 0) fail(`${path}.controlPlaneCount`, 'VM must declare zero control-plane service instances');
228
+ const resources = record(required(value, 'resources', path), `${path}.resources`, ['storageGB', 'publicIpCount', 'snatGatewayCount', 'loadBalancerCount', 'trafficGB']);
229
+ const storage = decimal(required(resources, 'storageGB', `${path}.resources`), `${path}.resources.storageGB`, { zero: true });
230
+ const publicIp = positiveInteger(required(resources, 'publicIpCount', `${path}.resources`), `${path}.resources.publicIpCount`, 1000000, true);
231
+ const snat = positiveInteger(required(resources, 'snatGatewayCount', `${path}.resources`), `${path}.resources.snatGatewayCount`, 1000000, true);
232
+ const lb = positiveInteger(required(resources, 'loadBalancerCount', `${path}.resources`), `${path}.resources.loadBalancerCount`, 1000000, true);
233
+ const trafficValue = required(resources, 'trafficGB', `${path}.resources`);
234
+ const traffic = trafficValue === null ? null : decimal(trafficValue, `${path}.resources.trafficGB`, { zero: true });
235
+ const expected = { compute: integerDecimal(count), controlPlane: integerDecimal(controlPlaneCount), storage, publicIp: integerDecimal(publicIp), snat: integerDecimal(snat), loadBalancer: integerDecimal(lb), traffic };
236
+ const raw = record(required(value, 'categories', path), `${path}.categories`, Object.keys(CATEGORIES));
237
+ const categories = {}, unpriced = [];
238
+ let known = integerDecimal(0);
239
+ for (const name of Object.keys(CATEGORIES)) {
240
+ const result = category(required(raw, name, `${path}.categories`), `${path}.categories.${name}`, name, expected[name], horizon, vatBasis);
241
+ if (result._total) { known = monetary(add(known, result._total), `${path}.knownSubtotal`); delete result._total; }
242
+ if (result.status === 'unpriced') unpriced.push({ category: name, reason: result.reason });
243
+ categories[name] = result;
244
+ }
245
+ const perNodeCpu = slots(subtract(guaranteedCpu, reservedCpu), workload.cpu, `${path}.nodes.cpu`);
246
+ const perNodeRam = slots(subtract(ram, reservedRam), workload.ram, `${path}.nodes.ramGB`);
247
+ const perNodeSlots = Math.min(perNodeCpu, perNodeRam);
248
+ const normalSlots = count * perNodeSlots;
249
+ const survivingNodes = Math.max(0, count - workload.failedNodes);
250
+ const survivingSlots = survivingNodes * perNodeSlots;
251
+ const feasible = normalSlots >= workload.replicas && survivingSlots >= workload.survivingReplicas;
252
+ return {
253
+ id, kind, nodes: node, controlPlaneCount, resources, categories,
254
+ capacity: { perNodeCpuSlots: perNodeCpu, perNodeRamSlots: perNodeRam, perNodeSlots, normalSlots, survivingNodes, survivingSlots, feasible,
255
+ formula: 'usable guaranteed CPU = cpu × cpuShare − reservedCpu; usable RAM = ramGB − reservedRamGB; per-node slots = min(floor(usable CPU / cpuPerReplica), floor(usable RAM / ramGBPerReplica)); normal/surviving slots = per-node slots × available nodes' },
256
+ unpriced, complete: unpriced.length === 0, knownSubtotalRubExact: exact(known), knownSubtotalRubDisplay: display(known), _known: known,
257
+ };
258
+ }
259
+
260
+ function calculate(input) {
261
+ record(input, 'input', ['schemaVersion', 'horizonHours', 'currency', 'vatBasis', 'workload', 'equivalence', 'scenarios']);
262
+ if (required(input, 'schemaVersion', 'input') !== 1) fail('input.schemaVersion', 'must be 1');
263
+ const horizon = decimal(required(input, 'horizonHours', 'input'), 'input.horizonHours', { max: 8760n });
264
+ if (required(input, 'currency', 'input') !== 'RUB') fail('input.currency', 'must be RUB');
265
+ const vatBasis = required(input, 'vatBasis', 'input');
266
+ if (vatBasis !== 'included') fail('input.vatBasis', 'must be included');
267
+ const rawWorkload = record(required(input, 'workload', 'input'), 'input.workload', ['replicas', 'cpuPerReplica', 'ramGBPerReplica', 'survivingReplicas', 'failedNodes']);
268
+ const workload = {
269
+ replicas: positiveInteger(required(rawWorkload, 'replicas', 'input.workload'), 'input.workload.replicas'),
270
+ cpu: decimal(required(rawWorkload, 'cpuPerReplica', 'input.workload'), 'input.workload.cpuPerReplica', { max: 10000n }),
271
+ ram: decimal(required(rawWorkload, 'ramGBPerReplica', 'input.workload'), 'input.workload.ramGBPerReplica', { max: 1000000n }),
272
+ survivingReplicas: positiveInteger(required(rawWorkload, 'survivingReplicas', 'input.workload'), 'input.workload.survivingReplicas', 1000000, true),
273
+ failedNodes: positiveInteger(required(rawWorkload, 'failedNodes', 'input.workload'), 'input.workload.failedNodes', 1000, true),
274
+ };
275
+ if (workload.survivingReplicas > workload.replicas) fail('input.workload.survivingReplicas', 'cannot exceed replicas');
276
+ const equivalence = record(required(input, 'equivalence', 'input'), 'input.equivalence', ['status', 'assumptions', 'reason']);
277
+ if (!['assumed', 'unknown'].includes(required(equivalence, 'status', 'input.equivalence'))) fail('input.equivalence.status', 'must be assumed or unknown');
278
+ const assumptions = required(equivalence, 'assumptions', 'input.equivalence');
279
+ if (!Array.isArray(assumptions) || assumptions.length > 16 || (equivalence.status === 'assumed' && assumptions.length === 0)) fail('input.equivalence.assumptions', 'must have 1 to 16 assumptions when assumed');
280
+ assumptions.forEach((item, i) => words(item, `input.equivalence.assumptions[${i}]`));
281
+ if (equivalence.status === 'unknown') words(required(equivalence, 'reason', 'input.equivalence'), 'input.equivalence.reason');
282
+ else if (equivalence.reason !== undefined) fail('input.equivalence.reason', 'allowed only when unknown');
283
+ const rawScenarios = required(input, 'scenarios', 'input');
284
+ if (!Array.isArray(rawScenarios) || rawScenarios.length !== 2) fail('input.scenarios', 'must contain exactly one VM and one K8s scenario');
285
+ const scenarios = rawScenarios.map((item, i) => scenario(item, `input.scenarios[${i}]`, workload, horizon, vatBasis));
286
+ if (new Set(scenarios.map(item => item.kind)).size !== 2 || new Set(scenarios.map(item => item.id)).size !== 2) fail('input.scenarios', 'must have unique IDs and one scenario of each kind');
287
+ const reasons = [];
288
+ if (equivalence.status === 'unknown') reasons.push('architecture/HA equivalence is unknown');
289
+ for (const item of scenarios) {
290
+ if (!item.capacity.feasible) reasons.push(`${item.id}: declared normal or node-failure capacity is insufficient`);
291
+ if (!item.complete) reasons.push(`${item.id}: unpriced categories remain`);
292
+ }
293
+ const eligible = reasons.length === 0;
294
+ let winner = null, savings = null, tie = false;
295
+ if (eligible) {
296
+ const [a, b] = scenarios;
297
+ const relation = compare(a._known, b._known);
298
+ tie = relation === 0;
299
+ if (!tie) { winner = relation < 0 ? a.id : b.id; savings = subtract(relation < 0 ? b._known : a._known, relation < 0 ? a._known : b._known); }
300
+ }
301
+ for (const item of scenarios) delete item._known;
302
+ return {
303
+ schemaVersion: 1, horizonHours: input.horizonHours, currency: 'RUB', vatBasis,
304
+ workload: rawWorkload, equivalence,
305
+ scenarios,
306
+ comparison: { eligible, reasons, basis: eligible ? 'conditional lower modeled infrastructure cost under supplied assumptions' : null,
307
+ winner, tie: eligible ? tie : null, savingsRubExact: savings ? exact(savings) : null, savingsRubDisplay: savings ? display(savings) : null },
308
+ limitations: ['Declared quantities and tariff provenance are user assertions, not verified inventory or live prices.',
309
+ 'Capacity is a homogeneous per-node packing bound, not a placement, performance, HA or SLA proof.',
310
+ 'Static full-horizon resources only; no tiered, monthly, stopped-resource, discount or heterogeneous-fleet modeling.'],
311
+ };
312
+ }
313
+
314
+ function parseFlags(argv) {
315
+ let input = null, json = false;
316
+ for (let i = 0; i < argv.length; i++) {
317
+ const arg = argv[i];
318
+ if (arg === '--json') { if (json) fail('flags', 'duplicate --json'); json = true; }
319
+ else if (arg === '--input') { if (input !== null) fail('flags', 'duplicate --input'); input = argv[++i]; if (!input || input.startsWith('--')) fail('flags', '--input requires a path'); }
320
+ else fail('flags', `unknown argument ${JSON.stringify(arg)}`);
321
+ }
322
+ if (input === null) fail('flags', '--input is required');
323
+ return { input, json };
324
+ }
325
+
326
+ function run(argv, write = s => process.stdout.write(s + '\n'), error = s => process.stderr.write(s + '\n')) {
327
+ try {
328
+ const flags = parseFlags(argv);
329
+ const result = calculate(readInput(flags.input));
330
+ if (flags.json) write(JSON.stringify(result, null, 2));
331
+ else {
332
+ write(`Offline Cloud.ru cost worksheet — ${result.horizonHours} hours, RUB, VAT included`);
333
+ for (const item of result.scenarios) {
334
+ write(`${item.id} (${item.kind}): known subtotal ${item.knownSubtotalRubExact} RUB (${item.knownSubtotalRubDisplay} displayed); capacity ${item.capacity.normalSlots} normal / ${item.capacity.survivingSlots} after ${result.workload.failedNodes} node failure(s): ${item.capacity.feasible ? 'feasible packing bound' : 'INSUFFICIENT'}`);
335
+ for (const [name, category] of Object.entries(item.categories)) for (const line of category.items) write(` ${name}/${line.id}: ${line.formula} = ${line.amountRubExact} RUB; tariff ${line.tariff.url} version ${line.tariff.version} as of ${line.tariff.asOf}, ${line.tariff.pageOrRow}, VAT ${line.tariff.vatBasis}`);
336
+ for (const missing of item.unpriced) write(` unpriced ${missing.category}: ${missing.reason}`);
337
+ }
338
+ write(`Architecture equivalence: ${result.equivalence.status}${result.equivalence.reason ? ` — ${result.equivalence.reason}` : ''}`);
339
+ for (const assumption of result.equivalence.assumptions) write(` assumption: ${assumption}`);
340
+ write(result.comparison.eligible ? (result.comparison.tie ? 'Conditional modeled cost: tie under supplied assumptions' : `Conditional lower modeled infrastructure cost: ${result.comparison.winner}; savings ${result.comparison.savingsRubExact} RUB (${result.comparison.savingsRubDisplay} displayed)`) : `No winner: ${result.comparison.reasons.join('; ')}`);
341
+ for (const limitation of result.limitations) write(`Limit: ${limitation}`);
342
+ write(`Formulas and tariff provenance: use --json or inspect ${flags.input}`);
343
+ }
344
+ return 0;
345
+ } catch (e) { error(`cloudru-hub cost-compare: ${e.message}`); return 1; }
346
+ }
347
+
348
+ module.exports = { run, calculate, readInput };
package/src/resolve.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // 1. env CLOUDRU_VM_BIN — explicit path (local baseline testing)
5
5
  // 2. config enginePath — ~/.cloudru-hub/config.json (or $CLOUDRU_HUB_CONFIG)
6
6
  // 3. platform package @dzhechkov/cloudru-vm-<os>-<cpu>/native/cloudru-vm
7
- // (does not exist until the ADR-001 licence grant clears — see LICENSE Part 2)
7
+ // (installed automatically when its optional dependency is published)
8
8
  // 4. loud, esbuild-style error explaining all three paths and the licence hold.
9
9
  //
10
10
  // sha256 policy: the resolved binary is hashed and compared against the pinned map in
@@ -64,7 +64,7 @@ function resolveEngine(env = process.env) {
64
64
  const pkgJson = require.resolve(`${platformPkg}/package.json`);
65
65
  candidates.push({ path: path.join(path.dirname(pkgJson), 'native', 'cloudru-vm'), source: 'platform-package' });
66
66
  } catch {
67
- /* optional platform package not installed — expected while the ADR-001 hold is in force */
67
+ /* optional platform package unavailable on this host or omitted by the installer */
68
68
  }
69
69
  }
70
70
 
@@ -94,7 +94,7 @@ function resolveEngine(env = process.env) {
94
94
  ` 2. enginePath in ${configPath(env)} ${cfg.enginePath ? `(set, but not a file: ${cfg.enginePath})` : '(unset/absent)'}\n` +
95
95
  ` 3. optional dependency ${(PKG.cloudruHub && PKG.cloudruHub.platformPackages && PKG.cloudruHub.platformPackages[key]) || '<none for ' + key + '>'} (not installed)\n` +
96
96
  'Local testing: point $CLOUDRU_VM_BIN at an engine build (see docs/LOCAL-TESTING.md).\n' +
97
- 'The hosted platform package does not exist yet: its first publication still requires the ADR-002 trusted-CI binary build (see LICENSE Part 2 and THIRD_PARTY_NOTICES).',
97
+ 'If the platform package has been released, install optional dependencies; otherwise provide a pinned local engine build.',
98
98
  };
99
99
  }
100
100