@moltbankhq/openclaw 0.1.10 → 0.1.12

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 (2) hide show
  1. package/index.ts +562 -92
  2. package/package.json +1 -1
package/index.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { execSync, spawn } from 'child_process';
2
- import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
1
+ import { spawn, spawnSync } from 'child_process';
2
+ import { chmodSync, chownSync, existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, type Dirent } from 'fs';
3
3
  import { join, dirname } from 'path';
4
- import { homedir } from 'os';
4
+ import { homedir, userInfo } from 'os';
5
5
  const IS_WIN = process.platform === 'win32';
6
6
 
7
7
  type OpenclawConfig = Record<string, unknown>;
@@ -32,6 +32,21 @@ interface LoggerApi {
32
32
  logger: LoggerLike;
33
33
  }
34
34
 
35
+ type RunOptions = {
36
+ cwd?: string;
37
+ silent?: boolean;
38
+ env?: Record<string, string | undefined>;
39
+ timeoutMs?: number;
40
+ };
41
+
42
+ type RunResult = {
43
+ ok: boolean;
44
+ stdout: string;
45
+ stderr: string;
46
+ exitCode: number | null;
47
+ timedOut: boolean;
48
+ };
49
+
35
50
  export interface SetupCommandLoggerOptions {
36
51
  verbose?: boolean;
37
52
  statusCommand?: string;
@@ -350,31 +365,191 @@ function getExecErrorMessage(error: unknown): string {
350
365
  return String(error);
351
366
  }
352
367
 
353
- function run(
354
- cmd: string,
355
- opts: { cwd?: string; silent?: boolean; env?: Record<string, string | undefined>; timeoutMs?: number } = {}
356
- ) {
368
+ function getPathEnvKey(env: NodeJS.ProcessEnv): string {
369
+ const existing = Object.keys(env).find((key) => key.toLowerCase() === 'path');
370
+ return existing ?? 'Path';
371
+ }
372
+
373
+ function splitPathEntries(value: string): string[] {
374
+ return value
375
+ .split(IS_WIN ? ';' : ':')
376
+ .map((entry) => entry.trim())
377
+ .filter(Boolean);
378
+ }
379
+
380
+ function prependPathEntries(env: NodeJS.ProcessEnv, entries: string[]): void {
381
+ if (entries.length === 0) return;
382
+
383
+ const pathKey = getPathEnvKey(env);
384
+ const existing = splitPathEntries(asString(env[pathKey]));
385
+ const seen = new Set<string>();
386
+ const ordered: string[] = [];
387
+
388
+ for (const entry of [...entries, ...existing]) {
389
+ const normalized = IS_WIN ? entry.toLowerCase() : entry;
390
+ if (seen.has(normalized)) continue;
391
+ seen.add(normalized);
392
+ ordered.push(entry);
393
+ }
394
+
395
+ env[pathKey] = ordered.join(IS_WIN ? ';' : ':');
396
+ }
397
+
398
+ function isExplicitCommandPath(command: string): boolean {
399
+ return command.includes('/') || command.includes('\\') || /^[A-Za-z]:/.test(command);
400
+ }
401
+
402
+ function getWindowsCommandExtensions(env: NodeJS.ProcessEnv): string[] {
403
+ const fromEnv = asString(env.PATHEXT || process.env.PATHEXT)
404
+ .split(';')
405
+ .map((value) => value.trim().toLowerCase())
406
+ .filter(Boolean);
407
+
408
+ const preferred = ['.exe', '.com', '.ps1', '.cmd', '.bat'];
409
+ const out: string[] = [];
410
+ for (const ext of [...preferred, ...fromEnv]) {
411
+ const normalized = ext.startsWith('.') ? ext : `.${ext}`;
412
+ if (!out.includes(normalized)) {
413
+ out.push(normalized);
414
+ }
415
+ }
416
+ return out;
417
+ }
418
+
419
+ function resolveWindowsCommandPath(command: string, env: NodeJS.ProcessEnv): string {
420
+ const extensions = getWindowsCommandExtensions(env);
421
+
422
+ const tryCandidates = (basePath: string): string => {
423
+ if (existsSync(basePath)) return basePath;
424
+
425
+ const lower = basePath.toLowerCase();
426
+ for (const ext of extensions) {
427
+ if (lower.endsWith(ext)) continue;
428
+ const candidate = `${basePath}${ext}`;
429
+ if (existsSync(candidate)) {
430
+ return candidate;
431
+ }
432
+ }
433
+
434
+ return '';
435
+ };
436
+
437
+ if (isExplicitCommandPath(command)) {
438
+ return tryCandidates(command);
439
+ }
440
+
441
+ const pathValue = asString(env[getPathEnvKey(env)]);
442
+ for (const entry of splitPathEntries(pathValue)) {
443
+ const resolved = tryCandidates(join(entry, command));
444
+ if (resolved) {
445
+ return resolved;
446
+ }
447
+ }
448
+
449
+ return '';
450
+ }
451
+
452
+ function quoteCmdArgument(value: string): string {
453
+ if (!value) return '""';
454
+
455
+ const escaped = value
456
+ .replace(/([()%!^"<>&|])/g, '^$1')
457
+ .replace(/\r/g, ' ')
458
+ .replace(/\n/g, ' ');
459
+
460
+ return /[\s()%!^"<>&|]/.test(value) ? `"${escaped}"` : escaped;
461
+ }
462
+
463
+ function prepareCommandForSpawn(command: string, args: string[], env: NodeJS.ProcessEnv): { command: string; args: string[] } {
464
+ if (!IS_WIN) {
465
+ return { command, args };
466
+ }
467
+
468
+ const resolved = resolveWindowsCommandPath(command, env);
469
+ if (!resolved) {
470
+ return { command, args };
471
+ }
472
+
473
+ const lower = resolved.toLowerCase();
474
+ if (lower.endsWith('.ps1')) {
475
+ return {
476
+ command: 'powershell.exe',
477
+ args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', resolved, ...args]
478
+ };
479
+ }
480
+
481
+ if (lower.endsWith('.cmd') || lower.endsWith('.bat')) {
482
+ const cmdExe = asString(env.ComSpec || env.COMSPEC, 'cmd.exe');
483
+ const commandLine = [resolved, ...args].map((value) => quoteCmdArgument(value)).join(' ');
484
+ return {
485
+ command: cmdExe,
486
+ args: ['/d', '/s', '/c', commandLine]
487
+ };
488
+ }
489
+
490
+ return { command: resolved, args };
491
+ }
492
+
493
+ function runCommand(command: string, args: string[], opts: RunOptions = {}): RunResult {
357
494
  try {
358
495
  const mergedEnv: NodeJS.ProcessEnv = { ...process.env, ...(opts.env ?? {}) };
359
- const out = execSync(cmd, {
496
+ const prepared = prepareCommandForSpawn(command, args, mergedEnv);
497
+ const result = spawnSync(prepared.command, prepared.args, {
360
498
  cwd: opts.cwd,
361
- stdio: opts.silent ? 'pipe' : 'inherit',
362
499
  env: mergedEnv,
500
+ encoding: 'utf8',
363
501
  timeout: opts.timeoutMs,
364
- shell: IS_WIN ? (process.env.ComSpec ?? 'cmd.exe') : '/bin/bash'
502
+ stdio: 'pipe',
503
+ windowsHide: true
365
504
  });
366
- return { ok: true, stdout: out?.toString().trim() ?? '' };
505
+
506
+ const stdout = (result.stdout ?? '').toString();
507
+ const stderr = (result.stderr ?? '').toString();
508
+
509
+ if (!opts.silent) {
510
+ if (stdout) process.stdout.write(stdout);
511
+ if (stderr) process.stderr.write(stderr);
512
+ }
513
+
514
+ const errorMessage = result.error ? getExecErrorMessage(result.error) : '';
515
+ const trimmedStdout = stdout.trim();
516
+ const trimmedStderr = stderr.trim();
517
+ const timedOut = Boolean(
518
+ result.error &&
519
+ ((isRecord(result.error) && asString((result.error as { code?: unknown }).code) === 'ETIMEDOUT') ||
520
+ errorMessage.toLowerCase().includes('timed out'))
521
+ );
522
+
523
+ if (result.status === 0) {
524
+ return {
525
+ ok: true,
526
+ stdout: trimmedStdout,
527
+ stderr: trimmedStderr,
528
+ exitCode: result.status,
529
+ timedOut: false
530
+ };
531
+ }
532
+
533
+ return {
534
+ ok: false,
535
+ stdout: trimmedStdout,
536
+ stderr: trimmedStderr || errorMessage || trimmedStdout,
537
+ exitCode: typeof result.status === 'number' ? result.status : null,
538
+ timedOut
539
+ };
367
540
  } catch (e: unknown) {
368
541
  return {
369
542
  ok: false,
370
543
  stdout: getExecOutputText(e, 'stdout'),
371
- stderr: getExecErrorMessage(e)
544
+ stderr: getExecErrorMessage(e),
545
+ exitCode: null,
546
+ timedOut: getExecErrorMessage(e).toLowerCase().includes('timed out')
372
547
  };
373
548
  }
374
549
  }
375
550
 
376
551
  function hasBin(bin: string) {
377
- return run(IS_WIN ? `where ${bin}` : `which ${bin}`, { silent: true }).ok;
552
+ return runCommand(IS_WIN ? 'where' : 'which', [bin], { silent: true }).ok;
378
553
  }
379
554
 
380
555
  function getJqInstallHint(): string {
@@ -396,7 +571,7 @@ function splitNonEmptyLines(text: string): string[] {
396
571
  }
397
572
 
398
573
  function getNpmGlobalBinDir(): string {
399
- const prefix = run('npm config get prefix', { silent: true });
574
+ const prefix = runCommand('npm', ['config', 'get', 'prefix'], { silent: true });
400
575
  if (!prefix.ok) return '';
401
576
 
402
577
  const value = prefix.stdout.trim();
@@ -412,7 +587,7 @@ function findFirstMatchingFile(rootDir: string, predicate: (name: string) => boo
412
587
  const current = queue.shift();
413
588
  if (!current) break;
414
589
 
415
- let entries: ReturnType<typeof readdirSync>;
590
+ let entries: Dirent[] = [];
416
591
  try {
417
592
  entries = readdirSync(current.dir, { withFileTypes: true });
418
593
  } catch {
@@ -433,13 +608,210 @@ function findFirstMatchingFile(rootDir: string, predicate: (name: string) => boo
433
608
  return '';
434
609
  }
435
610
 
611
+ function visitFiles(rootDir: string, visitor: (fullPath: string, entry: Dirent) => void, maxDepth = Number.POSITIVE_INFINITY): void {
612
+ if (!rootDir || !existsSync(rootDir)) return;
613
+
614
+ const queue: Array<{ dir: string; depth: number }> = [{ dir: rootDir, depth: 0 }];
615
+ while (queue.length) {
616
+ const current = queue.shift();
617
+ if (!current) break;
618
+
619
+ let entries: Dirent[] = [];
620
+ try {
621
+ entries = readdirSync(current.dir, { withFileTypes: true });
622
+ } catch {
623
+ continue;
624
+ }
625
+
626
+ for (const entry of entries) {
627
+ const fullPath = join(current.dir, entry.name);
628
+ visitor(fullPath, entry);
629
+ if (entry.isDirectory() && current.depth < maxDepth) {
630
+ queue.push({ dir: fullPath, depth: current.depth + 1 });
631
+ }
632
+ }
633
+ }
634
+ }
635
+
636
+ function normalizeFileLineEndings(filePath: string): void {
637
+ try {
638
+ const original = readFileSync(filePath, 'utf8');
639
+ const normalized = original.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
640
+ if (normalized !== original) {
641
+ writeFileSync(filePath, normalized, 'utf8');
642
+ }
643
+ } catch {
644
+ // ignore
645
+ }
646
+ }
647
+
648
+ function normalizeFilesMatching(rootDir: string, matcher: (fullPath: string, entry: Dirent) => boolean, maxDepth = Number.POSITIVE_INFINITY): void {
649
+ visitFiles(
650
+ rootDir,
651
+ (fullPath, entry) => {
652
+ if (entry.isFile() && matcher(fullPath, entry)) {
653
+ normalizeFileLineEndings(fullPath);
654
+ }
655
+ },
656
+ maxDepth
657
+ );
658
+ }
659
+
660
+ function applyRecursiveOwnership(rootDir: string, uid: number, gid: number): void {
661
+ try {
662
+ chownSync(rootDir, uid, gid);
663
+ } catch {
664
+ // ignore
665
+ }
666
+
667
+ visitFiles(rootDir, (fullPath) => {
668
+ try {
669
+ chownSync(fullPath, uid, gid);
670
+ } catch {
671
+ // ignore
672
+ }
673
+ });
674
+ }
675
+
676
+ function applyRecursiveMode(rootDir: string, mode: number): void {
677
+ try {
678
+ chmodSync(rootDir, mode);
679
+ } catch {
680
+ // ignore
681
+ }
682
+
683
+ visitFiles(rootDir, (fullPath) => {
684
+ try {
685
+ chmodSync(fullPath, mode);
686
+ } catch {
687
+ // ignore
688
+ }
689
+ });
690
+ }
691
+
692
+ function applyRecursiveModes(rootDir: string, dirMode: number, fileMode: number): void {
693
+ try {
694
+ chmodSync(rootDir, dirMode);
695
+ } catch {
696
+ // ignore
697
+ }
698
+
699
+ visitFiles(rootDir, (fullPath, entry) => {
700
+ try {
701
+ chmodSync(fullPath, entry.isDirectory() ? dirMode : fileMode);
702
+ } catch {
703
+ // ignore
704
+ }
705
+ });
706
+ }
707
+
708
+ type CommandSpec = {
709
+ command: string;
710
+ args: string[];
711
+ env?: Record<string, string | undefined>;
712
+ };
713
+
714
+ function withOptionalSudo(command: string, args: string[], env?: Record<string, string | undefined>): CommandSpec {
715
+ const isRoot = typeof process.getuid === 'function' ? process.getuid() === 0 : false;
716
+ if (isRoot || !hasBin('sudo')) {
717
+ return { command, args, env };
718
+ }
719
+ return { command: 'sudo', args: [command, ...args], env };
720
+ }
721
+
722
+ function getUnixJqInstallPlan(): { name: string; steps: CommandSpec[] } | null {
723
+ if (hasBin('apt-get')) {
724
+ return {
725
+ name: 'apt-get',
726
+ steps: [
727
+ withOptionalSudo('apt-get', ['update', '-qq'], { DEBIAN_FRONTEND: 'noninteractive' }),
728
+ withOptionalSudo('apt-get', ['install', '-y', 'jq'], { DEBIAN_FRONTEND: 'noninteractive' })
729
+ ]
730
+ };
731
+ }
732
+
733
+ if (hasBin('dnf')) {
734
+ return {
735
+ name: 'dnf',
736
+ steps: [withOptionalSudo('dnf', ['install', '-y', 'jq'])]
737
+ };
738
+ }
739
+
740
+ if (hasBin('yum')) {
741
+ return {
742
+ name: 'yum',
743
+ steps: [withOptionalSudo('yum', ['install', '-y', 'jq'])]
744
+ };
745
+ }
746
+
747
+ if (hasBin('pacman')) {
748
+ return {
749
+ name: 'pacman',
750
+ steps: [withOptionalSudo('pacman', ['-Sy', '--noconfirm', 'jq'])]
751
+ };
752
+ }
753
+
754
+ if (hasBin('apk')) {
755
+ return {
756
+ name: 'apk',
757
+ steps: [withOptionalSudo('apk', ['add', 'jq'])]
758
+ };
759
+ }
760
+
761
+ if (hasBin('zypper')) {
762
+ return {
763
+ name: 'zypper',
764
+ steps: [withOptionalSudo('zypper', ['install', '-y', 'jq'])]
765
+ };
766
+ }
767
+
768
+ if (hasBin('brew')) {
769
+ return {
770
+ name: 'brew',
771
+ steps: [{ command: 'brew', args: ['install', 'jq'] }]
772
+ };
773
+ }
774
+
775
+ return null;
776
+ }
777
+
778
+ function ensureUnixJq(api: LoggerApi): boolean {
779
+ const plan = getUnixJqInstallPlan();
780
+ if (!plan) {
781
+ return false;
782
+ }
783
+
784
+ api.logger.info(`[moltbank] jq not found — attempting install via ${plan.name}...`);
785
+
786
+ for (const step of plan.steps) {
787
+ const result = runCommand(step.command, step.args, {
788
+ env: step.env,
789
+ timeoutMs: 180000
790
+ });
791
+
792
+ if (!result.ok) {
793
+ api.logger.warn(`[moltbank] jq install via ${plan.name} failed: ${result.stderr}`);
794
+ return false;
795
+ }
796
+ }
797
+
798
+ if (!hasBin('jq')) {
799
+ api.logger.warn(`[moltbank] jq install via ${plan.name} finished, but jq is still not visible in PATH`);
800
+ return false;
801
+ }
802
+
803
+ const version = runCommand('jq', ['--version'], { silent: true });
804
+ api.logger.info(`[moltbank] ✓ jq installed via ${plan.name} (${version.stdout || 'unknown version'})`);
805
+ return true;
806
+ }
807
+
436
808
  function findWindowsJqBinaryPath(): string {
437
809
  if (!IS_WIN) return '';
438
810
 
439
811
  const fromEnv = asString(process.env.JQ_PATH).trim();
440
812
  if (fromEnv && existsSync(fromEnv)) return fromEnv;
441
813
 
442
- const whereJq = run('where jq', { silent: true });
814
+ const whereJq = runCommand('where', ['jq'], { silent: true });
443
815
  if (whereJq.ok) {
444
816
  for (const line of splitNonEmptyLines(whereJq.stdout)) {
445
817
  if (existsSync(line)) {
@@ -482,7 +854,7 @@ function findWindowsJqBinaryPath(): string {
482
854
 
483
855
  const wingetPackagesRoot = localAppData ? join(localAppData, 'Microsoft', 'WinGet', 'Packages') : '';
484
856
  if (wingetPackagesRoot && existsSync(wingetPackagesRoot)) {
485
- let packageEntries: ReturnType<typeof readdirSync> = [];
857
+ let packageEntries: Dirent<string>[] = [];
486
858
  try {
487
859
  packageEntries = readdirSync(wingetPackagesRoot, { withFileTypes: true });
488
860
  } catch {
@@ -522,6 +894,7 @@ function ensureWindowsJqShim(api: LoggerApi): boolean {
522
894
  const shimBody = `@echo off\r\n"${escapedSource}" %*\r\n`;
523
895
  writeFileSync(shimPath, shimBody, 'utf8');
524
896
  }
897
+ prependPathEntries(process.env, [npmBinDir]);
525
898
  } catch (e) {
526
899
  api.logger.warn('[moltbank] could not create jq shim in npm global bin: ' + String(e));
527
900
  return false;
@@ -529,7 +902,7 @@ function ensureWindowsJqShim(api: LoggerApi): boolean {
529
902
 
530
903
  if (!hasBin('jq')) return false;
531
904
 
532
- const v = run('jq --version', { silent: true });
905
+ const v = runCommand('jq', ['--version'], { silent: true });
533
906
  api.logger.info(`[moltbank] ✓ jq available via npm global bin shim (${v.stdout || 'unknown version'})`);
534
907
  return true;
535
908
  }
@@ -665,12 +1038,12 @@ function cleanupStaleMoltbankPluginLoadPaths(api: LoggerApi): boolean {
665
1038
 
666
1039
  function ensureMcporter(api: LoggerApi) {
667
1040
  if (hasBin('mcporter')) {
668
- const v = run('mcporter --version', { silent: true });
1041
+ const v = runCommand('mcporter', ['--version'], { silent: true });
669
1042
  api.logger.info(`[moltbank] ✓ mcporter already installed (${v.stdout || 'unknown version'})`);
670
1043
  return;
671
1044
  }
672
1045
  api.logger.info('[moltbank] installing mcporter globally...');
673
- const result = run('npm install -g mcporter');
1046
+ const result = runCommand('npm', ['install', '-g', 'mcporter']);
674
1047
  if (result.ok) {
675
1048
  api.logger.info('[moltbank] ✓ mcporter installed');
676
1049
  } else {
@@ -680,12 +1053,16 @@ function ensureMcporter(api: LoggerApi) {
680
1053
 
681
1054
  function ensureJq(api: LoggerApi) {
682
1055
  if (hasBin('jq')) {
683
- const v = run('jq --version', { silent: true });
1056
+ const v = runCommand('jq', ['--version'], { silent: true });
684
1057
  api.logger.info(`[moltbank] ✓ jq already installed (${v.stdout || 'unknown version'})`);
685
1058
  return;
686
1059
  }
687
1060
 
688
1061
  if (!IS_WIN) {
1062
+ if (ensureUnixJq(api)) {
1063
+ return;
1064
+ }
1065
+
689
1066
  api.logger.warn('[moltbank] ✗ jq not found in PATH (required by MoltBank wrapper on host mode)');
690
1067
  api.logger.warn(`[moltbank] install jq (${getJqInstallHint()}) and rerun setup`);
691
1068
  return;
@@ -698,29 +1075,41 @@ function ensureJq(api: LoggerApi) {
698
1075
 
699
1076
  api.logger.info('[moltbank] jq not found — attempting Windows install...');
700
1077
 
701
- const installers: Array<{ name: string; checkCmd: string; installCmd: string }> = [
1078
+ const installers: Array<{
1079
+ name: string;
1080
+ checkCommand: string;
1081
+ checkArgs: string[];
1082
+ installCommand: string;
1083
+ installArgs: string[];
1084
+ }> = [
702
1085
  {
703
1086
  name: 'winget',
704
- checkCmd: 'where winget',
705
- installCmd: 'winget install -e --id jqlang.jq --accept-package-agreements --accept-source-agreements --silent'
1087
+ checkCommand: 'where',
1088
+ checkArgs: ['winget'],
1089
+ installCommand: 'winget',
1090
+ installArgs: ['install', '-e', '--id', 'jqlang.jq', '--accept-package-agreements', '--accept-source-agreements', '--silent']
706
1091
  },
707
1092
  {
708
1093
  name: 'scoop',
709
- checkCmd: 'where scoop',
710
- installCmd: 'scoop install jq'
1094
+ checkCommand: 'where',
1095
+ checkArgs: ['scoop'],
1096
+ installCommand: 'scoop',
1097
+ installArgs: ['install', 'jq']
711
1098
  },
712
1099
  {
713
1100
  name: 'choco',
714
- checkCmd: 'where choco',
715
- installCmd: 'choco install jq -y'
1101
+ checkCommand: 'where',
1102
+ checkArgs: ['choco'],
1103
+ installCommand: 'choco',
1104
+ installArgs: ['install', 'jq', '-y']
716
1105
  }
717
1106
  ];
718
1107
 
719
1108
  for (const installer of installers) {
720
- if (!run(installer.checkCmd, { silent: true }).ok) continue;
1109
+ if (!runCommand(installer.checkCommand, installer.checkArgs, { silent: true }).ok) continue;
721
1110
 
722
1111
  api.logger.info(`[moltbank] trying jq install via ${installer.name}...`);
723
- const install = run(installer.installCmd, { timeoutMs: 180000 });
1112
+ const install = runCommand(installer.installCommand, installer.installArgs, { timeoutMs: 180000 });
724
1113
  const installOutput = `${install.stdout}\n${install.stderr}`;
725
1114
  const installOutputLower = installOutput.toLowerCase();
726
1115
  const alreadyInstalledOrNoUpdate =
@@ -741,7 +1130,7 @@ function ensureJq(api: LoggerApi) {
741
1130
  }
742
1131
 
743
1132
  if (hasBin('jq')) {
744
- const v = run('jq --version', { silent: true });
1133
+ const v = runCommand('jq', ['--version'], { silent: true });
745
1134
  api.logger.info(`[moltbank] ✓ jq installed via ${installer.name} (${v.stdout || 'unknown version'})`);
746
1135
  return;
747
1136
  }
@@ -821,13 +1210,67 @@ function hasRequiredSkillFiles(skillDir: string): boolean {
821
1210
  return SKILL_FILES.every((file) => existsSync(join(skillDir, file)));
822
1211
  }
823
1212
 
824
- function ensureSkillInstalled(
1213
+ async function installSkillBundleFiles(appBaseUrl: string, skillDir: string): Promise<RunResult> {
1214
+ const base = appBaseUrl.endsWith('/') ? appBaseUrl.slice(0, -1) : appBaseUrl;
1215
+ const aliases: Record<string, string[]> = {
1216
+ 'SKILL.md': ['SKILL.md', 'skill.md'],
1217
+ 'skill.md': ['skill.md', 'SKILL.md']
1218
+ };
1219
+
1220
+ try {
1221
+ for (const file of SKILL_FILES) {
1222
+ const outPath = join(skillDir, file);
1223
+ mkdirSync(dirname(outPath), { recursive: true });
1224
+
1225
+ let body: string | null = null;
1226
+ let lastUrl = '';
1227
+ let lastStatus = '';
1228
+ for (const candidate of aliases[file] ?? [file]) {
1229
+ const url = `${base}/${candidate}`;
1230
+ lastUrl = url;
1231
+
1232
+ const response = await fetch(url);
1233
+ if (response.ok) {
1234
+ body = await response.text();
1235
+ break;
1236
+ }
1237
+
1238
+ lastStatus = String(response.status);
1239
+ }
1240
+
1241
+ if (body === null) {
1242
+ return {
1243
+ ok: false,
1244
+ stdout: '',
1245
+ stderr: `download failed ${lastUrl} ${lastStatus}`.trim(),
1246
+ exitCode: 2,
1247
+ timedOut: false
1248
+ };
1249
+ }
1250
+
1251
+ writeFileSync(outPath, body, 'utf8');
1252
+ }
1253
+
1254
+ writeFileSync(join(skillDir, '.install_success'), 'ok\n', 'utf8');
1255
+ return { ok: true, stdout: '', stderr: '', exitCode: 0, timedOut: false };
1256
+ } catch (e) {
1257
+ return {
1258
+ ok: false,
1259
+ stdout: '',
1260
+ stderr: String(e),
1261
+ exitCode: null,
1262
+ timedOut: false
1263
+ };
1264
+ }
1265
+ }
1266
+
1267
+ async function ensureSkillInstalled(
825
1268
  skillDir: string,
826
1269
  appBaseUrl: string,
827
1270
  skillName: string,
828
1271
  api: LoggerApi,
829
1272
  mode: 'sandbox' | 'host' = 'sandbox'
830
- ) {
1273
+ ): Promise<boolean> {
831
1274
  const successFlag = join(skillDir, '.install_success');
832
1275
  if (existsSync(successFlag) && hasRequiredSkillFiles(skillDir)) {
833
1276
  ensureWrapperExecutable(skillDir, api);
@@ -847,11 +1290,7 @@ function ensureSkillInstalled(
847
1290
  api.logger.info(`[moltbank] installing skill '${skillName}' to ${skillDir} (mode: ${mode})`);
848
1291
  mkdirSync(skillDir, { recursive: true });
849
1292
 
850
- const filesJson = JSON.stringify(SKILL_FILES).replace(/"/g, '\\"');
851
- const installNode = run(
852
- `node --input-type=module -e "import fs from 'fs'; import path from 'path'; const baseRaw=process.argv[1]; const dir=process.argv[2]; const files=JSON.parse(process.argv[3]); const base=baseRaw.endsWith('/') ? baseRaw.slice(0,-1) : baseRaw; const aliases={ 'SKILL.md':['SKILL.md','skill.md'], 'skill.md':['skill.md','SKILL.md'] }; fs.mkdirSync(dir,{recursive:true}); for (const f of files){ const out=path.join(dir,f); fs.mkdirSync(path.dirname(out),{recursive:true}); let body=null; let lastUrl=''; let lastStatus=''; for (const candidate of (aliases[f] ?? [f])){ const u=base+'/'+candidate; lastUrl=u; const r=await fetch(u); if(r.ok){ body=await r.text(); break; } lastStatus=String(r.status); } if(body===null){ console.error('download failed',lastUrl,lastStatus); process.exit(2);} fs.writeFileSync(out, body,'utf8'); } fs.writeFileSync(path.join(dir,'.install_success'),'ok\\n','utf8');" "${appBaseUrl}" "${skillDir}" "${filesJson}"`,
853
- { cwd: dirname(skillDir), silent: true }
854
- );
1293
+ const installNode = await installSkillBundleFiles(appBaseUrl, skillDir);
855
1294
 
856
1295
  if (installNode.ok) {
857
1296
  ensureWrapperExecutable(skillDir, api);
@@ -906,34 +1345,42 @@ function ensureSkillPermissions(skillDir: string, api: LoggerApi) {
906
1345
  const referencesDir = join(skillDir, 'references');
907
1346
  const scriptsDir = join(skillDir, 'scripts');
908
1347
 
909
- const user = run('whoami', { silent: true }).stdout;
910
- if (user && existsSync(skillDir)) {
911
- run(`chown -R ${user} "${skillDir}"`, { silent: true });
912
- api.logger.info(`[moltbank] ownership corrected to ${user}`);
1348
+ let currentUserName = '';
1349
+ try {
1350
+ const currentUser = userInfo();
1351
+ currentUserName = currentUser.username;
1352
+ if (existsSync(skillDir)) {
1353
+ applyRecursiveOwnership(skillDir, currentUser.uid, currentUser.gid);
1354
+ api.logger.info(`[moltbank] ✓ ownership corrected to ${currentUserName}`);
1355
+ }
1356
+ } catch {
1357
+ // ignore ownership correction when uid/gid are unavailable
913
1358
  }
914
1359
 
915
1360
  if (existsSync(assetsDir)) {
916
- run(`chmod 777 "${assetsDir}"`, { silent: true });
917
- api.logger.info('[moltbank] ✓ assets/ permissions → 777');
1361
+ applyRecursiveModes(assetsDir, 0o750, 0o640);
1362
+ api.logger.info('[moltbank] ✓ assets/ permissions → 750 dirs, 640 files');
918
1363
  }
919
1364
  if (existsSync(configFile)) {
920
- run(`chmod 666 "${configFile}"`, { silent: true });
921
- api.logger.info('[moltbank] ✓ mcporter.json permissions → 666');
1365
+ try {
1366
+ chmodSync(configFile, 0o600);
1367
+ } catch {
1368
+ // ignore
1369
+ }
1370
+ api.logger.info('[moltbank] ✓ mcporter.json permissions → 600');
922
1371
  }
923
1372
  if (existsSync(scriptsDir)) {
924
- run(`chmod -R 755 "${scriptsDir}"`, { silent: true });
925
- api.logger.info('[moltbank] ✓ scripts/ permissions → 755');
926
- run(`find "${scriptsDir}" -type f -exec sed -i 's/\r$//' {} +`, {
927
- silent: true
928
- });
1373
+ applyRecursiveModes(scriptsDir, 0o750, 0o750);
1374
+ api.logger.info('[moltbank] ✓ scripts/ permissions → 750');
1375
+ normalizeFilesMatching(scriptsDir, () => true);
929
1376
  api.logger.info('[moltbank] ✓ scripts/ line endings normalized (CRLF → LF)');
930
1377
  }
931
1378
 
932
1379
  if (existsSync(skillDir)) {
933
- run(`find "${skillDir}" -maxdepth 1 -type f -name "*.md" -exec sed -i 's/\r$//' {} +`, { silent: true });
1380
+ normalizeFilesMatching(skillDir, (fullPath, entry) => entry.isFile() && fullPath.endsWith('.md'), 0);
934
1381
  }
935
1382
  if (existsSync(referencesDir)) {
936
- run(`find "${referencesDir}" -maxdepth 1 -type f -name "*.md" -exec sed -i 's/\r$//' {} +`, { silent: true });
1383
+ normalizeFilesMatching(referencesDir, (fullPath, entry) => entry.isFile() && fullPath.endsWith('.md'), 0);
937
1384
  }
938
1385
  if (existsSync(skillDir) || existsSync(referencesDir)) {
939
1386
  api.logger.info('[moltbank] ✓ markdown line endings normalized (CRLF → LF)');
@@ -961,7 +1408,7 @@ function ensureNpmDeps(skillDir: string, api: LoggerApi, mode: 'sandbox' | 'host
961
1408
  }
962
1409
  api.logger.info('[moltbank] installing npm deps...');
963
1410
  if (mode === 'sandbox') {
964
- const result = run('npm install --ignore-scripts', { cwd: skillDir });
1411
+ const result = runCommand('npm', ['install', '--ignore-scripts'], { cwd: skillDir });
965
1412
  if (result.ok) {
966
1413
  api.logger.info('[moltbank] ✓ npm deps installed');
967
1414
  } else {
@@ -972,20 +1419,20 @@ function ensureNpmDeps(skillDir: string, api: LoggerApi, mode: 'sandbox' | 'host
972
1419
 
973
1420
  const hasLock = existsSync(join(skillDir, 'package-lock.json')) || existsSync(join(skillDir, 'npm-shrinkwrap.json'));
974
1421
  if (hasLock) {
975
- const ci = run('npm ci', { cwd: skillDir });
1422
+ const ci = runCommand('npm', ['ci'], { cwd: skillDir });
976
1423
  if (ci.ok) {
977
1424
  api.logger.info('[moltbank] ✓ npm deps installed with npm ci');
978
1425
  return;
979
1426
  }
980
1427
  api.logger.warn('[moltbank] npm ci failed; trying npm install: ' + ci.stderr);
981
1428
  }
982
- const install = run('npm install', { cwd: skillDir });
1429
+ const install = runCommand('npm', ['install'], { cwd: skillDir });
983
1430
  if (install.ok) {
984
1431
  api.logger.info('[moltbank] ✓ npm deps installed with npm install');
985
1432
  return;
986
1433
  }
987
1434
  api.logger.warn('[moltbank] npm install failed; trying safe fallback --ignore-scripts');
988
- const fallback = run('npm install --ignore-scripts', { cwd: skillDir });
1435
+ const fallback = runCommand('npm', ['install', '--ignore-scripts'], { cwd: skillDir });
989
1436
  if (fallback.ok) {
990
1437
  api.logger.warn('[moltbank] npm deps installed with fallback --ignore-scripts (some packages may require postinstall)');
991
1438
  } else {
@@ -994,7 +1441,7 @@ function ensureNpmDeps(skillDir: string, api: LoggerApi, mode: 'sandbox' | 'host
994
1441
  }
995
1442
 
996
1443
  function isMoltBankRegistered(): boolean {
997
- const result = run('mcporter config list', { silent: true });
1444
+ const result = runCommand('mcporter', ['config', 'list'], { silent: true });
998
1445
  return result.stdout.toLowerCase().includes('moltbank');
999
1446
  }
1000
1447
 
@@ -1266,7 +1713,7 @@ function ensureMoltbankAuth(skillDir: string, appBaseUrl: string, api: LoggerApi
1266
1713
  if (!deviceCode || !userCode) {
1267
1714
  api.logger.info('[moltbank] no valid credentials found — starting onboarding flow...');
1268
1715
 
1269
- const requestCode = run(`"${process.execPath}" "./scripts/request-oauth-device-code.mjs"`, {
1716
+ const requestCode = runCommand(process.execPath, ['./scripts/request-oauth-device-code.mjs'], {
1270
1717
  cwd: skillDir,
1271
1718
  silent: true,
1272
1719
  env: {
@@ -1341,8 +1788,9 @@ function ensureMoltbankAuth(skillDir: string, appBaseUrl: string, api: LoggerApi
1341
1788
  const pollIntervalSeconds = Number(process.env.MOLTBANK_OAUTH_POLL_INTERVAL_SECONDS ?? 5);
1342
1789
  const safePollIntervalSeconds = Number.isFinite(pollIntervalSeconds) && pollIntervalSeconds > 0 ? Math.floor(pollIntervalSeconds) : 5;
1343
1790
 
1344
- const poll = run(
1345
- `"${process.execPath}" "./scripts/poll-oauth-token.mjs" "${deviceCode}" ${safePollTimeoutSeconds} ${safePollIntervalSeconds} --save`,
1791
+ const poll = runCommand(
1792
+ process.execPath,
1793
+ ['./scripts/poll-oauth-token.mjs', deviceCode, String(safePollTimeoutSeconds), String(safePollIntervalSeconds), '--save'],
1346
1794
  {
1347
1795
  cwd: skillDir,
1348
1796
  silent: true,
@@ -1506,7 +1954,7 @@ export function injectSandboxEnv(skillDir: string, api: LoggerApi): boolean {
1506
1954
 
1507
1955
  if (!privateKey) {
1508
1956
  api.logger.info('[moltbank] x402_signer_private_key not found — generating EOA signer...');
1509
- const initResult = run(`"${process.execPath}" "./scripts/init-openclaw-signer.mjs"`, {
1957
+ const initResult = runCommand(process.execPath, ['./scripts/init-openclaw-signer.mjs'], {
1510
1958
  cwd: skillDir,
1511
1959
  silent: true,
1512
1960
  env: {
@@ -1629,39 +2077,52 @@ export function configureSandbox(api: LoggerApi): boolean {
1629
2077
  export function recreateSandboxAndRestart(api: LoggerApi) {
1630
2078
  api.logger.info('[moltbank] recreating sandbox containers...');
1631
2079
  api.logger.info('[moltbank] ⏳ waiting 8s before recreate (hot container protection)...');
2080
+
2081
+ const getGatewayPids = (): string[] => {
2082
+ const result = runCommand('pgrep', ['-f', 'openclaw-gateway'], { silent: true });
2083
+ if (!result.ok) return [];
2084
+ return splitNonEmptyLines(result.stdout).filter((pid) => /^\d+$/.test(pid));
2085
+ };
2086
+
1632
2087
  setTimeout(() => {
1633
2088
  api.logger.info('[moltbank] stopping gateway...');
1634
- run('openclaw gateway stop', { silent: true });
1635
-
1636
- const stillRunning = run("ps aux | grep openclaw-gateway | grep -v grep | awk '{print $2}'", { silent: true });
1637
- if (stillRunning.stdout.trim()) {
1638
- api.logger.info(`[moltbank] gateway still running (pids: ${stillRunning.stdout.trim()}) — sending SIGKILL...`);
1639
- run("kill -9 $(ps aux | grep openclaw-gateway | grep -v grep | awk '{print $2}') 2>/dev/null || true", { silent: true });
2089
+ runCommand('openclaw', ['gateway', 'stop'], { silent: true });
2090
+
2091
+ const stillRunning = getGatewayPids();
2092
+ if (stillRunning.length) {
2093
+ api.logger.info(`[moltbank] gateway still running (pids: ${stillRunning.join(' ')}) — sending SIGKILL...`);
2094
+ for (const pid of stillRunning) {
2095
+ try {
2096
+ process.kill(Number(pid), 'SIGKILL');
2097
+ } catch {
2098
+ // ignore
2099
+ }
2100
+ }
1640
2101
  api.logger.info('[moltbank] ✓ gateway process killed');
1641
2102
  } else {
1642
2103
  api.logger.info('[moltbank] ✓ gateway stopped cleanly');
1643
2104
  }
1644
2105
 
1645
- run('sleep 2', { silent: true });
1646
-
1647
- const recreate = run('openclaw sandbox recreate --all --force', {
1648
- silent: false
1649
- });
1650
- if (recreate.ok) {
1651
- api.logger.info('[moltbank] sandbox containers recreated — new container will be created on next agent message');
1652
- } else {
1653
- api.logger.warn('[moltbank] sandbox recreate failed');
1654
- api.logger.warn('[moltbank] run manually: openclaw sandbox recreate --all --force');
1655
- }
2106
+ setTimeout(() => {
2107
+ const recreate = runCommand('openclaw', ['sandbox', 'recreate', '--all', '--force'], {
2108
+ silent: false
2109
+ });
2110
+ if (recreate.ok) {
2111
+ api.logger.info('[moltbank] ✓ sandbox containers recreated — new container will be created on next agent message');
2112
+ } else {
2113
+ api.logger.warn('[moltbank] sandbox recreate failed');
2114
+ api.logger.warn('[moltbank] run manually: openclaw sandbox recreate --all --force');
2115
+ }
1656
2116
 
1657
- api.logger.info('[moltbank] restarting gateway...');
1658
- run('openclaw gateway', { silent: true });
2117
+ api.logger.info('[moltbank] restarting gateway...');
2118
+ runCommand('openclaw', ['gateway'], { silent: true });
2119
+ }, 2000);
1659
2120
  }, 8000);
1660
2121
  }
1661
2122
 
1662
2123
  function restartGatewayAfterHostSetup(api: LoggerApi): boolean {
1663
2124
  api.logger.info('[moltbank] restarting gateway to refresh host skill state...');
1664
- const restart = run('openclaw gateway restart', { silent: true, timeoutMs: 30000 });
2125
+ const restart = runCommand('openclaw', ['gateway', 'restart'], { silent: true, timeoutMs: 30000 });
1665
2126
  if (restart.ok) {
1666
2127
  api.logger.info('[moltbank] ✓ gateway restarted');
1667
2128
  return true;
@@ -1709,7 +2170,7 @@ export async function runSetup(
1709
2170
  ensureJq(api);
1710
2171
 
1711
2172
  api.logger.info('[moltbank] [sandbox 1/10] installing skill files...');
1712
- const skillInstalled = ensureSkillInstalled(skillDir, skillBundleBaseUrl, skillName, api, 'sandbox');
2173
+ const skillInstalled = await ensureSkillInstalled(skillDir, skillBundleBaseUrl, skillName, api, 'sandbox');
1713
2174
  if (!skillInstalled) {
1714
2175
  api.logger.warn('[moltbank] skill install failed — aborting sandbox setup');
1715
2176
  return;
@@ -1767,7 +2228,7 @@ export async function runSetup(
1767
2228
  ensureJq(api);
1768
2229
 
1769
2230
  api.logger.info('[moltbank] [host 2/8] installing skill files...');
1770
- const installed = ensureSkillInstalled(skillDir, skillBundleBaseUrl, skillName, api, 'host');
2231
+ const installed = await ensureSkillInstalled(skillDir, skillBundleBaseUrl, skillName, api, 'host');
1771
2232
  if (!installed) {
1772
2233
  api.logger.warn('[moltbank] host setup aborted: skill install failed. Verify install.sh/base URL and retry.');
1773
2234
  return;
@@ -1811,7 +2272,7 @@ export async function runSetup(
1811
2272
  if (IS_WIN) {
1812
2273
  const mcporterConfigPath = join(skillDir, 'assets', 'mcporter.json');
1813
2274
  const active = parseActiveTokenFromCredentials();
1814
- const mcpSmoke = run(`mcporter --config "${mcporterConfigPath}" list MoltBank`, {
2275
+ const mcpSmoke = runCommand('mcporter', ['--config', mcporterConfigPath, 'list', 'MoltBank'], {
1815
2276
  cwd: skillDir,
1816
2277
  silent: true,
1817
2278
  timeoutMs: smokeTimeoutMs,
@@ -1838,8 +2299,16 @@ export async function runSetup(
1838
2299
  );
1839
2300
 
1840
2301
  const ps1Path = join(skillDir, 'scripts', 'moltbank.ps1');
1841
- const fallback = run(
1842
- 'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$ErrorActionPreference = \'Stop\'; if (-not (Test-Path -LiteralPath $env:MOLTBANK_WRAPPER_PATH)) { throw \'moltbank.ps1 not found\' }; if (-not (Get-Command bash -ErrorAction SilentlyContinue)) { throw \'bash is not installed or not on PATH\' }; if (-not (Get-Command mcporter -ErrorAction SilentlyContinue)) { throw \'mcporter is not installed or not on PATH\' }; Write-Output \'ok\'"',
2302
+ const fallback = runCommand(
2303
+ 'powershell',
2304
+ [
2305
+ '-NoProfile',
2306
+ '-NonInteractive',
2307
+ '-ExecutionPolicy',
2308
+ 'Bypass',
2309
+ '-Command',
2310
+ "$ErrorActionPreference = 'Stop'; if (-not (Test-Path -LiteralPath $env:MOLTBANK_WRAPPER_PATH)) { throw 'moltbank.ps1 not found' }; if (-not (Get-Command bash -ErrorAction SilentlyContinue)) { throw 'bash is not installed or not on PATH' }; if (-not (Get-Command mcporter -ErrorAction SilentlyContinue)) { throw 'mcporter is not installed or not on PATH' }; Write-Output 'ok'"
2311
+ ],
1843
2312
  {
1844
2313
  cwd: skillDir,
1845
2314
  silent: true,
@@ -1869,7 +2338,7 @@ export async function runSetup(
1869
2338
  }
1870
2339
  }
1871
2340
  } else {
1872
- const smoke = run(`"${skillDir}/scripts/moltbank.sh" list MoltBank`, {
2341
+ const smoke = runCommand(join(skillDir, 'scripts', 'moltbank.sh'), ['list', 'MoltBank'], {
1873
2342
  cwd: skillDir,
1874
2343
  silent: true,
1875
2344
  timeoutMs: smokeTimeoutMs
@@ -1925,10 +2394,11 @@ export async function runSetup(
1925
2394
  `[moltbank] setupCommand: ${finalCmd.includes('node_22.x') && !finalCmd.includes('/home/') ? '✓ ok (no host paths)' : '✗ may be corrupted'}`
1926
2395
  );
1927
2396
 
1928
- const containerCheck = run(
1929
- `docker inspect $(docker ps | grep openclaw-sbx | awk '{print $1}') --format '{{.HostConfig.NetworkMode}}' 2>/dev/null`,
1930
- { silent: true }
1931
- );
2397
+ const sandboxContainer = runCommand('docker', ['ps', '-q', '--filter', 'name=openclaw-sbx'], { silent: true });
2398
+ const sandboxContainerId = splitNonEmptyLines(sandboxContainer.stdout)[0] || '';
2399
+ const containerCheck = sandboxContainerId
2400
+ ? runCommand('docker', ['inspect', sandboxContainerId, '--format', '{{.HostConfig.NetworkMode}}'], { silent: true })
2401
+ : { ok: false, stdout: '', stderr: '', exitCode: 1, timedOut: false };
1932
2402
  if (!containerCheck.ok || !containerCheck.stdout) {
1933
2403
  api.logger.info(`[moltbank] container: not running yet — will be created on first agent message`);
1934
2404
  } else if (containerCheck.stdout.trim() === 'bridge') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moltbankhq/openclaw",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "MoltBank stablecoin treasury CLI and OpenClaw plugin",
5
5
  "main": "index.ts",
6
6
  "bin": {