nodio-cli 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "nodio-cli",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Nodio distributed storage network",
5
5
  "main": "src/server/index.js",
6
6
  "type": "commonjs",
7
7
  "bin": {
8
+ "nodio-cli": "src/cli/index.js",
8
9
  "nodio-server": "src/server/index.js",
9
10
  "nodio-node": "src/node/index.js",
10
11
  "nodio": "src/user/index.js"
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ const path = require('path');
3
+ const { spawn } = require('child_process');
4
+
5
+ const commandMap = {
6
+ 'nodio-node': path.join(__dirname, '..', 'node', 'index.js'),
7
+ 'nodio-server': path.join(__dirname, '..', 'server', 'index.js'),
8
+ nodio: path.join(__dirname, '..', 'user', 'index.js')
9
+ };
10
+
11
+ function printHelp() {
12
+ console.log('Nodio CLI dispatcher');
13
+ console.log('');
14
+ console.log('Usage:');
15
+ console.log(' nodio-cli <command> [...args]');
16
+ console.log('');
17
+ console.log('Commands:');
18
+ console.log(' nodio-node Start donor node runtime');
19
+ console.log(' nodio-server Start central server');
20
+ console.log(' nodio User CLI (upload/download/delete)');
21
+ console.log('');
22
+ console.log('Example:');
23
+ console.log(' npx nodio-cli nodio-node 10gb');
24
+ }
25
+
26
+ const [command, ...restArgs] = process.argv.slice(2);
27
+
28
+ if (!command || command === '--help' || command === '-h') {
29
+ printHelp();
30
+ process.exit(0);
31
+ }
32
+
33
+ const scriptPath = commandMap[command];
34
+ if (!scriptPath) {
35
+ console.error(`[nodio-cli] Unknown command: ${command}`);
36
+ printHelp();
37
+ process.exit(1);
38
+ }
39
+
40
+ const child = spawn(process.execPath, [scriptPath, ...restArgs], {
41
+ stdio: 'inherit'
42
+ });
43
+
44
+ child.on('exit', (code, signal) => {
45
+ if (signal) {
46
+ process.kill(process.pid, signal);
47
+ return;
48
+ }
49
+ process.exit(code ?? 0);
50
+ });
@@ -0,0 +1,45 @@
1
+ const fs = require('fs/promises');
2
+ const os = require('os');
3
+ const path = require('path');
4
+ const crypto = require('crypto');
5
+
6
+ const DEVICE_DIR = path.join(os.homedir(), '.nodio');
7
+ const DEVICE_FILE = path.join(DEVICE_DIR, 'device-identity.json');
8
+
9
+ async function readDeviceIdentity() {
10
+ try {
11
+ const raw = await fs.readFile(DEVICE_FILE, 'utf-8');
12
+ const parsed = JSON.parse(raw);
13
+ return parsed && typeof parsed === 'object' ? parsed : {};
14
+ } catch {
15
+ return {};
16
+ }
17
+ }
18
+
19
+ async function getOrCreateDeviceKey() {
20
+ const identity = await readDeviceIdentity();
21
+ if (identity.deviceKey) {
22
+ return identity.deviceKey;
23
+ }
24
+
25
+ const deviceKey = crypto.randomUUID();
26
+ await fs.mkdir(DEVICE_DIR, { recursive: true });
27
+ await fs.writeFile(
28
+ DEVICE_FILE,
29
+ JSON.stringify(
30
+ {
31
+ deviceKey,
32
+ createdAt: new Date().toISOString()
33
+ },
34
+ null,
35
+ 2
36
+ ),
37
+ 'utf-8'
38
+ );
39
+
40
+ return deviceKey;
41
+ }
42
+
43
+ module.exports = {
44
+ getOrCreateDeviceKey
45
+ };
package/src/node/index.js CHANGED
@@ -1,31 +1,85 @@
1
1
  #!/usr/bin/env node
2
2
  const path = require('path');
3
3
  const os = require('os');
4
+ const net = require('net');
4
5
  const { Command } = require('commander');
5
- const { v4: uuidv4 } = require('uuid');
6
6
  const { NodioNodeRuntime } = require('./runtime');
7
7
 
8
8
  const program = new Command();
9
9
 
10
+ function parseCapacityGb(value) {
11
+ if (value === undefined || value === null || value === '') {
12
+ return null;
13
+ }
14
+
15
+ const raw = String(value).trim().toLowerCase();
16
+ const match = raw.match(/^([0-9]+(?:\.[0-9]+)?)(gb)?$/);
17
+ if (!match) {
18
+ throw new Error('capacity must be a number or <number>gb, e.g. 10 or 10gb');
19
+ }
20
+
21
+ return Number(match[1]);
22
+ }
23
+
24
+ async function isPortFree(port) {
25
+ return new Promise((resolve) => {
26
+ const server = net.createServer();
27
+ server.unref();
28
+
29
+ server.once('error', () => resolve(false));
30
+ server.once('listening', () => {
31
+ server.close(() => resolve(true));
32
+ });
33
+
34
+ server.listen(port, '127.0.0.1');
35
+ });
36
+ }
37
+
38
+ async function findFreePort(startPort, endPort) {
39
+ for (let port = startPort; port <= endPort; port += 1) {
40
+ // eslint-disable-next-line no-await-in-loop
41
+ if (await isPortFree(port)) {
42
+ return port;
43
+ }
44
+ }
45
+
46
+ throw new Error(`no free port found in range ${startPort}-${endPort}`);
47
+ }
48
+
10
49
  program
11
50
  .name('nodio-node')
12
51
  .description('Nodio donor node CLI')
13
- .option('--node-id <id>', 'unique node ID', uuidv4())
14
- .option('--server <url>', 'central server URL', 'http://127.0.0.1:4000')
52
+ .argument('[capacity]', 'optional capacity shorthand, e.g. 10gb')
53
+ .option('--node-id <id>', 'unique node ID (optional; auto-assigned and persisted if omitted)')
54
+ .option('--server <url>', 'central server URL', 'https://api.nodio.me')
15
55
  .option('--host <host>', 'host/IP exposed to network', '127.0.0.1')
16
- .option('--port <port>', 'port to expose shard API', '5001')
17
- .option('--storage-dir <path>', 'local shard storage directory', path.join(os.homedir(), '.nodio-node'))
56
+ .option('--port <port>', 'port to expose shard API (auto-picked when omitted)')
57
+ .option('--storage-dir <path>', 'local shard storage directory (defaults to ~/.nodio-nodes/node-<port>)')
18
58
  .option('--capacity-gb <gb>', 'donated capacity in GB', '10')
59
+ .option('--auto-port-start <port>', 'start of auto-port range', '5001')
60
+ .option('--auto-port-end <port>', 'end of auto-port range', '5999')
19
61
  .option('--heartbeat-ms <ms>', 'heartbeat interval in milliseconds', '30000');
20
62
 
21
- program.action(async (options) => {
22
- const port = Number(options.port);
23
- const capacityGb = Number(options.capacityGb);
63
+ program.action(async (capacityArg, options) => {
64
+ const autoPortStart = Number(options.autoPortStart);
65
+ const autoPortEnd = Number(options.autoPortEnd);
66
+ const port = options.port
67
+ ? Number(options.port)
68
+ : await findFreePort(autoPortStart, autoPortEnd);
69
+
70
+ const parsedCapacityArg = parseCapacityGb(capacityArg);
71
+ const capacityGb = parsedCapacityArg ?? Number(options.capacityGb);
24
72
  const heartbeatMs = Number(options.heartbeatMs);
73
+ const storageDir = options.storageDir
74
+ ? path.resolve(options.storageDir)
75
+ : path.join(os.homedir(), '.nodio-nodes', `node-${port}`);
25
76
 
26
77
  if (!Number.isInteger(port) || port <= 0) {
27
78
  throw new Error('port must be a positive integer');
28
79
  }
80
+ if (!Number.isInteger(autoPortStart) || !Number.isInteger(autoPortEnd) || autoPortStart <= 0 || autoPortEnd < autoPortStart) {
81
+ throw new Error('auto-port range is invalid');
82
+ }
29
83
  if (!Number.isFinite(capacityGb) || capacityGb <= 0) {
30
84
  throw new Error('capacity-gb must be greater than 0');
31
85
  }
@@ -38,7 +92,7 @@ program.action(async (options) => {
38
92
  serverUrl: options.server,
39
93
  publicUrl: `http://${options.host}:${port}`,
40
94
  port,
41
- storageDir: path.resolve(options.storageDir),
95
+ storageDir,
42
96
  capacityBytes: Math.floor(capacityGb * 1024 * 1024 * 1024),
43
97
  heartbeatIntervalMs: heartbeatMs
44
98
  });
@@ -1,6 +1,7 @@
1
1
  const express = require('express');
2
2
  const axios = require('axios');
3
3
  const { LocalShardStore } = require('./storage');
4
+ const { getOrCreateDeviceKey } = require('./deviceIdentity');
4
5
 
5
6
  function normalizeUrl(url) {
6
7
  return String(url || '').replace(/\/+$/, '');
@@ -8,7 +9,7 @@ function normalizeUrl(url) {
8
9
 
9
10
  class NodioNodeRuntime {
10
11
  constructor(options) {
11
- this.nodeId = options.nodeId;
12
+ this.nodeId = options.nodeId || null;
12
13
  this.serverUrl = normalizeUrl(options.serverUrl);
13
14
  this.publicUrl = normalizeUrl(options.publicUrl);
14
15
  this.port = Number(options.port);
@@ -20,6 +21,11 @@ class NodioNodeRuntime {
20
21
 
21
22
  async start() {
22
23
  await this.shardStore.init();
24
+
25
+ if (!this.nodeId) {
26
+ this.nodeId = await this.shardStore.getSavedNodeId();
27
+ }
28
+
23
29
  await this.startShardServer();
24
30
  await this.registerNode();
25
31
  await this.sendHeartbeat();
@@ -93,13 +99,23 @@ class NodioNodeRuntime {
93
99
  }
94
100
 
95
101
  async registerNode() {
102
+ const deviceKey = await getOrCreateDeviceKey();
103
+ const nodeKey = await this.shardStore.getOrCreateNodeKey();
104
+ const knownNodeIds = await this.shardStore.discoverKnownNodeIds();
105
+
96
106
  const response = await axios.post(`${this.serverUrl}/api/nodes/register`, {
97
107
  nodeId: this.nodeId,
108
+ deviceKey,
109
+ nodeKey,
110
+ knownNodeIds,
98
111
  url: this.publicUrl,
99
112
  capacityBytes: this.capacityBytes,
100
113
  freeBytes: await this.freeBytes()
101
114
  });
102
115
 
116
+ this.nodeId = response.data.nodeId;
117
+ await this.shardStore.saveAssignedNodeId(this.nodeId);
118
+
103
119
  const interval = Number(response.data.heartbeatIntervalMs);
104
120
  if (Number.isFinite(interval) && interval > 0) {
105
121
  this.heartbeatIntervalMs = interval;
@@ -1,5 +1,6 @@
1
1
  const fs = require('fs/promises');
2
2
  const path = require('path');
3
+ const crypto = require('crypto');
3
4
  const { sha256Hex } = require('../common/crypto');
4
5
 
5
6
  class LocalShardStore {
@@ -7,6 +8,7 @@ class LocalShardStore {
7
8
  this.baseDir = baseDir;
8
9
  this.indexFile = path.join(baseDir, 'index.json');
9
10
  this.shardsDir = path.join(baseDir, 'shards');
11
+ this.identityFile = path.join(baseDir, 'node-identity.json');
10
12
  }
11
13
 
12
14
  async init() {
@@ -31,6 +33,90 @@ class LocalShardStore {
31
33
  await fs.writeFile(this.indexFile, JSON.stringify(index, null, 2), 'utf-8');
32
34
  }
33
35
 
36
+ async readIdentity() {
37
+ try {
38
+ const raw = await fs.readFile(this.identityFile, 'utf-8');
39
+ const parsed = JSON.parse(raw);
40
+ return parsed && typeof parsed === 'object' ? parsed : {};
41
+ } catch {
42
+ return {};
43
+ }
44
+ }
45
+
46
+ async writeIdentity(identity) {
47
+ await fs.writeFile(this.identityFile, JSON.stringify(identity, null, 2), 'utf-8');
48
+ }
49
+
50
+ async getIdentity() {
51
+ return this.readIdentity();
52
+ }
53
+
54
+ async getOrCreateNodeKey() {
55
+ const identity = await this.readIdentity();
56
+ if (identity.nodeKey) {
57
+ return identity.nodeKey;
58
+ }
59
+
60
+ const nodeKey = crypto.randomUUID();
61
+ await this.writeIdentity({ ...identity, nodeKey, updatedAt: new Date().toISOString() });
62
+ return nodeKey;
63
+ }
64
+
65
+ async getSavedNodeId() {
66
+ const identity = await this.readIdentity();
67
+ return identity.nodeId || null;
68
+ }
69
+
70
+ async discoverKnownNodeIds() {
71
+ const known = new Set();
72
+
73
+ const current = await this.readIdentity();
74
+ if (current.nodeId) {
75
+ known.add(current.nodeId);
76
+ }
77
+
78
+ const parentDir = path.dirname(this.baseDir);
79
+ let entries = [];
80
+ try {
81
+ entries = await fs.readdir(parentDir, { withFileTypes: true });
82
+ } catch {
83
+ return [...known];
84
+ }
85
+
86
+ for (const entry of entries) {
87
+ if (!entry.isDirectory()) {
88
+ continue;
89
+ }
90
+
91
+ const identityPath = path.join(parentDir, entry.name, 'node-identity.json');
92
+ try {
93
+ // eslint-disable-next-line no-await-in-loop
94
+ const raw = await fs.readFile(identityPath, 'utf-8');
95
+ const parsed = JSON.parse(raw);
96
+ if (parsed?.nodeId && typeof parsed.nodeId === 'string') {
97
+ known.add(parsed.nodeId);
98
+ }
99
+ } catch {
100
+ continue;
101
+ }
102
+ }
103
+
104
+ return [...known];
105
+ }
106
+
107
+ async saveAssignedNodeId(nodeId) {
108
+ if (!nodeId) {
109
+ return;
110
+ }
111
+
112
+ const identity = await this.readIdentity();
113
+ await this.writeIdentity({
114
+ ...identity,
115
+ nodeId,
116
+ updatedAt: new Date().toISOString()
117
+ });
118
+ }
119
+
34
120
  shardPath(shardId) {
35
121
  return path.join(this.shardsDir, `${shardId}.bin`);
36
122
  }
@@ -3,6 +3,8 @@ const mongoose = require('mongoose');
3
3
  const nodeSchema = new mongoose.Schema(
4
4
  {
5
5
  nodeId: { type: String, required: true, unique: true, index: true },
6
+ deviceKey: { type: String, index: true },
7
+ nodeKey: { type: String, unique: true, sparse: true, index: true },
6
8
  url: { type: String, required: true },
7
9
  capacityBytes: { type: Number, required: true, min: 1 },
8
10
  freeBytes: { type: Number, required: true, min: 0 },
@@ -31,10 +31,10 @@ function buildRoutes(config) {
31
31
 
32
32
  router.post('/nodes/register', async (req, res, next) => {
33
33
  try {
34
- const { nodeId, url, capacityBytes, freeBytes } = req.body;
34
+ const { nodeId, deviceKey, nodeKey, knownNodeIds, url, capacityBytes, freeBytes } = req.body;
35
35
 
36
- if (!nodeId || !url) {
37
- return res.status(400).json({ error: 'nodeId and url are required' });
36
+ if (!url) {
37
+ return res.status(400).json({ error: 'url is required' });
38
38
  }
39
39
 
40
40
  const capacity = Number(capacityBytes);
@@ -42,11 +42,53 @@ function buildRoutes(config) {
42
42
  if (!Number.isFinite(capacity) || capacity <= 0 || !Number.isFinite(free) || free < 0) {
43
43
  return res.status(400).json({ error: 'capacityBytes and freeBytes must be valid numbers' });
44
44
  }
45
+ if (deviceKey && typeof deviceKey !== 'string') {
46
+ return res.status(400).json({ error: 'deviceKey must be a string when provided' });
47
+ }
48
+ if (knownNodeIds !== undefined && !Array.isArray(knownNodeIds)) {
49
+ return res.status(400).json({ error: 'knownNodeIds must be an array when provided' });
50
+ }
51
+
52
+ const normalizedKnownNodeIds = Array.isArray(knownNodeIds)
53
+ ? [...new Set(knownNodeIds.filter((value) => typeof value === 'string' && value.length > 0))]
54
+ : [];
55
+
56
+ let existingByNodeKey = null;
57
+ if (nodeKey) {
58
+ existingByNodeKey = await NodeModel.findOne({ nodeKey }).lean();
59
+ }
60
+
61
+ if (existingByNodeKey && nodeId && nodeId !== existingByNodeKey.nodeId) {
62
+ return res.status(409).json({ error: 'nodeKey is already associated with a different nodeId' });
63
+ }
64
+
65
+ let effectiveNodeId = nodeId || existingByNodeKey?.nodeId || null;
66
+
67
+ if (!effectiveNodeId && deviceKey && normalizedKnownNodeIds.length > 0) {
68
+ const oldestOfflineKnownNode = await NodeModel.findOne({
69
+ deviceKey,
70
+ status: 'offline',
71
+ nodeId: { $in: normalizedKnownNodeIds }
72
+ })
73
+ .sort({ createdAt: 1 })
74
+ .lean();
75
+
76
+ if (oldestOfflineKnownNode) {
77
+ effectiveNodeId = oldestOfflineKnownNode.nodeId;
78
+ }
79
+ }
80
+
81
+ if (!effectiveNodeId) {
82
+ effectiveNodeId = `donor-${uuidv4().slice(0, 8)}`;
83
+ }
45
84
 
46
85
  const node = await NodeModel.findOneAndUpdate(
47
- { nodeId },
86
+ { nodeId: effectiveNodeId },
48
87
  {
49
88
  $set: {
89
+ nodeId: effectiveNodeId,
90
+ ...(deviceKey ? { deviceKey } : {}),
91
+ ...(nodeKey ? { nodeKey } : {}),
50
92
  url,
51
93
  capacityBytes: capacity,
52
94
  freeBytes: free,
@@ -343,15 +385,21 @@ function buildRoutes(config) {
343
385
  const nodeUrlMap = new Map(onlineNodes.map((node) => [node.nodeId, node.url]));
344
386
 
345
387
  const shardDeleteFailures = [];
388
+ let deleteAttempts = 0;
389
+ let deleteSuccesses = 0;
390
+ let deleteSkippedOffline = 0;
346
391
  for (const placement of placements) {
347
392
  const nodeUrl = nodeUrlMap.get(placement.nodeId);
348
393
  if (!nodeUrl) {
394
+ deleteSkippedOffline += 1;
349
395
  continue;
350
396
  }
351
397
 
352
398
  const deleteUrl = `${normalizeUrl(nodeUrl)}/shards/${placement.shardId}`;
399
+ deleteAttempts += 1;
353
400
  try {
354
401
  await axios.delete(deleteUrl, { timeout: 15000 });
402
+ deleteSuccesses += 1;
355
403
  } catch (error) {
356
404
  shardDeleteFailures.push({
357
405
  shardId: placement.shardId,
@@ -376,6 +424,9 @@ function buildRoutes(config) {
376
424
  fileId,
377
425
  deletedShards: shardIds.length,
378
426
  deletedPlacements: placements.length,
427
+ donorDeleteAttempts: deleteAttempts,
428
+ donorDeleteSuccesses: deleteSuccesses,
429
+ donorDeleteSkippedOffline: deleteSkippedOffline,
379
430
  shardDeleteFailures
380
431
  });
381
432
  } catch (error) {
@@ -210,7 +210,10 @@ async function deleteFile(options) {
210
210
  console.log('Delete complete');
211
211
  console.log(`fileId: ${payload.fileId || fileId}`);
212
212
  console.log(`deletedShards: ${payload.deletedShards || 0}`);
213
- console.log(`deletedPlacements: ${payload.deletedPlacements || 0}`);
213
+ console.log(`deletedPlacementsMetadata: ${payload.deletedPlacements || 0}`);
214
+ console.log(`donorDeleteAttempts: ${payload.donorDeleteAttempts || 0}`);
215
+ console.log(`donorDeleteSuccesses: ${payload.donorDeleteSuccesses || 0}`);
216
+ console.log(`donorDeleteSkippedOffline: ${payload.donorDeleteSkippedOffline || 0}`);
214
217
  console.log(`shardDeleteFailures: ${(payload.shardDeleteFailures || []).length}`);
215
218
  }
216
219
 
package/src/user/index.js CHANGED
@@ -10,7 +10,7 @@ program
10
10
  .command('upload')
11
11
  .description('Encrypt, shard, and distribute a file across donor nodes')
12
12
  .requiredOption('--file <path>', 'path to local file')
13
- .option('--server <url>', 'central server URL', 'http://127.0.0.1:4000')
13
+ .option('--server <url>', 'central server URL', 'https://api.nodio.me')
14
14
  .option('--file-id <id>', 'custom file ID (optional)')
15
15
  .option('--shard-size-mb <mb>', 'plaintext shard size in MB', '1')
16
16
  .option('--replicas <count>', 'replicas per shard (minimum 5)', '5')
@@ -24,7 +24,7 @@ program
24
24
  .description('Download, verify, decrypt, and reconstruct a file')
25
25
  .requiredOption('--file-id <id>', 'file ID to download')
26
26
  .requiredOption('--key-base64 <key>', '32-byte AES key in base64 from upload output')
27
- .option('--server <url>', 'central server URL', 'http://127.0.0.1:4000')
27
+ .option('--server <url>', 'central server URL', 'https://api.nodio.me')
28
28
  .option('--output <path>', 'output file path')
29
29
  .action(async (options) => {
30
30
  await downloadFile(options);
@@ -34,7 +34,7 @@ program
34
34
  .command('delete')
35
35
  .description('Delete a file and its shard replicas from the network')
36
36
  .requiredOption('--file-id <id>', 'file ID to delete')
37
- .option('--server <url>', 'central server URL', 'http://127.0.0.1:4000')
37
+ .option('--server <url>', 'central server URL', 'https://api.nodio.me')
38
38
  .action(async (options) => {
39
39
  await deleteFile(options);
40
40
  });