@rozek/nanoclaw 1.2.17
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/.claude/settings.json +1 -0
- package/.claude/skills/add-compact/SKILL.md +135 -0
- package/.claude/skills/add-discord/SKILL.md +203 -0
- package/.claude/skills/add-gmail/SKILL.md +220 -0
- package/.claude/skills/add-image-vision/SKILL.md +94 -0
- package/.claude/skills/add-ollama-tool/SKILL.md +153 -0
- package/.claude/skills/add-parallel/SKILL.md +290 -0
- package/.claude/skills/add-pdf-reader/SKILL.md +104 -0
- package/.claude/skills/add-reactions/SKILL.md +117 -0
- package/.claude/skills/add-slack/SKILL.md +207 -0
- package/.claude/skills/add-telegram/SKILL.md +222 -0
- package/.claude/skills/add-telegram-swarm/SKILL.md +384 -0
- package/.claude/skills/add-voice-transcription/SKILL.md +148 -0
- package/.claude/skills/add-whatsapp/SKILL.md +372 -0
- package/.claude/skills/convert-to-apple-container/SKILL.md +175 -0
- package/.claude/skills/customize/SKILL.md +110 -0
- package/.claude/skills/debug/SKILL.md +349 -0
- package/.claude/skills/get-qodo-rules/SKILL.md +122 -0
- package/.claude/skills/get-qodo-rules/references/output-format.md +41 -0
- package/.claude/skills/get-qodo-rules/references/pagination.md +33 -0
- package/.claude/skills/get-qodo-rules/references/repository-scope.md +26 -0
- package/.claude/skills/qodo-pr-resolver/SKILL.md +326 -0
- package/.claude/skills/qodo-pr-resolver/resources/providers.md +329 -0
- package/.claude/skills/setup/SKILL.md +218 -0
- package/.claude/skills/update-nanoclaw/SKILL.md +235 -0
- package/.claude/skills/update-skills/SKILL.md +130 -0
- package/.claude/skills/use-local-whisper/SKILL.md +152 -0
- package/.claude/skills/x-integration/SKILL.md +417 -0
- package/.claude/skills/x-integration/agent.ts +243 -0
- package/.claude/skills/x-integration/host.ts +159 -0
- package/.claude/skills/x-integration/lib/browser.ts +148 -0
- package/.claude/skills/x-integration/lib/config.ts +62 -0
- package/.claude/skills/x-integration/scripts/like.ts +56 -0
- package/.claude/skills/x-integration/scripts/post.ts +66 -0
- package/.claude/skills/x-integration/scripts/quote.ts +80 -0
- package/.claude/skills/x-integration/scripts/reply.ts +74 -0
- package/.claude/skills/x-integration/scripts/retweet.ts +62 -0
- package/.claude/skills/x-integration/scripts/setup.ts +87 -0
- package/.env.example +1 -0
- package/.github/CODEOWNERS +10 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +14 -0
- package/.github/workflows/bump-version.yml +32 -0
- package/.github/workflows/ci.yml +25 -0
- package/.github/workflows/merge-forward-skills.yml +160 -0
- package/.github/workflows/update-tokens.yml +42 -0
- package/.husky/pre-commit +1 -0
- package/.mcp.json +3 -0
- package/.nvmrc +1 -0
- package/.prettierrc +3 -0
- package/CHANGELOG.md +8 -0
- package/CLAUDE.md +64 -0
- package/CONTRIBUTING.md +23 -0
- package/CONTRIBUTORS.md +15 -0
- package/LICENSE +21 -0
- package/NanoClaw_with_Web-Support.md +290 -0
- package/README.md +261 -0
- package/README_zh.md +200 -0
- package/assets/nanoclaw-favicon.png +0 -0
- package/assets/nanoclaw-icon.png +0 -0
- package/assets/nanoclaw-logo-dark.png +0 -0
- package/assets/nanoclaw-logo.png +0 -0
- package/assets/nanoclaw-profile.jpeg +0 -0
- package/assets/nanoclaw-sales.png +0 -0
- package/assets/social-preview.jpg +0 -0
- package/config-examples/mount-allowlist.json +25 -0
- package/container/Dockerfile +70 -0
- package/container/agent-runner/package-lock.json +1524 -0
- package/container/agent-runner/package.json +21 -0
- package/container/agent-runner/src/index.ts +558 -0
- package/container/agent-runner/src/ipc-mcp-stdio.ts +338 -0
- package/container/agent-runner/tsconfig.json +15 -0
- package/container/build.sh +23 -0
- package/container/skills/agent-browser/SKILL.md +159 -0
- package/container/skills/capabilities/SKILL.md +100 -0
- package/container/skills/status/SKILL.md +104 -0
- package/dist/channels/index.d.ts +2 -0
- package/dist/channels/index.d.ts.map +1 -0
- package/dist/channels/index.js +9 -0
- package/dist/channels/index.js.map +1 -0
- package/dist/channels/registry.d.ts +13 -0
- package/dist/channels/registry.d.ts.map +1 -0
- package/dist/channels/registry.js +11 -0
- package/dist/channels/registry.js.map +1 -0
- package/dist/channels/registry.test.d.ts +2 -0
- package/dist/channels/registry.test.d.ts.map +1 -0
- package/dist/channels/registry.test.js +32 -0
- package/dist/channels/registry.test.js.map +1 -0
- package/dist/channels/web.d.ts +2 -0
- package/dist/channels/web.d.ts.map +1 -0
- package/dist/channels/web.js +1738 -0
- package/dist/channels/web.js.map +1 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +182 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +36 -0
- package/dist/config.js.map +1 -0
- package/dist/container-runner.d.ts +44 -0
- package/dist/container-runner.d.ts.map +1 -0
- package/dist/container-runner.js +467 -0
- package/dist/container-runner.js.map +1 -0
- package/dist/container-runner.test.d.ts +2 -0
- package/dist/container-runner.test.d.ts.map +1 -0
- package/dist/container-runner.test.js +150 -0
- package/dist/container-runner.test.js.map +1 -0
- package/dist/container-runtime.d.ts +22 -0
- package/dist/container-runtime.d.ts.map +1 -0
- package/dist/container-runtime.js +96 -0
- package/dist/container-runtime.js.map +1 -0
- package/dist/container-runtime.test.d.ts +2 -0
- package/dist/container-runtime.test.d.ts.map +1 -0
- package/dist/container-runtime.test.js +93 -0
- package/dist/container-runtime.test.js.map +1 -0
- package/dist/credential-proxy.d.ts +21 -0
- package/dist/credential-proxy.d.ts.map +1 -0
- package/dist/credential-proxy.js +95 -0
- package/dist/credential-proxy.js.map +1 -0
- package/dist/credential-proxy.test.d.ts +2 -0
- package/dist/credential-proxy.test.d.ts.map +1 -0
- package/dist/credential-proxy.test.js +134 -0
- package/dist/credential-proxy.test.js.map +1 -0
- package/dist/db.d.ts +115 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +549 -0
- package/dist/db.js.map +1 -0
- package/dist/db.test.d.ts +2 -0
- package/dist/db.test.d.ts.map +1 -0
- package/dist/db.test.js +360 -0
- package/dist/db.test.js.map +1 -0
- package/dist/env.d.ts +8 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +42 -0
- package/dist/env.js.map +1 -0
- package/dist/formatting.test.d.ts +2 -0
- package/dist/formatting.test.d.ts.map +1 -0
- package/dist/formatting.test.js +183 -0
- package/dist/formatting.test.js.map +1 -0
- package/dist/group-folder.d.ts +5 -0
- package/dist/group-folder.d.ts.map +1 -0
- package/dist/group-folder.js +44 -0
- package/dist/group-folder.js.map +1 -0
- package/dist/group-folder.test.d.ts +2 -0
- package/dist/group-folder.test.d.ts.map +1 -0
- package/dist/group-folder.test.js +29 -0
- package/dist/group-folder.test.js.map +1 -0
- package/dist/group-queue.d.ts +34 -0
- package/dist/group-queue.d.ts.map +1 -0
- package/dist/group-queue.js +263 -0
- package/dist/group-queue.js.map +1 -0
- package/dist/group-queue.test.d.ts +2 -0
- package/dist/group-queue.test.d.ts.map +1 -0
- package/dist/group-queue.test.js +341 -0
- package/dist/group-queue.test.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +518 -0
- package/dist/index.js.map +1 -0
- package/dist/ipc-auth.test.d.ts +2 -0
- package/dist/ipc-auth.test.d.ts.map +1 -0
- package/dist/ipc-auth.test.js +434 -0
- package/dist/ipc-auth.test.js.map +1 -0
- package/dist/ipc.d.ts +32 -0
- package/dist/ipc.d.ts.map +1 -0
- package/dist/ipc.js +311 -0
- package/dist/ipc.js.map +1 -0
- package/dist/logger.d.ts +3 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +14 -0
- package/dist/logger.js.map +1 -0
- package/dist/mount-security.d.ts +34 -0
- package/dist/mount-security.d.ts.map +1 -0
- package/dist/mount-security.js +325 -0
- package/dist/mount-security.js.map +1 -0
- package/dist/remote-control.d.ts +32 -0
- package/dist/remote-control.d.ts.map +1 -0
- package/dist/remote-control.js +185 -0
- package/dist/remote-control.js.map +1 -0
- package/dist/remote-control.test.d.ts +2 -0
- package/dist/remote-control.test.d.ts.map +1 -0
- package/dist/remote-control.test.js +321 -0
- package/dist/remote-control.test.js.map +1 -0
- package/dist/router.d.ts +8 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +37 -0
- package/dist/router.js.map +1 -0
- package/dist/routing.test.d.ts +2 -0
- package/dist/routing.test.d.ts.map +1 -0
- package/dist/routing.test.js +81 -0
- package/dist/routing.test.js.map +1 -0
- package/dist/sender-allowlist.d.ts +14 -0
- package/dist/sender-allowlist.d.ts.map +1 -0
- package/dist/sender-allowlist.js +79 -0
- package/dist/sender-allowlist.js.map +1 -0
- package/dist/sender-allowlist.test.d.ts +2 -0
- package/dist/sender-allowlist.test.d.ts.map +1 -0
- package/dist/sender-allowlist.test.js +186 -0
- package/dist/sender-allowlist.test.js.map +1 -0
- package/dist/session-commands.d.ts +47 -0
- package/dist/session-commands.d.ts.map +1 -0
- package/dist/session-commands.js +102 -0
- package/dist/session-commands.js.map +1 -0
- package/dist/session-commands.test.d.ts +2 -0
- package/dist/session-commands.test.d.ts.map +1 -0
- package/dist/session-commands.test.js +190 -0
- package/dist/session-commands.test.js.map +1 -0
- package/dist/task-scheduler.d.ts +22 -0
- package/dist/task-scheduler.d.ts.map +1 -0
- package/dist/task-scheduler.js +210 -0
- package/dist/task-scheduler.js.map +1 -0
- package/dist/task-scheduler.test.d.ts +2 -0
- package/dist/task-scheduler.test.d.ts.map +1 -0
- package/dist/task-scheduler.test.js +107 -0
- package/dist/task-scheduler.test.js.map +1 -0
- package/dist/timezone.d.ts +6 -0
- package/dist/timezone.d.ts.map +1 -0
- package/dist/timezone.js +17 -0
- package/dist/timezone.js.map +1 -0
- package/dist/timezone.test.d.ts +2 -0
- package/dist/timezone.test.d.ts.map +1 -0
- package/dist/timezone.test.js +23 -0
- package/dist/timezone.test.js.map +1 -0
- package/dist/types.d.ts +78 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/docs/APPLE-CONTAINER-NETWORKING.md +90 -0
- package/docs/DEBUG_CHECKLIST.md +143 -0
- package/docs/REQUIREMENTS.md +196 -0
- package/docs/SDK_DEEP_DIVE.md +643 -0
- package/docs/SECURITY.md +122 -0
- package/docs/SPEC.md +785 -0
- package/docs/docker-sandboxes.md +359 -0
- package/docs/nanoclaw-architecture-final.md +1063 -0
- package/docs/nanorepo-architecture.md +168 -0
- package/docs/skills-as-branches.md +662 -0
- package/groups/global/CLAUDE.md +58 -0
- package/groups/main/CLAUDE.md +246 -0
- package/launchd/com.nanoclaw.plist +32 -0
- package/package.json +45 -0
- package/repo-tokens/README.md +113 -0
- package/repo-tokens/action.yml +186 -0
- package/repo-tokens/badge.svg +23 -0
- package/repo-tokens/examples/green.svg +14 -0
- package/repo-tokens/examples/red.svg +14 -0
- package/repo-tokens/examples/yellow-green.svg +14 -0
- package/repo-tokens/examples/yellow.svg +14 -0
- package/scripts/run-migrations.ts +105 -0
- package/setup/container.ts +144 -0
- package/setup/environment.test.ts +121 -0
- package/setup/environment.ts +94 -0
- package/setup/groups.ts +229 -0
- package/setup/index.ts +58 -0
- package/setup/mounts.ts +115 -0
- package/setup/platform.test.ts +120 -0
- package/setup/platform.ts +132 -0
- package/setup/register.test.ts +257 -0
- package/setup/register.ts +177 -0
- package/setup/service.test.ts +187 -0
- package/setup/service.ts +362 -0
- package/setup/status.ts +16 -0
- package/setup/verify.ts +192 -0
- package/setup.sh +161 -0
- package/src/channels/index.ts +12 -0
- package/src/channels/registry.test.ts +42 -0
- package/src/channels/registry.ts +32 -0
- package/src/channels/web.ts +1856 -0
- package/src/cli.ts +209 -0
- package/src/config.ts +73 -0
- package/src/container-runner.test.ts +210 -0
- package/src/container-runner.ts +707 -0
- package/src/container-runtime.test.ts +149 -0
- package/src/container-runtime.ts +127 -0
- package/src/credential-proxy.test.ts +192 -0
- package/src/credential-proxy.ts +125 -0
- package/src/db.test.ts +484 -0
- package/src/db.ts +803 -0
- package/src/env.ts +42 -0
- package/src/formatting.test.ts +256 -0
- package/src/group-folder.test.ts +43 -0
- package/src/group-folder.ts +44 -0
- package/src/group-queue.test.ts +484 -0
- package/src/group-queue.ts +365 -0
- package/src/index.ts +731 -0
- package/src/ipc-auth.test.ts +679 -0
- package/src/ipc.ts +461 -0
- package/src/logger.ts +16 -0
- package/src/mount-security.ts +419 -0
- package/src/remote-control.test.ts +397 -0
- package/src/remote-control.ts +224 -0
- package/src/router.ts +52 -0
- package/src/routing.test.ts +170 -0
- package/src/sender-allowlist.test.ts +216 -0
- package/src/sender-allowlist.ts +128 -0
- package/src/session-commands.test.ts +247 -0
- package/src/session-commands.ts +163 -0
- package/src/task-scheduler.test.ts +129 -0
- package/src/task-scheduler.ts +295 -0
- package/src/timezone.test.ts +29 -0
- package/src/timezone.ts +16 -0
- package/src/types.ts +107 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +7 -0
- package/vitest.skills.config.ts +7 -0
package/setup/groups.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step: groups — Fetch group metadata from messaging platforms, write to DB.
|
|
3
|
+
* WhatsApp requires an upfront sync (Baileys groupFetchAllParticipating).
|
|
4
|
+
* Other channels discover group names at runtime — this step auto-skips for them.
|
|
5
|
+
* Replaces 05-sync-groups.sh + 05b-list-groups.sh
|
|
6
|
+
*/
|
|
7
|
+
import { execSync } from 'child_process';
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
|
|
11
|
+
import Database from 'better-sqlite3';
|
|
12
|
+
|
|
13
|
+
import { STORE_DIR } from '../src/config.js';
|
|
14
|
+
import { logger } from '../src/logger.js';
|
|
15
|
+
import { emitStatus } from './status.js';
|
|
16
|
+
|
|
17
|
+
function parseArgs(args: string[]): { list: boolean; limit: number } {
|
|
18
|
+
let list = false;
|
|
19
|
+
let limit = 30;
|
|
20
|
+
for (let i = 0; i < args.length; i++) {
|
|
21
|
+
if (args[i] === '--list') list = true;
|
|
22
|
+
if (args[i] === '--limit' && args[i + 1]) {
|
|
23
|
+
limit = parseInt(args[i + 1], 10);
|
|
24
|
+
i++;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { list, limit };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function run(args: string[]): Promise<void> {
|
|
31
|
+
const projectRoot = process.cwd();
|
|
32
|
+
const { list, limit } = parseArgs(args);
|
|
33
|
+
|
|
34
|
+
if (list) {
|
|
35
|
+
await listGroups(limit);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
await syncGroups(projectRoot);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function listGroups(limit: number): Promise<void> {
|
|
43
|
+
const dbPath = path.join(STORE_DIR, 'messages.db');
|
|
44
|
+
|
|
45
|
+
if (!fs.existsSync(dbPath)) {
|
|
46
|
+
console.error('ERROR: database not found');
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const db = new Database(dbPath, { readonly: true });
|
|
51
|
+
const rows = db
|
|
52
|
+
.prepare(
|
|
53
|
+
`SELECT jid, name FROM chats
|
|
54
|
+
WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__' AND name <> jid
|
|
55
|
+
ORDER BY last_message_time DESC
|
|
56
|
+
LIMIT ?`,
|
|
57
|
+
)
|
|
58
|
+
.all(limit) as Array<{ jid: string; name: string }>;
|
|
59
|
+
db.close();
|
|
60
|
+
|
|
61
|
+
for (const row of rows) {
|
|
62
|
+
console.log(`${row.jid}|${row.name}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function syncGroups(projectRoot: string): Promise<void> {
|
|
67
|
+
// Only WhatsApp needs an upfront group sync; other channels resolve names at runtime.
|
|
68
|
+
// Detect WhatsApp by checking for auth credentials on disk.
|
|
69
|
+
const authDir = path.join(projectRoot, 'store', 'auth');
|
|
70
|
+
const hasWhatsAppAuth =
|
|
71
|
+
fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
|
|
72
|
+
|
|
73
|
+
if (!hasWhatsAppAuth) {
|
|
74
|
+
logger.info('WhatsApp auth not found — skipping group sync');
|
|
75
|
+
emitStatus('SYNC_GROUPS', {
|
|
76
|
+
BUILD: 'skipped',
|
|
77
|
+
SYNC: 'skipped',
|
|
78
|
+
GROUPS_IN_DB: 0,
|
|
79
|
+
REASON: 'whatsapp_not_configured',
|
|
80
|
+
STATUS: 'success',
|
|
81
|
+
LOG: 'logs/setup.log',
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Build TypeScript first
|
|
87
|
+
logger.info('Building TypeScript');
|
|
88
|
+
let buildOk = false;
|
|
89
|
+
try {
|
|
90
|
+
execSync('npm run build', {
|
|
91
|
+
cwd: projectRoot,
|
|
92
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
93
|
+
});
|
|
94
|
+
buildOk = true;
|
|
95
|
+
logger.info('Build succeeded');
|
|
96
|
+
} catch {
|
|
97
|
+
logger.error('Build failed');
|
|
98
|
+
emitStatus('SYNC_GROUPS', {
|
|
99
|
+
BUILD: 'failed',
|
|
100
|
+
SYNC: 'skipped',
|
|
101
|
+
GROUPS_IN_DB: 0,
|
|
102
|
+
STATUS: 'failed',
|
|
103
|
+
ERROR: 'build_failed',
|
|
104
|
+
LOG: 'logs/setup.log',
|
|
105
|
+
});
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Run sync script via a temp file to avoid shell escaping issues with node -e
|
|
110
|
+
logger.info('Fetching group metadata');
|
|
111
|
+
let syncOk = false;
|
|
112
|
+
try {
|
|
113
|
+
const syncScript = `
|
|
114
|
+
import makeWASocket, { useMultiFileAuthState, makeCacheableSignalKeyStore, Browsers } from '@whiskeysockets/baileys';
|
|
115
|
+
import pino from 'pino';
|
|
116
|
+
import path from 'path';
|
|
117
|
+
import fs from 'fs';
|
|
118
|
+
import Database from 'better-sqlite3';
|
|
119
|
+
|
|
120
|
+
const logger = pino({ level: 'silent' });
|
|
121
|
+
const authDir = path.join('store', 'auth');
|
|
122
|
+
const dbPath = path.join('store', 'messages.db');
|
|
123
|
+
|
|
124
|
+
if (!fs.existsSync(authDir)) {
|
|
125
|
+
console.error('NO_AUTH');
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const db = new Database(dbPath);
|
|
130
|
+
db.pragma('journal_mode = WAL');
|
|
131
|
+
db.exec('CREATE TABLE IF NOT EXISTS chats (jid TEXT PRIMARY KEY, name TEXT, last_message_time TEXT)');
|
|
132
|
+
|
|
133
|
+
const upsert = db.prepare(
|
|
134
|
+
'INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) ON CONFLICT(jid) DO UPDATE SET name = excluded.name'
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const { state, saveCreds } = await useMultiFileAuthState(authDir);
|
|
138
|
+
|
|
139
|
+
const sock = makeWASocket({
|
|
140
|
+
auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, logger) },
|
|
141
|
+
printQRInTerminal: false,
|
|
142
|
+
logger,
|
|
143
|
+
browser: Browsers.macOS('Chrome'),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const timeout = setTimeout(() => {
|
|
147
|
+
console.error('TIMEOUT');
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}, 30000);
|
|
150
|
+
|
|
151
|
+
sock.ev.on('creds.update', saveCreds);
|
|
152
|
+
|
|
153
|
+
sock.ev.on('connection.update', async (update) => {
|
|
154
|
+
if (update.connection === 'open') {
|
|
155
|
+
try {
|
|
156
|
+
const groups = await sock.groupFetchAllParticipating();
|
|
157
|
+
const now = new Date().toISOString();
|
|
158
|
+
let count = 0;
|
|
159
|
+
for (const [jid, metadata] of Object.entries(groups)) {
|
|
160
|
+
if (metadata.subject) {
|
|
161
|
+
upsert.run(jid, metadata.subject, now);
|
|
162
|
+
count++;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
console.log('SYNCED:' + count);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
console.error('FETCH_ERROR:' + err.message);
|
|
168
|
+
} finally {
|
|
169
|
+
clearTimeout(timeout);
|
|
170
|
+
sock.end(undefined);
|
|
171
|
+
db.close();
|
|
172
|
+
process.exit(0);
|
|
173
|
+
}
|
|
174
|
+
} else if (update.connection === 'close') {
|
|
175
|
+
clearTimeout(timeout);
|
|
176
|
+
console.error('CONNECTION_CLOSED');
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
`;
|
|
181
|
+
|
|
182
|
+
const tmpScript = path.join(projectRoot, '.tmp-group-sync.mjs');
|
|
183
|
+
fs.writeFileSync(tmpScript, syncScript, 'utf-8');
|
|
184
|
+
try {
|
|
185
|
+
const output = execSync(`node ${tmpScript}`, {
|
|
186
|
+
cwd: projectRoot,
|
|
187
|
+
encoding: 'utf-8',
|
|
188
|
+
timeout: 45000,
|
|
189
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
190
|
+
});
|
|
191
|
+
syncOk = output.includes('SYNCED:');
|
|
192
|
+
logger.info({ output: output.trim() }, 'Sync output');
|
|
193
|
+
} finally {
|
|
194
|
+
try { fs.unlinkSync(tmpScript); } catch { /* ignore cleanup errors */ }
|
|
195
|
+
}
|
|
196
|
+
} catch (err) {
|
|
197
|
+
logger.error({ err }, 'Sync failed');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Count groups in DB using better-sqlite3 (no sqlite3 CLI)
|
|
201
|
+
let groupsInDb = 0;
|
|
202
|
+
const dbPath = path.join(STORE_DIR, 'messages.db');
|
|
203
|
+
if (fs.existsSync(dbPath)) {
|
|
204
|
+
try {
|
|
205
|
+
const db = new Database(dbPath, { readonly: true });
|
|
206
|
+
const row = db
|
|
207
|
+
.prepare(
|
|
208
|
+
"SELECT COUNT(*) as count FROM chats WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__'",
|
|
209
|
+
)
|
|
210
|
+
.get() as { count: number };
|
|
211
|
+
groupsInDb = row.count;
|
|
212
|
+
db.close();
|
|
213
|
+
} catch {
|
|
214
|
+
// DB may not exist yet
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const status = syncOk ? 'success' : 'failed';
|
|
219
|
+
|
|
220
|
+
emitStatus('SYNC_GROUPS', {
|
|
221
|
+
BUILD: buildOk ? 'success' : 'failed',
|
|
222
|
+
SYNC: syncOk ? 'success' : 'failed',
|
|
223
|
+
GROUPS_IN_DB: groupsInDb,
|
|
224
|
+
STATUS: status,
|
|
225
|
+
LOG: 'logs/setup.log',
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
if (status === 'failed') process.exit(1);
|
|
229
|
+
}
|
package/setup/index.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup CLI entry point.
|
|
3
|
+
* Usage: npx tsx setup/index.ts --step <name> [args...]
|
|
4
|
+
*/
|
|
5
|
+
import { logger } from '../src/logger.js';
|
|
6
|
+
import { emitStatus } from './status.js';
|
|
7
|
+
|
|
8
|
+
const STEPS: Record<
|
|
9
|
+
string,
|
|
10
|
+
() => Promise<{ run: (args: string[]) => Promise<void> }>
|
|
11
|
+
> = {
|
|
12
|
+
environment: () => import('./environment.js'),
|
|
13
|
+
container: () => import('./container.js'),
|
|
14
|
+
groups: () => import('./groups.js'),
|
|
15
|
+
register: () => import('./register.js'),
|
|
16
|
+
mounts: () => import('./mounts.js'),
|
|
17
|
+
service: () => import('./service.js'),
|
|
18
|
+
verify: () => import('./verify.js'),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
async function main(): Promise<void> {
|
|
22
|
+
const args = process.argv.slice(2);
|
|
23
|
+
const stepIdx = args.indexOf('--step');
|
|
24
|
+
|
|
25
|
+
if (stepIdx === -1 || !args[stepIdx + 1]) {
|
|
26
|
+
console.error(
|
|
27
|
+
`Usage: npx tsx setup/index.ts --step <${Object.keys(STEPS).join('|')}> [args...]`,
|
|
28
|
+
);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const stepName = args[stepIdx + 1];
|
|
33
|
+
const stepArgs = args.filter(
|
|
34
|
+
(a, i) => i !== stepIdx && i !== stepIdx + 1 && a !== '--',
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const loader = STEPS[stepName];
|
|
38
|
+
if (!loader) {
|
|
39
|
+
console.error(`Unknown step: ${stepName}`);
|
|
40
|
+
console.error(`Available steps: ${Object.keys(STEPS).join(', ')}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const mod = await loader();
|
|
46
|
+
await mod.run(stepArgs);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
49
|
+
logger.error({ err, step: stepName }, 'Setup step failed');
|
|
50
|
+
emitStatus(stepName.toUpperCase(), {
|
|
51
|
+
STATUS: 'failed',
|
|
52
|
+
ERROR: message,
|
|
53
|
+
});
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
main();
|
package/setup/mounts.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step: mounts — Write mount allowlist config file.
|
|
3
|
+
* Replaces 07-configure-mounts.sh
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
|
|
9
|
+
import { logger } from '../src/logger.js';
|
|
10
|
+
import { isRoot } from './platform.js';
|
|
11
|
+
import { emitStatus } from './status.js';
|
|
12
|
+
|
|
13
|
+
function parseArgs(args: string[]): { empty: boolean; json: string } {
|
|
14
|
+
let empty = false;
|
|
15
|
+
let json = '';
|
|
16
|
+
for (let i = 0; i < args.length; i++) {
|
|
17
|
+
if (args[i] === '--empty') empty = true;
|
|
18
|
+
if (args[i] === '--json' && args[i + 1]) {
|
|
19
|
+
json = args[i + 1];
|
|
20
|
+
i++;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { empty, json };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function run(args: string[]): Promise<void> {
|
|
27
|
+
const { empty, json } = parseArgs(args);
|
|
28
|
+
const homeDir = os.homedir();
|
|
29
|
+
const configDir = path.join(homeDir, '.config', 'nanoclaw');
|
|
30
|
+
const configFile = path.join(configDir, 'mount-allowlist.json');
|
|
31
|
+
|
|
32
|
+
if (isRoot()) {
|
|
33
|
+
logger.warn(
|
|
34
|
+
'Running as root — mount allowlist will be written to root home directory',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
39
|
+
|
|
40
|
+
let allowedRoots = 0;
|
|
41
|
+
let nonMainReadOnly = 'true';
|
|
42
|
+
|
|
43
|
+
if (empty) {
|
|
44
|
+
logger.info('Writing empty mount allowlist');
|
|
45
|
+
const emptyConfig = {
|
|
46
|
+
allowedRoots: [],
|
|
47
|
+
blockedPatterns: [],
|
|
48
|
+
nonMainReadOnly: true,
|
|
49
|
+
};
|
|
50
|
+
fs.writeFileSync(configFile, JSON.stringify(emptyConfig, null, 2) + '\n');
|
|
51
|
+
} else if (json) {
|
|
52
|
+
// Validate JSON with JSON.parse (not piped through shell)
|
|
53
|
+
let parsed: { allowedRoots?: unknown[]; nonMainReadOnly?: boolean };
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(json);
|
|
56
|
+
} catch {
|
|
57
|
+
logger.error('Invalid JSON input');
|
|
58
|
+
emitStatus('CONFIGURE_MOUNTS', {
|
|
59
|
+
PATH: configFile,
|
|
60
|
+
ALLOWED_ROOTS: 0,
|
|
61
|
+
NON_MAIN_READ_ONLY: 'unknown',
|
|
62
|
+
STATUS: 'failed',
|
|
63
|
+
ERROR: 'invalid_json',
|
|
64
|
+
LOG: 'logs/setup.log',
|
|
65
|
+
});
|
|
66
|
+
process.exit(4);
|
|
67
|
+
return; // unreachable but satisfies TS
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
fs.writeFileSync(configFile, JSON.stringify(parsed, null, 2) + '\n');
|
|
71
|
+
allowedRoots = Array.isArray(parsed.allowedRoots)
|
|
72
|
+
? parsed.allowedRoots.length
|
|
73
|
+
: 0;
|
|
74
|
+
nonMainReadOnly = parsed.nonMainReadOnly === false ? 'false' : 'true';
|
|
75
|
+
} else {
|
|
76
|
+
// Read from stdin
|
|
77
|
+
logger.info('Reading mount allowlist from stdin');
|
|
78
|
+
const input = fs.readFileSync(0, 'utf-8');
|
|
79
|
+
let parsed: { allowedRoots?: unknown[]; nonMainReadOnly?: boolean };
|
|
80
|
+
try {
|
|
81
|
+
parsed = JSON.parse(input);
|
|
82
|
+
} catch {
|
|
83
|
+
logger.error('Invalid JSON from stdin');
|
|
84
|
+
emitStatus('CONFIGURE_MOUNTS', {
|
|
85
|
+
PATH: configFile,
|
|
86
|
+
ALLOWED_ROOTS: 0,
|
|
87
|
+
NON_MAIN_READ_ONLY: 'unknown',
|
|
88
|
+
STATUS: 'failed',
|
|
89
|
+
ERROR: 'invalid_json',
|
|
90
|
+
LOG: 'logs/setup.log',
|
|
91
|
+
});
|
|
92
|
+
process.exit(4);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
fs.writeFileSync(configFile, JSON.stringify(parsed, null, 2) + '\n');
|
|
97
|
+
allowedRoots = Array.isArray(parsed.allowedRoots)
|
|
98
|
+
? parsed.allowedRoots.length
|
|
99
|
+
: 0;
|
|
100
|
+
nonMainReadOnly = parsed.nonMainReadOnly === false ? 'false' : 'true';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
logger.info(
|
|
104
|
+
{ configFile, allowedRoots, nonMainReadOnly },
|
|
105
|
+
'Allowlist configured',
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
emitStatus('CONFIGURE_MOUNTS', {
|
|
109
|
+
PATH: configFile,
|
|
110
|
+
ALLOWED_ROOTS: allowedRoots,
|
|
111
|
+
NON_MAIN_READ_ONLY: nonMainReadOnly,
|
|
112
|
+
STATUS: 'success',
|
|
113
|
+
LOG: 'logs/setup.log',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getPlatform,
|
|
5
|
+
isWSL,
|
|
6
|
+
isRoot,
|
|
7
|
+
isHeadless,
|
|
8
|
+
hasSystemd,
|
|
9
|
+
getServiceManager,
|
|
10
|
+
commandExists,
|
|
11
|
+
getNodeVersion,
|
|
12
|
+
getNodeMajorVersion,
|
|
13
|
+
} from './platform.js';
|
|
14
|
+
|
|
15
|
+
// --- getPlatform ---
|
|
16
|
+
|
|
17
|
+
describe('getPlatform', () => {
|
|
18
|
+
it('returns a valid platform string', () => {
|
|
19
|
+
const result = getPlatform();
|
|
20
|
+
expect(['macos', 'linux', 'unknown']).toContain(result);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// --- isWSL ---
|
|
25
|
+
|
|
26
|
+
describe('isWSL', () => {
|
|
27
|
+
it('returns a boolean', () => {
|
|
28
|
+
expect(typeof isWSL()).toBe('boolean');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('checks /proc/version for WSL markers', () => {
|
|
32
|
+
// On non-WSL Linux, should return false
|
|
33
|
+
// On WSL, should return true
|
|
34
|
+
// Just verify it doesn't throw
|
|
35
|
+
const result = isWSL();
|
|
36
|
+
expect(typeof result).toBe('boolean');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// --- isRoot ---
|
|
41
|
+
|
|
42
|
+
describe('isRoot', () => {
|
|
43
|
+
it('returns a boolean', () => {
|
|
44
|
+
expect(typeof isRoot()).toBe('boolean');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// --- isHeadless ---
|
|
49
|
+
|
|
50
|
+
describe('isHeadless', () => {
|
|
51
|
+
it('returns a boolean', () => {
|
|
52
|
+
expect(typeof isHeadless()).toBe('boolean');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// --- hasSystemd ---
|
|
57
|
+
|
|
58
|
+
describe('hasSystemd', () => {
|
|
59
|
+
it('returns a boolean', () => {
|
|
60
|
+
expect(typeof hasSystemd()).toBe('boolean');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('checks /proc/1/comm', () => {
|
|
64
|
+
// On systemd systems, should return true
|
|
65
|
+
// Just verify it doesn't throw
|
|
66
|
+
const result = hasSystemd();
|
|
67
|
+
expect(typeof result).toBe('boolean');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// --- getServiceManager ---
|
|
72
|
+
|
|
73
|
+
describe('getServiceManager', () => {
|
|
74
|
+
it('returns a valid service manager', () => {
|
|
75
|
+
const result = getServiceManager();
|
|
76
|
+
expect(['launchd', 'systemd', 'none']).toContain(result);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('matches the detected platform', () => {
|
|
80
|
+
const platform = getPlatform();
|
|
81
|
+
const result = getServiceManager();
|
|
82
|
+
if (platform === 'macos') {
|
|
83
|
+
expect(result).toBe('launchd');
|
|
84
|
+
} else {
|
|
85
|
+
expect(['systemd', 'none']).toContain(result);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// --- commandExists ---
|
|
91
|
+
|
|
92
|
+
describe('commandExists', () => {
|
|
93
|
+
it('returns true for node', () => {
|
|
94
|
+
expect(commandExists('node')).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('returns false for nonexistent command', () => {
|
|
98
|
+
expect(commandExists('this_command_does_not_exist_xyz_123')).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// --- getNodeVersion ---
|
|
103
|
+
|
|
104
|
+
describe('getNodeVersion', () => {
|
|
105
|
+
it('returns a version string', () => {
|
|
106
|
+
const version = getNodeVersion();
|
|
107
|
+
expect(version).not.toBeNull();
|
|
108
|
+
expect(version).toMatch(/^\d+\.\d+\.\d+/);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// --- getNodeMajorVersion ---
|
|
113
|
+
|
|
114
|
+
describe('getNodeMajorVersion', () => {
|
|
115
|
+
it('returns at least 20', () => {
|
|
116
|
+
const major = getNodeMajorVersion();
|
|
117
|
+
expect(major).not.toBeNull();
|
|
118
|
+
expect(major!).toBeGreaterThanOrEqual(20);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform detection utilities for NanoClaw setup.
|
|
3
|
+
*/
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
|
|
8
|
+
export type Platform = 'macos' | 'linux' | 'unknown';
|
|
9
|
+
export type ServiceManager = 'launchd' | 'systemd' | 'none';
|
|
10
|
+
|
|
11
|
+
export function getPlatform(): Platform {
|
|
12
|
+
const platform = os.platform();
|
|
13
|
+
if (platform === 'darwin') return 'macos';
|
|
14
|
+
if (platform === 'linux') return 'linux';
|
|
15
|
+
return 'unknown';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isWSL(): boolean {
|
|
19
|
+
if (os.platform() !== 'linux') return false;
|
|
20
|
+
try {
|
|
21
|
+
const release = fs.readFileSync('/proc/version', 'utf-8').toLowerCase();
|
|
22
|
+
return release.includes('microsoft') || release.includes('wsl');
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isRoot(): boolean {
|
|
29
|
+
return process.getuid?.() === 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isHeadless(): boolean {
|
|
33
|
+
// No display server available
|
|
34
|
+
if (getPlatform() === 'linux') {
|
|
35
|
+
return !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
|
|
36
|
+
}
|
|
37
|
+
// macOS is never headless in practice (even SSH sessions can open URLs)
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function hasSystemd(): boolean {
|
|
42
|
+
if (getPlatform() !== 'linux') return false;
|
|
43
|
+
try {
|
|
44
|
+
// Check if systemd is PID 1
|
|
45
|
+
const init = fs.readFileSync('/proc/1/comm', 'utf-8').trim();
|
|
46
|
+
return init === 'systemd';
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Open a URL in the default browser, cross-platform.
|
|
54
|
+
* Returns true if the command was attempted, false if no method available.
|
|
55
|
+
*/
|
|
56
|
+
export function openBrowser(url: string): boolean {
|
|
57
|
+
try {
|
|
58
|
+
const platform = getPlatform();
|
|
59
|
+
if (platform === 'macos') {
|
|
60
|
+
execSync(`open ${JSON.stringify(url)}`, { stdio: 'ignore' });
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
if (platform === 'linux') {
|
|
64
|
+
// Try xdg-open first, then wslview for WSL
|
|
65
|
+
if (commandExists('xdg-open')) {
|
|
66
|
+
execSync(`xdg-open ${JSON.stringify(url)}`, { stdio: 'ignore' });
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
if (isWSL() && commandExists('wslview')) {
|
|
70
|
+
execSync(`wslview ${JSON.stringify(url)}`, { stdio: 'ignore' });
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
// WSL without wslview: try cmd.exe
|
|
74
|
+
if (isWSL()) {
|
|
75
|
+
try {
|
|
76
|
+
execSync(`cmd.exe /c start "" ${JSON.stringify(url)}`, {
|
|
77
|
+
stdio: 'ignore',
|
|
78
|
+
});
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
// cmd.exe not available
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// Command failed
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getServiceManager(): ServiceManager {
|
|
92
|
+
const platform = getPlatform();
|
|
93
|
+
if (platform === 'macos') return 'launchd';
|
|
94
|
+
if (platform === 'linux') {
|
|
95
|
+
if (hasSystemd()) return 'systemd';
|
|
96
|
+
return 'none';
|
|
97
|
+
}
|
|
98
|
+
return 'none';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getNodePath(): string {
|
|
102
|
+
try {
|
|
103
|
+
return execSync('command -v node', { encoding: 'utf-8' }).trim();
|
|
104
|
+
} catch {
|
|
105
|
+
return process.execPath;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function commandExists(name: string): boolean {
|
|
110
|
+
try {
|
|
111
|
+
execSync(`command -v ${name}`, { stdio: 'ignore' });
|
|
112
|
+
return true;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function getNodeVersion(): string | null {
|
|
119
|
+
try {
|
|
120
|
+
const version = execSync('node --version', { encoding: 'utf-8' }).trim();
|
|
121
|
+
return version.replace(/^v/, '');
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function getNodeMajorVersion(): number | null {
|
|
128
|
+
const version = getNodeVersion();
|
|
129
|
+
if (!version) return null;
|
|
130
|
+
const major = parseInt(version.split('.')[0], 10);
|
|
131
|
+
return isNaN(major) ? null : major;
|
|
132
|
+
}
|