natureco-cli 5.63.0 → 5.64.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/bin/natureco.js +10 -3
  3. package/package.json +2 -1
  4. package/scripts/benchmark-startup.js +26 -0
  5. package/src/commands/backup.js +3 -3
  6. package/src/commands/code.js +49 -14
  7. package/src/commands/code_v5.js +15 -1
  8. package/src/commands/gateway-server.js +128 -24
  9. package/src/commands/git.js +2 -2
  10. package/src/commands/pairing.js +2 -22
  11. package/src/commands/repl.js +8 -6
  12. package/src/tools/agentic-runner.js +41 -33
  13. package/src/tools/memory_write.js +16 -12
  14. package/src/tools/structural_patch.js +25 -0
  15. package/src/tools/workflow.js +10 -2
  16. package/src/utils/agent-core.js +51 -0
  17. package/src/utils/agent-workspace.js +24 -0
  18. package/src/utils/api.js +12 -8
  19. package/src/utils/channel-sdk.js +212 -0
  20. package/src/utils/code-intelligence.js +69 -0
  21. package/src/utils/coding-session.js +54 -0
  22. package/src/utils/delivery-store.js +34 -0
  23. package/src/utils/i18n.js +7 -1
  24. package/src/utils/json-schema.js +43 -0
  25. package/src/utils/lsp-client.js +129 -0
  26. package/src/utils/memory-record.js +49 -0
  27. package/src/utils/pairing-store.js +55 -0
  28. package/src/utils/pattern-detector.js +13 -3
  29. package/src/utils/plugin-registry.js +3 -3
  30. package/src/utils/process-errors.js +14 -7
  31. package/src/utils/runtime-health.js +28 -0
  32. package/src/utils/secret-store.js +90 -0
  33. package/src/utils/secure-sync.js +63 -0
  34. package/src/utils/skill-lifecycle.js +59 -0
  35. package/src/utils/structural-patch.js +68 -0
  36. package/src/utils/sub-agent.js +13 -2
  37. package/src/utils/test-failure-analyzer.js +64 -0
  38. package/src/utils/tool-execution-gateway.js +56 -0
  39. package/src/utils/tool-manifest.js +31 -0
  40. package/src/utils/tool-path-policy.js +49 -0
  41. package/src/utils/tool-result.js +17 -0
  42. package/src/utils/tool-runner.js +81 -53
  43. package/src/utils/tools.js +30 -42
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const TB = require('./token-budget');
6
+ const { writeFileAtomicSync } = require('./atomic-file');
7
+
8
+ class CodingSession {
9
+ constructor(options = {}) {
10
+ this.maxSnapshots = options.maxSnapshots || 20;
11
+ this.snapshots = [];
12
+ this.lastUserMessage = null;
13
+ }
14
+
15
+ rememberUserMessage(message) { if (message && !message.startsWith('/')) this.lastUserMessage = message; }
16
+ retryMessage() { return this.lastUserMessage; }
17
+
18
+ capture(filePath) {
19
+ if (!filePath) return null;
20
+ const target = path.resolve(filePath);
21
+ const existed = fs.existsSync(target);
22
+ const content = existed ? fs.readFileSync(target) : null;
23
+ const snapshot = { path: target, existed, content, capturedAt: Date.now() };
24
+ this.snapshots.push(snapshot);
25
+ if (this.snapshots.length > this.maxSnapshots) this.snapshots.shift();
26
+ return snapshot;
27
+ }
28
+
29
+ undo() {
30
+ const snapshot = this.snapshots.pop();
31
+ if (!snapshot) return { ok: false, error: 'Geri alınacak değişiklik yok.' };
32
+ if (snapshot.existed) writeFileAtomicSync(snapshot.path, snapshot.content);
33
+ else if (fs.existsSync(snapshot.path)) fs.unlinkSync(snapshot.path);
34
+ return { ok: true, path: snapshot.path, restored: snapshot.existed };
35
+ }
36
+
37
+ compact(messages) {
38
+ const before = messages.length;
39
+ const compacted = TB.smartTrim(messages);
40
+ return { messages: compacted, before, after: compacted.length, removed: before - compacted.length };
41
+ }
42
+
43
+ riskSummary(toolCall) {
44
+ const name = toolCall?.name || '';
45
+ const args = toolCall?.input || {};
46
+ const risks = [];
47
+ if (name === 'write_file' || name === 'edit_file') risks.push('filesystem-write');
48
+ if (name === 'bash' || name === 'shell_command') risks.push('command-execution');
49
+ if (/\b(rm|delete|drop|truncate|chmod|chown)\b/i.test(args.command || '')) risks.push('destructive');
50
+ return { level: risks.includes('destructive') ? 'high' : risks.length ? 'medium' : 'low', risks };
51
+ }
52
+ }
53
+
54
+ module.exports = { CodingSession };
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const { writeJsonAtomicSync, readJsonSafeSync } = require('./atomic-file');
7
+
8
+ class DeliveryStore {
9
+ constructor(options = {}) {
10
+ this.file = options.file || path.join(os.homedir(), '.natureco', 'delivery-queue.json');
11
+ this.maxItems = options.maxItems || 10000;
12
+ }
13
+
14
+ load() {
15
+ const data = readJsonSafeSync(this.file, { version: 1, queue: [], deadLetters: [] });
16
+ return {
17
+ version: 1,
18
+ queue: Array.isArray(data.queue) ? data.queue.slice(0, this.maxItems) : [],
19
+ deadLetters: Array.isArray(data.deadLetters) ? data.deadLetters.slice(-this.maxItems) : [],
20
+ };
21
+ }
22
+
23
+ save(state) {
24
+ fs.mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 });
25
+ writeJsonAtomicSync(this.file, {
26
+ version: 1,
27
+ savedAt: new Date().toISOString(),
28
+ queue: (state.queue || []).slice(0, this.maxItems),
29
+ deadLetters: (state.deadLetters || []).slice(-this.maxItems),
30
+ }, { mode: 0o600 });
31
+ }
32
+ }
33
+
34
+ module.exports = { DeliveryStore };
package/src/utils/i18n.js CHANGED
@@ -92,4 +92,10 @@ function t(key, vars) {
92
92
  return s;
93
93
  }
94
94
 
95
- module.exports = { t, getLang, setLangCache, MESSAGES };
95
+ function validateCatalogParity(catalog = MESSAGES) {
96
+ const tr = Object.keys(catalog.tr || {}).sort();
97
+ const en = Object.keys(catalog.en || {}).sort();
98
+ return { ok: tr.length === en.length && tr.every((key, index) => key === en[index]), trOnly: tr.filter(key => !en.includes(key)), enOnly: en.filter(key => !tr.includes(key)), keys: tr.length };
99
+ }
100
+
101
+ module.exports = { t, getLang, setLangCache, MESSAGES, validateCatalogParity };
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ function valueType(value) {
4
+ if (Array.isArray(value)) return 'array';
5
+ if (value === null) return 'null';
6
+ if (Number.isInteger(value)) return 'integer';
7
+ return typeof value;
8
+ }
9
+
10
+ function validateJsonSchema(schema, value, at = '$') {
11
+ const errors = [];
12
+ if (!schema || typeof schema !== 'object') return { valid: true, errors };
13
+ const allowedTypes = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : [];
14
+ const actual = valueType(value);
15
+ if (allowedTypes.length && !allowedTypes.includes(actual) && !(actual === 'integer' && allowedTypes.includes('number'))) {
16
+ return { valid: false, errors: [`${at}: expected ${allowedTypes.join('|')}, got ${actual}`] };
17
+ }
18
+ if (schema.enum && !schema.enum.some(item => Object.is(item, value))) {
19
+ errors.push(`${at}: value is not in enum`);
20
+ }
21
+ if (actual === 'object' && value !== null) {
22
+ for (const key of schema.required || []) {
23
+ if (!Object.prototype.hasOwnProperty.call(value, key)) errors.push(`${at}.${key}: required`);
24
+ }
25
+ for (const [key, child] of Object.entries(schema.properties || {})) {
26
+ if (Object.prototype.hasOwnProperty.call(value, key)) {
27
+ errors.push(...validateJsonSchema(child, value[key], `${at}.${key}`).errors);
28
+ }
29
+ }
30
+ if (schema.additionalProperties === false) {
31
+ const known = new Set(Object.keys(schema.properties || {}));
32
+ for (const key of Object.keys(value)) if (!known.has(key)) errors.push(`${at}.${key}: additional property`);
33
+ }
34
+ }
35
+ if (actual === 'array') {
36
+ if (schema.minItems != null && value.length < schema.minItems) errors.push(`${at}: minItems ${schema.minItems}`);
37
+ if (schema.maxItems != null && value.length > schema.maxItems) errors.push(`${at}: maxItems ${schema.maxItems}`);
38
+ if (schema.items) value.forEach((item, index) => errors.push(...validateJsonSchema(schema.items, item, `${at}[${index}]`).errors));
39
+ }
40
+ return { valid: errors.length === 0, errors };
41
+ }
42
+
43
+ module.exports = { validateJsonSchema };
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('child_process');
4
+ const { EventEmitter } = require('events');
5
+ const path = require('path');
6
+ const { pathToFileURL } = require('url');
7
+
8
+ class LspClient extends EventEmitter {
9
+ constructor(options = {}) {
10
+ super();
11
+ this.command = options.command;
12
+ this.args = options.args || [];
13
+ this.cwd = options.cwd || process.cwd();
14
+ this.timeoutMs = options.timeoutMs || 10000;
15
+ this.spawnFn = options.spawnFn || spawn;
16
+ this.process = null;
17
+ this.buffer = Buffer.alloc(0);
18
+ this.nextId = 1;
19
+ this.pending = new Map();
20
+ }
21
+
22
+ start() {
23
+ if (this.process) return this;
24
+ if (!this.command) throw new Error('LSP command is required');
25
+ this.process = this.spawnFn(this.command, this.args, {
26
+ cwd: this.cwd, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, shell: false,
27
+ });
28
+ this.process.stdout.on('data', chunk => this._onData(chunk));
29
+ this.process.stderr?.on('data', chunk => this.emit('stderr', chunk.toString()));
30
+ this.process.on('error', error => this._failAll(error));
31
+ this.process.on('exit', code => { this._failAll(new Error(`LSP exited (${code})`)); this.process = null; });
32
+ return this;
33
+ }
34
+
35
+ async initialize(capabilities = {}) {
36
+ this.start();
37
+ const rootUri = pathToFileURL(path.resolve(this.cwd)).href;
38
+ const result = await this.request('initialize', {
39
+ processId: process.pid, rootUri, capabilities,
40
+ workspaceFolders: [{ uri: rootUri, name: path.basename(this.cwd) }],
41
+ });
42
+ this.notify('initialized', {});
43
+ return result;
44
+ }
45
+
46
+ request(method, params, timeoutMs = this.timeoutMs) {
47
+ this.start();
48
+ const id = this.nextId++;
49
+ this._send({ jsonrpc: '2.0', id, method, params });
50
+ return new Promise((resolve, reject) => {
51
+ const timer = setTimeout(() => {
52
+ this.pending.delete(id);
53
+ reject(new Error(`LSP request timed out: ${method}`));
54
+ }, timeoutMs);
55
+ this.pending.set(id, { resolve, reject, timer, method });
56
+ });
57
+ }
58
+
59
+ notify(method, params) { this.start(); this._send({ jsonrpc: '2.0', method, params }); }
60
+
61
+ definition(filePath, line, character) {
62
+ return this.request('textDocument/definition', {
63
+ textDocument: { uri: pathToFileURL(path.resolve(filePath)).href },
64
+ position: { line, character },
65
+ });
66
+ }
67
+
68
+ references(filePath, line, character, includeDeclaration = true) {
69
+ return this.request('textDocument/references', {
70
+ textDocument: { uri: pathToFileURL(path.resolve(filePath)).href },
71
+ position: { line, character }, context: { includeDeclaration },
72
+ });
73
+ }
74
+
75
+ async stop() {
76
+ if (!this.process) return;
77
+ try { await this.request('shutdown', null, 2000); } catch {}
78
+ try { this.notify('exit', null); } catch {}
79
+ this.process.kill();
80
+ this.process = null;
81
+ }
82
+
83
+ _send(message) {
84
+ const body = Buffer.from(JSON.stringify(message), 'utf8');
85
+ this.process.stdin.write(Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`), body]));
86
+ }
87
+
88
+ _onData(chunk) {
89
+ this.buffer = Buffer.concat([this.buffer, Buffer.from(chunk)]);
90
+ while (true) {
91
+ const headerEnd = this.buffer.indexOf('\r\n\r\n');
92
+ if (headerEnd === -1) return;
93
+ const header = this.buffer.slice(0, headerEnd).toString('ascii');
94
+ const match = header.match(/Content-Length:\s*(\d+)/i);
95
+ if (!match) { this.buffer = this.buffer.slice(headerEnd + 4); continue; }
96
+ const length = Number(match[1]);
97
+ const bodyStart = headerEnd + 4;
98
+ if (this.buffer.length < bodyStart + length) return;
99
+ const body = this.buffer.slice(bodyStart, bodyStart + length).toString('utf8');
100
+ this.buffer = this.buffer.slice(bodyStart + length);
101
+ try { this._handle(JSON.parse(body)); } catch (error) { this.emit('protocolError', error); }
102
+ }
103
+ }
104
+
105
+ _handle(message) {
106
+ if (message.id != null && this.pending.has(message.id)) {
107
+ const pending = this.pending.get(message.id);
108
+ clearTimeout(pending.timer);
109
+ this.pending.delete(message.id);
110
+ if (message.error) pending.reject(new Error(message.error.message || 'LSP error'));
111
+ else pending.resolve(message.result);
112
+ } else this.emit('notification', message);
113
+ }
114
+
115
+ _failAll(error) {
116
+ for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(error); }
117
+ this.pending.clear();
118
+ }
119
+ }
120
+
121
+ const DEFAULT_SERVERS = {
122
+ javascript: { command: 'typescript-language-server', args: ['--stdio'] },
123
+ typescript: { command: 'typescript-language-server', args: ['--stdio'] },
124
+ python: { command: 'pyright-langserver', args: ['--stdio'] },
125
+ rust: { command: 'rust-analyzer', args: [] },
126
+ go: { command: 'gopls', args: [] },
127
+ };
128
+
129
+ module.exports = { LspClient, DEFAULT_SERVERS };
@@ -0,0 +1,49 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); }
6
+ function factKey(value, category = 'general') {
7
+ const text = String(value || '').trim().toLowerCase();
8
+ const subject = text.includes(':') ? text.split(':', 1)[0] : text.split(/\s+(?:is|=|likes?|prefers?|seviyor|yaşıyor)\s+/i, 1)[0];
9
+ return `${category}:${subject.trim()}`;
10
+ }
11
+
12
+ function createMemoryRecord(input = {}, now = new Date()) {
13
+ const createdAt = now.toISOString();
14
+ const ttlMs = input.ttlMs == null ? null : Math.max(0, Number(input.ttlMs));
15
+ return {
16
+ id: input.id || `mem_${crypto.randomBytes(8).toString('hex')}`,
17
+ value: String(input.value || '').trim(), category: input.category || 'general',
18
+ score: clamp(Number(input.score ?? 5), 0, 10),
19
+ source: input.source || 'unknown', confidence: clamp(Number(input.confidence ?? 0.5), 0, 1),
20
+ createdAt: input.createdAt || createdAt, updatedAt: createdAt,
21
+ lastVerifiedAt: input.lastVerifiedAt || (input.verified ? createdAt : null),
22
+ expiresAt: ttlMs == null ? null : new Date(now.getTime() + ttlMs).toISOString(),
23
+ status: input.status || 'active', userConfirmed: input.userConfirmed === true,
24
+ };
25
+ }
26
+
27
+ function isActive(record, now = new Date()) {
28
+ if (!record || record.status === 'rejected' || record.status === 'superseded') return false;
29
+ return !record.expiresAt || new Date(record.expiresAt).getTime() > now.getTime();
30
+ }
31
+
32
+ function resolveConflict(existing, incoming) {
33
+ if (!existing) return { winner: incoming, loser: null, reason: 'new' };
34
+ if (existing.value === incoming.value) {
35
+ const merged = { ...existing, ...incoming, id: existing.id, createdAt: existing.createdAt, confidence: Math.max(existing.confidence || 0, incoming.confidence || 0) };
36
+ return { winner: merged, loser: null, reason: 'same-value' };
37
+ }
38
+ const existingWeight = (existing.userConfirmed ? 2 : 0) + (existing.confidence || 0);
39
+ const incomingWeight = (incoming.userConfirmed ? 2 : 0) + (incoming.confidence || 0);
40
+ if (incomingWeight > existingWeight) return { winner: incoming, loser: { ...existing, status: 'superseded' }, reason: 'higher-confidence' };
41
+ return { winner: existing, loser: { ...incoming, status: 'rejected' }, reason: 'existing-preferred' };
42
+ }
43
+
44
+ function promoteCandidate(record, approval) {
45
+ if (!approval?.approved) return { ok: false, error: 'user approval required' };
46
+ return { ok: true, record: { ...record, status: 'active', userConfirmed: true, lastVerifiedAt: new Date().toISOString(), confidence: Math.max(record.confidence || 0, 0.9) } };
47
+ }
48
+
49
+ module.exports = { createMemoryRecord, isActive, resolveConflict, promoteCandidate, factKey };
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const { writeJsonAtomicSync, readJsonSafeSync } = require('./atomic-file');
7
+
8
+ const PAIRINGS_FILE = path.join(os.homedir(), '.natureco', 'pairings.json');
9
+
10
+ function loadPairings() {
11
+ const value = readJsonSafeSync(PAIRINGS_FILE, []);
12
+ return Array.isArray(value) ? value : [];
13
+ }
14
+
15
+ function savePairings(pairings) {
16
+ fs.mkdirSync(path.dirname(PAIRINGS_FILE), { recursive: true, mode: 0o700 });
17
+ writeJsonAtomicSync(PAIRINGS_FILE, pairings, { mode: 0o600 });
18
+ }
19
+
20
+ function genCode() {
21
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 8).toUpperCase();
22
+ }
23
+
24
+ function findPairing(pairings, channel, senderId, status) {
25
+ const sender = String(senderId);
26
+ return pairings.find(item => item.channel === channel && String(item.senderId) === sender && item.status === status);
27
+ }
28
+
29
+ function createPairingEntry(channel, senderId) {
30
+ const sender = String(senderId);
31
+ return {
32
+ id: `pair_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`,
33
+ code: genCode(), channel, senderId: sender, nodeName: `${channel}:${sender}`,
34
+ status: 'pending', createdAt: new Date().toISOString(),
35
+ };
36
+ }
37
+
38
+ function ensurePendingPairing(channel, senderId) {
39
+ const pairings = loadPairings();
40
+ const existing = findPairing(pairings, channel, senderId, 'pending');
41
+ if (existing) return existing;
42
+ const entry = createPairingEntry(channel, senderId);
43
+ pairings.push(entry);
44
+ savePairings(pairings);
45
+ return entry;
46
+ }
47
+
48
+ function isPaired(channel, senderId) {
49
+ return !!findPairing(loadPairings(), channel, senderId, 'approved');
50
+ }
51
+
52
+ module.exports = {
53
+ PAIRINGS_FILE, loadPairings, savePairings, genCode, ensurePendingPairing, isPaired,
54
+ _internals: { findPairing, createPairingEntry },
55
+ };
@@ -265,10 +265,20 @@ function acceptProposal(proposalId) {
265
265
  if (fs.existsSync(skillDir)) {
266
266
  return { success: false, reason: `Skill zaten var: ${proposal.suggestedName}` };
267
267
  }
268
- fs.mkdirSync(skillDir, { recursive: true });
269
-
270
268
  const skillMd = generateSkillMd(proposal);
271
- fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillMd, 'utf8');
269
+ const { SkillLifecycle } = require('./skill-lifecycle');
270
+ const lifecycle = new SkillLifecycle(userSkillsDir);
271
+ const staged = lifecycle.stage({ name: proposal.suggestedName, content: skillMd, source: 'repeated-pattern', evidence: { hash: proposal.hash, count: proposal.count } });
272
+ if (!staged.ok) return { success: false, reason: staged.error };
273
+ // This function is only reached through the explicit `skills accept` command.
274
+ // Promotion stays synchronous here because validation is deterministic.
275
+ const candidate = staged.candidate;
276
+ fs.mkdirSync(skillDir, { recursive: true });
277
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), candidate.content, 'utf8');
278
+ fs.writeFileSync(path.join(skillDir, 'lifecycle.json'), JSON.stringify({
279
+ currentVersion: 1,
280
+ versions: [{ version: 1, candidateId: candidate.id, source: candidate.source, evidence: candidate.evidence, approvedBy: 'cli-user', approvedAt: new Date().toISOString(), validation: { ok: true, type: 'frontmatter' } }],
281
+ }, null, 2), 'utf8');
272
282
 
273
283
  proposals[idx].status = 'accepted';
274
284
  proposals[idx].acceptedAt = new Date().toISOString();
@@ -1,7 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const os = require('os');
4
- const { execSync } = require('child_process');
4
+ const { execFileSync } = require('child_process');
5
5
  const { PluginError, handleError } = require('./errors');
6
6
 
7
7
  const PLUGINS_DIR = path.join(os.homedir(), '.natureco', 'plugins');
@@ -122,7 +122,7 @@ function installFromNpm(pkg) {
122
122
  const tmpDir = path.join(os.tmpdir(), `nc-plugin-${Date.now()}`);
123
123
  ensureDir(tmpDir);
124
124
  try {
125
- execSync(`npm install ${pkg} --prefix "${tmpDir}" --no-save --ignore-scripts --no-audit --no-fund`, { stdio: 'pipe', timeout: 120000 });
125
+ execFileSync('npm', ['install', pkg, '--prefix', tmpDir, '--no-save', '--ignore-scripts', '--no-audit', '--no-fund'], { stdio: 'pipe', timeout: 120000, shell: false });
126
126
  const pkgDir = path.join(tmpDir, 'node_modules', pkg.split('/').pop());
127
127
  const scopedPkgDir = pkg.startsWith('@') ? path.join(tmpDir, 'node_modules', pkg) : null;
128
128
  const srcDir = scopedPkgDir && fs.existsSync(scopedPkgDir) ? scopedPkgDir : (fs.existsSync(pkgDir) ? pkgDir : null);
@@ -155,7 +155,7 @@ function installFromGit(spec) {
155
155
  const tmpDir = path.join(os.tmpdir(), `nc-plugin-git-${Date.now()}`);
156
156
  ensureDir(tmpDir);
157
157
  try {
158
- execSync(`git clone --depth 1 "${repoUrl}" "${tmpDir}"`, { stdio: 'pipe', timeout: 60000 });
158
+ execFileSync('git', ['clone', '--depth', '1', repoUrl, tmpDir], { stdio: 'pipe', timeout: 60000, shell: false });
159
159
  const slug = spec.split('/').pop().replace(/\.git$/, '');
160
160
  const dest = path.join(PLUGINS_DIR, slug);
161
161
  if (fs.existsSync(dest)) fs.rmSync(dest, { recursive: true, force: true });
@@ -21,7 +21,17 @@ const os = require('os');
21
21
  const ERROR_LOG_PATH = path.join(os.homedir(), '.natureco', 'logs', 'crash.log');
22
22
 
23
23
  let _installed = false;
24
- let _registered = { rejection: null, exception: null, warning: null };
24
+ let _registered = { rejection: null, exception: null, streamError: null };
25
+
26
+ function _removeRegisteredHandlers() {
27
+ if (_registered.rejection) process.off('unhandledRejection', _registered.rejection);
28
+ if (_registered.exception) process.off('uncaughtException', _registered.exception);
29
+ if (_registered.streamError) {
30
+ process.stdout.off('error', _registered.streamError);
31
+ process.stderr.off('error', _registered.streamError);
32
+ }
33
+ _registered = { rejection: null, exception: null, streamError: null };
34
+ }
25
35
 
26
36
  function _defaultAudit() {
27
37
  try {
@@ -63,8 +73,7 @@ function install(opts = {}) {
63
73
  const stderr = opts.stderr || ((msg) => process.stderr.write(msg));
64
74
 
65
75
  // Replace any previous handlers (idempotency for tests).
66
- if (_registered.rejection) process.off('unhandledRejection', _registered.rejection);
67
- if (_registered.exception) process.off('uncaughtException', _registered.exception);
76
+ _removeRegisteredHandlers();
68
77
 
69
78
  const onRejection = (reason) => {
70
79
  const payload = { kind: 'unhandledRejection', error: _serializeError(reason) };
@@ -106,13 +115,11 @@ function install(opts = {}) {
106
115
 
107
116
  process.on('unhandledRejection', onRejection);
108
117
  process.on('uncaughtException', onException);
109
- _registered = { rejection: onRejection, exception: onException, warning: null };
118
+ _registered = { rejection: onRejection, exception: onException, streamError: onStreamError };
110
119
  _installed = true;
111
120
 
112
121
  return function uninstall() {
113
- if (_registered.rejection) process.off('unhandledRejection', _registered.rejection);
114
- if (_registered.exception) process.off('uncaughtException', _registered.exception);
115
- _registered = { rejection: null, exception: null, warning: null };
122
+ _removeRegisteredHandlers();
116
123
  _installed = false;
117
124
  };
118
125
  }
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const { execFileSync } = require('child_process');
5
+
6
+ function checkPidFile(pidFile, processKill = process.kill) {
7
+ if (!fs.existsSync(pidFile)) return { ok: false, status: 'stopped', reason: 'pid-file-missing' };
8
+ const pid = Number(fs.readFileSync(pidFile, 'utf8').trim());
9
+ if (!Number.isInteger(pid) || pid <= 0) return { ok: false, status: 'invalid', reason: 'invalid-pid' };
10
+ try { processKill(pid, 0); return { ok: true, status: 'running', pid }; }
11
+ catch { return { ok: false, status: 'stale', reason: 'process-not-running', pid }; }
12
+ }
13
+
14
+ function checkDockerContainer(name = 'natureco-sandbox', execFile = execFileSync) {
15
+ try {
16
+ const output = execFile('docker', ['inspect', '--format', '{{json .State}}', name], { encoding: 'utf8', stdio: 'pipe', timeout: 5000 });
17
+ const state = JSON.parse(output.trim());
18
+ return { ok: state.Running === true && state.Health?.Status !== 'unhealthy', status: state.Health?.Status || (state.Running ? 'running' : 'stopped'), running: !!state.Running, restartCount: state.RestartCount || 0 };
19
+ } catch (error) { return { ok: false, status: 'unavailable', reason: error.code === 'ENOENT' ? 'docker-not-installed' : 'container-not-found' }; }
20
+ }
21
+
22
+ function aggregateRuntimeHealth(parts) {
23
+ const entries = Object.entries(parts || {});
24
+ const failed = entries.filter(([, value]) => !value?.ok).map(([name]) => name);
25
+ return { ok: failed.length === 0, status: failed.length === 0 ? 'healthy' : failed.length === entries.length ? 'down' : 'degraded', failed, checks: parts };
26
+ }
27
+
28
+ module.exports = { checkPidFile, checkDockerContainer, aggregateRuntimeHealth };
@@ -0,0 +1,90 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const crypto = require('crypto');
7
+ const { execFileSync } = require('child_process');
8
+ const { writeJsonAtomicSync, readJsonSafeSync } = require('./atomic-file');
9
+
10
+ class MacKeychainBackend {
11
+ constructor(service = 'natureco-cli', execFile = execFileSync) { this.service = service; this.execFile = execFile; }
12
+ set(key, value) { this.execFile('security', ['add-generic-password', '-U', '-a', key, '-s', this.service, '-w', value], { stdio: 'pipe' }); }
13
+ get(key) { try { return this.execFile('security', ['find-generic-password', '-a', key, '-s', this.service, '-w'], { encoding: 'utf8', stdio: 'pipe' }).trim(); } catch { return null; } }
14
+ delete(key) { try { this.execFile('security', ['delete-generic-password', '-a', key, '-s', this.service], { stdio: 'pipe' }); return true; } catch { return false; } }
15
+ }
16
+
17
+ class LinuxSecretServiceBackend {
18
+ constructor(service = 'natureco-cli', execFile = execFileSync) { this.service = service; this.execFile = execFile; }
19
+ set(key, value) { this.execFile('secret-tool', ['store', '--label', `NatureCo ${key}`, 'service', this.service, 'account', key], { input: value, stdio: ['pipe', 'pipe', 'pipe'] }); }
20
+ get(key) { try { return this.execFile('secret-tool', ['lookup', 'service', this.service, 'account', key], { encoding: 'utf8', stdio: 'pipe' }).trim() || null; } catch { return null; } }
21
+ delete(key) { try { this.execFile('secret-tool', ['clear', 'service', this.service, 'account', key], { stdio: 'pipe' }); return true; } catch { return false; } }
22
+ }
23
+
24
+ class WindowsDpapiBackend {
25
+ constructor(options = {}) { this.file = options.file || path.join(os.homedir(), '.natureco', 'secrets.dpapi.json'); this.execFile = options.execFile || execFileSync; }
26
+ _run(script, input) {
27
+ const encoded = Buffer.from(script, 'utf16le').toString('base64');
28
+ return this.execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], { input, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }).trim();
29
+ }
30
+ _load() { return readJsonSafeSync(this.file, { version: 1, entries: {} }); }
31
+ _save(data) { fs.mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 }); writeJsonAtomicSync(this.file, data, { mode: 0o600 }); }
32
+ set(name, value) {
33
+ const script = '$v=[Console]::In.ReadToEnd();$b=[Text.Encoding]::UTF8.GetBytes($v);$p=[Security.Cryptography.ProtectedData]::Protect($b,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser);[Convert]::ToBase64String($p)';
34
+ const data = this._load(); data.entries[name] = { data: this._run(script, String(value)), updatedAt: new Date().toISOString() }; this._save(data);
35
+ }
36
+ get(name) {
37
+ const entry = this._load().entries[name]; if (!entry) return null;
38
+ try {
39
+ const script = '$v=[Console]::In.ReadToEnd();$p=[Convert]::FromBase64String($v);$b=[Security.Cryptography.ProtectedData]::Unprotect($p,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser);[Text.Encoding]::UTF8.GetString($b)';
40
+ return this._run(script, entry.data);
41
+ } catch { return null; }
42
+ }
43
+ delete(name) { const data = this._load(); if (!data.entries[name]) return false; delete data.entries[name]; this._save(data); return true; }
44
+ }
45
+
46
+ class EncryptedFileBackend {
47
+ constructor(options = {}) {
48
+ if (!options.masterKey) throw new Error('NATURECO_MASTER_KEY is required for encrypted file secret storage');
49
+ this.file = options.file || path.join(os.homedir(), '.natureco', 'secrets.enc.json');
50
+ this.key = crypto.scryptSync(String(options.masterKey), 'natureco-secret-store-v1', 32);
51
+ }
52
+ _load() { return readJsonSafeSync(this.file, { version: 1, entries: {} }); }
53
+ _save(data) { fs.mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 }); writeJsonAtomicSync(this.file, data, { mode: 0o600 }); }
54
+ set(name, value) {
55
+ const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', this.key, iv);
56
+ const encrypted = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]);
57
+ const data = this._load();
58
+ data.entries[name] = { iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), data: encrypted.toString('base64'), updatedAt: new Date().toISOString() };
59
+ this._save(data);
60
+ }
61
+ get(name) {
62
+ const entry = this._load().entries[name]; if (!entry) return null;
63
+ try {
64
+ const decipher = crypto.createDecipheriv('aes-256-gcm', this.key, Buffer.from(entry.iv, 'base64'));
65
+ decipher.setAuthTag(Buffer.from(entry.tag, 'base64'));
66
+ return Buffer.concat([decipher.update(Buffer.from(entry.data, 'base64')), decipher.final()]).toString('utf8');
67
+ } catch { return null; }
68
+ }
69
+ delete(name) { const data = this._load(); if (!data.entries[name]) return false; delete data.entries[name]; this._save(data); return true; }
70
+ }
71
+
72
+ class SecretStore {
73
+ constructor(backend) { if (!backend) throw new Error('secret store backend is required'); this.backend = backend; }
74
+ _name(name) { if (!/^[A-Za-z0-9_.-]{1,128}$/.test(name || '')) throw new Error('invalid secret name'); return name; }
75
+ set(name, value) { if (value == null || value === '') throw new Error('secret value cannot be empty'); this.backend.set(this._name(name), String(value)); return { ok: true, name }; }
76
+ get(name) { const value = this.backend.get(this._name(name)); return value == null ? { ok: false, error: 'secret not found' } : { ok: true, value }; }
77
+ delete(name) { return { ok: this.backend.delete(this._name(name)) }; }
78
+ }
79
+
80
+ function createSecretStore(options = {}) {
81
+ if (options.backend) return new SecretStore(options.backend);
82
+ if (process.platform === 'darwin') return new SecretStore(new MacKeychainBackend(options.service, options.execFile));
83
+ if (process.platform === 'win32') return new SecretStore(new WindowsDpapiBackend({ file: options.file, execFile: options.execFile }));
84
+ if (process.platform === 'linux' && options.useSecretService !== false) return new SecretStore(new LinuxSecretServiceBackend(options.service, options.execFile));
85
+ const masterKey = options.masterKey || process.env.NATURECO_MASTER_KEY;
86
+ if (!masterKey) throw new Error('No OS keychain backend available. Set NATURECO_MASTER_KEY for encrypted fallback.');
87
+ return new SecretStore(new EncryptedFileBackend({ masterKey, file: options.file }));
88
+ }
89
+
90
+ module.exports = { SecretStore, MacKeychainBackend, LinuxSecretServiceBackend, WindowsDpapiBackend, EncryptedFileBackend, createSecretStore };