Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"plugins": [
{
"name": "genie",
"version": "3.260310.5",
"version": "3.260314.8",
"source": "./plugins/genie",
"description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, wish them into plans, make with parallel agents, ship as one team. A coding genie that grows with your project."
}
Expand Down
15 changes: 15 additions & 0 deletions .github/workflows/version.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,18 @@ jobs:

git tag "v${VERSION}"
git push --atomic origin HEAD:refs/heads/dev "refs/tags/v${VERSION}"

- name: Build CLI
run: bun run build

- name: Publish dev release to npm
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
HUSKY: "0"
run: |
if [ -z "$NPM_TOKEN" ]; then
echo "⚠️ NPM_TOKEN not set — skipping dev publish"
exit 0
fi
bun publish --access public --tag next
Comment on lines +84 to +98

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Tag pushed before publish—orphan tags possible on failure.

The workflow pushes the git tag at line 83 before attempting npm publish. If the build or publish step fails, the tag remains in the repo without a corresponding npm release. Consider either:

  1. Moving the tag push after successful publish, or
  2. Adding cleanup logic to delete the tag on publish failure.
Potential fix: reorder tag push after publish
      - name: Commit and tag
        run: |
          VERSION="${{ steps.version.outputs.version }}"

          git add -A '*.json' 'src/lib/version.ts'
          if git diff --cached --quiet; then
            echo "No version changes to commit"
          else
            git commit -m "chore(version): bump to ${VERSION} [skip ci]"
          fi

          git tag "v${VERSION}"
-          git push --atomic origin HEAD:refs/heads/dev "refs/tags/v${VERSION}"
+          git push origin HEAD:refs/heads/dev

      - name: Build CLI
        run: bun run build

      - name: Publish dev release to npm
        env:
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
          NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
          HUSKY: "0"
        run: |
          if [ -z "$NPM_TOKEN" ]; then
            echo "⚠️ NPM_TOKEN not set — skipping dev publish"
            exit 0
          fi
          bun publish --access public --tag next

+      - name: Push tag after successful publish
+        run: |
+          VERSION="${{ steps.version.outputs.version }}"
+          git push origin "refs/tags/v${VERSION}"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/version.yml around lines 84 - 98, Workflow pushes a git
tag before running the "Build CLI" and "Publish dev release to npm" steps,
risking orphan tags if the build/publish fails; move the tag push so it occurs
after the "Publish dev release to npm" step (only on successful publish) or add
cleanup logic to delete the pushed tag on publish failure (e.g., in the publish
step's failure handler) so tags are only kept when bun publish --tag next
succeeds; update the job that currently performs the tag push to reference the
new post-publish placement and ensure it runs conditionally based on publish
success.

2 changes: 1 addition & 1 deletion openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"id": "genie",
"name": "Genie",
"description": "Skills, agents, and hooks for the Genie CLI terminal orchestration toolkit",
"version": "3.260310.5",
"version": "3.260314.8",
"configSchema": {
"type": "object",
"additionalProperties": false,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@automagik/genie",
"version": "3.260310.5",
"version": "3.260314.8",
"description": "Collaborative terminal toolkit for human + AI workflows",
"type": "module",
"bin": {
Expand Down
2 changes: 1 addition & 1 deletion plugins/genie/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "genie",
"version": "3.260310.5",
"version": "3.260314.8",
"description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.",
"author": {
"name": "Namastex Labs"
Expand Down
2 changes: 1 addition & 1 deletion plugins/genie/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "genie-plugin",
"version": "3.260310.5",
"version": "3.260314.8",
"private": true,
"description": "Runtime dependencies for genie bundled CLIs",
"type": "module",
Expand Down
178 changes: 165 additions & 13 deletions src/genie-commands/update.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { chmod, copyFile, mkdir, unlink } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { genieConfigExists, loadGenieConfig } from '../lib/genie-config.js';
import { genieConfigExists, loadGenieConfig, saveGenieConfig } from '../lib/genie-config.js';

const GENIE_HOME = process.env.GENIE_HOME || join(homedir(), '.genie');
const GENIE_SRC = join(GENIE_HOME, 'src');
Expand Down Expand Up @@ -144,26 +144,26 @@ async function detectInstallationType(): Promise<InstallationType> {
return hasBun ? 'bun' : 'npm';
}

async function updateViaBun(): Promise<void> {
log('Updating via bun...');
const result = await runCommand('bun', ['install', '-g', '@automagik/genie@latest']);
async function updateViaBun(channel: string): Promise<void> {
log(`Updating via bun (channel: ${channel})...`);
const result = await runCommand('bun', ['install', '-g', `@automagik/genie@${channel}`]);
if (!result.success) {
error('Failed to update via bun');
process.exit(1);
}
console.log();
success('Genie CLI updated!');
success(`Genie CLI updated (${channel})!`);
}

async function updateViaNpm(): Promise<void> {
log('Updating via npm...');
const result = await runCommand('npm', ['install', '-g', '@automagik/genie@latest']);
async function updateViaNpm(channel: string): Promise<void> {
log(`Updating via npm (channel: ${channel})...`);
const result = await runCommand('npm', ['install', '-g', `@automagik/genie@${channel}`]);
if (!result.success) {
error('Failed to update via npm');
process.exit(1);
}
console.log();
success('Genie CLI updated!');
success(`Genie CLI updated (${channel})!`);
}

async function updateSource(): Promise<void> {
Expand Down Expand Up @@ -286,14 +286,164 @@ async function symlinkOrCopy(src: string, dest: string): Promise<void> {
}
}

export async function updateCommand(): Promise<void> {
// ============================================================================
// Plugin Sync — update Claude Code plugin cache after CLI update
// ============================================================================

function copyDirSync(src: string, dest: string): void {
mkdirSync(dest, { recursive: true });
for (const entry of readdirSync(src, { withFileTypes: true })) {
const srcPath = join(src, entry.name);
const destPath = join(dest, entry.name);
if (entry.isDirectory()) {
copyDirSync(srcPath, destPath);
} else {
copyFileSync(srcPath, destPath);
}
}
}
Comment on lines +293 to +304

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Symlinks are followed but not explicitly handled.

copyFileSync follows symlinks by default. If the plugin source contains symlinks to directories, entry.isDirectory() returns false for the symlink entry, causing copyFileSync to fail. This is unlikely for typical npm packages but worth noting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/genie-commands/update.ts` around lines 293 - 304, The current copyDirSync
follows symlinks because it treats non-directory Dirent entries the same and
calls copyFileSync, which will fail for symlinks to directories; update
copyDirSync to explicitly detect symlinks (use entry.isSymbolicLink() and/or
lstat) before copying: when a symlink is found, call readlink to get its target
and create an equivalent symlink at dest (using symlink), rather than calling
copyFileSync; keep the existing recursion for real directories
(entry.isDirectory()) and use copyFileSync only for regular files—refer to the
copyDirSync function, entry.isDirectory(), entry.isSymbolicLink(), copyFileSync,
readdirSync, readlink and symlink handling to locate where to add this logic.


async function resolveGlobalPkgDir(installType: InstallationType): Promise<string | null> {
// Prefer the package manager that was actually used for this update
if (installType === 'bun') {
const bunPath = join(homedir(), '.bun', 'install', 'global', 'node_modules', '@automagik', 'genie');
if (existsSync(bunPath)) return bunPath;
}

if (installType === 'npm') {
// Dynamic resolution via npm root -g (handles nvm/fnm/volta)
const npmRootResult = await runCommandSilent('npm', ['root', '-g']);
if (npmRootResult.success) {
const npmPath = join(npmRootResult.output.trim(), '@automagik', 'genie');
if (existsSync(npmPath)) return npmPath;
}
}

// Fallback: try both regardless of installType
const bunFallback = join(homedir(), '.bun', 'install', 'global', 'node_modules', '@automagik', 'genie');
if (existsSync(bunFallback)) return bunFallback;

const npmRootFallback = await runCommandSilent('npm', ['root', '-g']);
if (npmRootFallback.success) {
const npmPath = join(npmRootFallback.output.trim(), '@automagik', 'genie');
if (existsSync(npmPath)) return npmPath;
}

return null;
}

async function syncPlugin(installType: InstallationType): Promise<void> {
log('Syncing Claude Code plugin...');

const globalPkgDir = await resolveGlobalPkgDir(installType);
if (!globalPkgDir) {
log('Could not find installed package — skipping plugin sync');
return;
}

const pluginSrc = join(globalPkgDir, 'plugins', 'genie');
if (!existsSync(pluginSrc)) {
log('Plugin source not found in package — skipping plugin sync');
return;
}

// Read version from installed package
let version: string;
try {
const pkg = JSON.parse(readFileSync(join(globalPkgDir, 'package.json'), 'utf-8'));
version = pkg.version;
} catch {
log('Could not read package version — skipping plugin sync');
return;
}
Comment on lines +350 to +358

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Version string used in path construction without sanitization.

The version from package.json is directly used in cacheDir path construction (Line 362). A malicious version string containing path separators (e.g., ../..) could cause rmSync to delete unintended directories. While the package is self-published and low risk in practice, defensive sanitization is advisable.

Proposed fix
   try {
     const pkg = JSON.parse(readFileSync(join(globalPkgDir, 'package.json'), 'utf-8'));
-    version = pkg.version;
+    version = pkg.version?.replace(/[/\\]/g, '_');
+    if (!version || !/^\d+\.\d+/.test(version)) {
+      log('Invalid package version — skipping plugin sync');
+      return;
+    }
   } catch {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Read version from installed package
let version: string;
try {
const pkg = JSON.parse(readFileSync(join(globalPkgDir, 'package.json'), 'utf-8'));
version = pkg.version;
} catch {
log('Could not read package version — skipping plugin sync');
return;
}
// Read version from installed package
let version: string | undefined;
try {
const pkg = JSON.parse(readFileSync(join(globalPkgDir, 'package.json'), 'utf-8'));
version = pkg.version?.replace(/[/\\]/g, '_');
if (!version || !/^\d+\.\d+/.test(version)) {
log('Invalid package version — skipping plugin sync');
return;
}
} catch {
log('Could not read package version — skipping plugin sync');
return;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/genie-commands/update.ts` around lines 350 - 358, The code reads
package.version into the variable version and then uses it to build cacheDir and
later calls rmSync, which is unsafe if version contains path separators;
sanitize or validate version before use (e.g., allow only a strict semver-like
pattern or escape/remove path separators) and construct cacheDir from the
sanitized value; update the logic where cacheDir is computed (references:
version, cacheDir, globalPkgDir, join) and ensure rmSync is only called on the
validated/sanitized path to prevent directory traversal or accidental deletes.


// Copy to Claude Code plugin cache
const claudePlugins = join(homedir(), '.claude', 'plugins');
const cacheDir = join(claudePlugins, 'cache', 'automagik', 'genie', version);

try {
// Clean existing cache dir if it exists (stale version)
if (existsSync(cacheDir)) {
rmSync(cacheDir, { recursive: true, force: true });
}
copyDirSync(pluginSrc, cacheDir);
} catch (err) {
error(`Failed to copy plugin: ${err}`);
return;
}

// Update installed_plugins.json registry
const registryPath = join(claudePlugins, 'installed_plugins.json');
try {
if (existsSync(registryPath)) {
const registry = JSON.parse(readFileSync(registryPath, 'utf-8'));
const entries = registry.plugins?.['genie@automagik'];
if (Array.isArray(entries)) {
for (const entry of entries) {
if (entry.scope === 'user') {
entry.installPath = cacheDir;
entry.version = version;
entry.lastUpdated = new Date().toISOString();
}
}
writeFileSync(registryPath, JSON.stringify(registry, null, 2));
}
}
} catch (err) {
log(`Registry update failed (non-fatal): ${err}`);
}
Comment on lines +375 to +394

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Registry update is not atomic.

Read-modify-write on installed_plugins.json without locking could race with Claude Code. Probability is low (manual update command), but consider a backup or temp-file-rename pattern if this causes issues in practice.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/genie-commands/update.ts` around lines 375 - 394, The read-modify-write
of installed_plugins.json via registryPath is not atomic and can race; change
the update in the registry update block to perform an atomic write (write JSON
to a temporary file in the same directory, fsync the temp file, then rename/move
it to overwrite installed_plugins.json) and consider creating a timestamped
backup before replacing; ensure you still parse the file
(readFileSync/JSON.parse) and update the same entries logic
(registry.plugins?.['genie@automagik'], entry.scope === 'user', set
installPath/version/lastUpdated) but replace writeFileSync(registryPath, ...)
with the temp-file-write+fsync+rename (or use an advisory lock around the
read-modify-write if available in your environment) so the operation is atomic
and recoverable.


success(`Plugin synced to v${version}`);
}

// ============================================================================
// Channel Management
// ============================================================================

async function resolveChannel(options: { next?: boolean; stable?: boolean }): Promise<string> {
// Explicit flags override everything
if (options.next) return 'next';
if (options.stable) return 'latest';

Comment on lines +403 to +407

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject conflicting channel flags instead of silently preferring --next.

If users pass both --next and --stable, the current logic picks next (Line 291) and persists it. This is ambiguous input and should fail fast.

Proposed fix
 export async function updateCommand(options: { next?: boolean; stable?: boolean } = {}): Promise<void> {
+  if (options.next && options.stable) {
+    error('Choose either --next or --stable, not both.');
+    process.exit(1);
+  }
+
   console.log();
   console.log('\x1b[1m🧞 Genie CLI Update\x1b[0m');
   console.log('\x1b[2m────────────────────────────────────\x1b[0m');
   console.log();

Also applies to: 317-327

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/genie-commands/update.ts` around lines 289 - 293, The resolveChannel
function currently prefers options.next when both options.next and
options.stable are passed; change it to detect the conflicting flags (both true)
and throw a user-facing error (e.g., throw new Error or a CLI-specific exit with
a clear message) instead of silently choosing 'next'; update the same
conflicting-flag logic in the other channel-resolution block in this file (the
second branch handling next/stable around lines 317-327) to perform the same
validation so any input with both --next and --stable fails fast and
consistently.

// Read saved channel from config
if (genieConfigExists()) {
try {
const config = await loadGenieConfig();
if (config.updateChannel) return config.updateChannel;
} catch {
// Ignore config errors
}
}

return 'latest';
}

async function persistChannel(channel: string): Promise<void> {
try {
const config = await loadGenieConfig();
config.updateChannel = channel as 'latest' | 'next';
await saveGenieConfig(config);
} catch {
// Non-fatal — channel preference lost but update still works
}
Comment on lines +421 to +428

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t silently ignore channel persistence failures after explicit switch requests.

When users run genie update --next|--stable, failing to save that preference is meaningful; swallowing the error makes later behavior inconsistent without explanation.

Proposed fix
 async function persistChannel(channel: string): Promise<void> {
   try {
     const config = await loadGenieConfig();
     config.updateChannel = channel as 'latest' | 'next';
     await saveGenieConfig(config);
-  } catch {
-    // Non-fatal — channel preference lost but update still works
+  } catch (err) {
+    const message = err instanceof Error ? err.message : String(err);
+    console.warn(`Warning: failed to persist update channel "${channel}": ${message}`);
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/genie-commands/update.ts` around lines 307 - 314, persistChannel
currently swallows any error from loadGenieConfig()/saveGenieConfig, hiding
failures when users explicitly switch channels; update persistChannel to handle
errors by catching exceptions from loadGenieConfig/saveGenieConfig and either
logging a clear, user-facing error (e.g., via console.error or the existing
logger) that includes the caught error details and the channel attempted, and
then rethrow or return a rejected Promise so callers of persistChannel (the
command handler for update --next/--stable) can surface the failure to the user;
reference the persistChannel function and the loadGenieConfig/saveGenieConfig
calls to locate where to add the error reporting and propagation.

}

export async function updateCommand(options: { next?: boolean; stable?: boolean } = {}): Promise<void> {
console.log();
console.log('\x1b[1m🧞 Genie CLI Update\x1b[0m');
console.log('\x1b[2m────────────────────────────────────\x1b[0m');
console.log();

const channel = await resolveChannel(options);

// Persist channel when explicitly switching
if (options.next || options.stable) {
await persistChannel(channel);
}

const installType = await detectInstallationType();
log(`Detected installation: ${installType}`);
log(`Channel: ${channel}${channel === 'next' ? ' (dev builds)' : ' (stable)'}`);
console.log();

if (installType === 'unknown') {
Expand All @@ -312,10 +462,12 @@ export async function updateCommand(): Promise<void> {
await updateSource();
break;
case 'bun':
await updateViaBun();
await updateViaBun(channel);
await syncPlugin(installType);
break;
case 'npm':
await updateViaNpm();
await updateViaNpm(channel);
await syncPlugin(installType);
break;
}
}
7 changes: 6 additions & 1 deletion src/genie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,12 @@ program
});

program.command('doctor').description('Run diagnostic checks on genie installation').action(doctorCommand);
program.command('update').description('Update Genie CLI to the latest version').action(updateCommand);
program
.command('update')
.description('Update Genie CLI to the latest version')
.option('--next', 'Switch to dev builds (npm @next tag)')
.option('--stable', 'Switch to stable releases (npm @latest tag)')
Comment on lines +97 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Update command description is outdated.

Line 97 says “latest version”, but Lines 98-99 now support both stable (latest) and dev (next) channels. Please make the description channel-aware to avoid confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/genie.ts` around lines 97 - 99, Update the .description(...) text for the
Update command to reflect both channel options instead of saying "latest
version": change the string passed to the .description call (adjacent to
.option('--next', ...) and .option('--stable', ...)) to mention that the command
can switch channels (stable/@latest or dev/@next) or to a specific channel like
"Update or switch Genie CLI channel (stable `@latest` or dev `@next`)"; keep the
rest of the command builder intact.

.action(updateCommand);
program.command('uninstall').description('Remove Genie CLI and clean up hooks').action(uninstallCommand);

const shortcuts = program.command('shortcuts').description('Manage tmux keyboard shortcuts');
Expand Down
72 changes: 3 additions & 69 deletions src/lib/agent-directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@
*/

import { existsSync } from 'node:fs';
import { mkdir, open, readFile, stat, unlink, writeFile } from 'node:fs/promises';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { BUILTIN_COUNCIL_MEMBERS, BUILTIN_ROLES, type BuiltinAgent } from './builtin-agents.js';
import { acquireLock } from './file-lock.js';

// ============================================================================
// Types
Expand Down Expand Up @@ -63,73 +64,6 @@ function getDirectoryFilePath(): string {
return join(getGlobalDir(), 'agent-directory.json');
}

// ============================================================================
// File Locking — prevents concurrent load/modify/save races
// ============================================================================

const LOCK_TIMEOUT_MS = 5000;
const LOCK_RETRY_MS = 50;
const LOCK_STALE_MS = 10000;

async function tryCleanStaleLock(lockPath: string): Promise<boolean> {
try {
const lockStat = await stat(lockPath);
if (Date.now() - lockStat.mtimeMs > LOCK_STALE_MS) {
try {
await unlink(lockPath);
} catch {
/* race with other cleanup */
}
return true;
}
} catch {
return true; // lock gone, retry
}
return false;
}

async function tryCreateLock(lockPath: string): Promise<(() => Promise<void>) | null> {
try {
const handle = await open(lockPath, 'wx');
await handle.writeFile(String(process.pid));
await handle.close();
return async () => {
try {
await unlink(lockPath);
} catch {
/* already removed */
}
};
} catch (err) {
const errCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined;
if (errCode !== 'EEXIST') throw err;
return null;
}
}

async function acquireLock(): Promise<() => Promise<void>> {
const lockPath = `${getDirectoryFilePath()}.lock`;
const deadline = Date.now() + LOCK_TIMEOUT_MS;

while (true) {
const release = await tryCreateLock(lockPath);
if (release) return release;

const cleaned = await tryCleanStaleLock(lockPath);
if (cleaned) continue;

if (Date.now() > deadline) {
try {
await unlink(lockPath);
} catch {
throw new Error(`Directory lock timeout: could not remove stale lock at ${lockPath}`);
}
continue;
}
await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
}
}

// ============================================================================
// Internal
// ============================================================================
Expand All @@ -151,7 +85,7 @@ async function saveDirectory(data: AgentDirectoryData): Promise<void> {
}

async function withDirectory<T>(fn: (data: AgentDirectoryData) => T | Promise<T>): Promise<T> {
const release = await acquireLock();
const release = await acquireLock(getDirectoryFilePath());
try {
const data = await loadDirectory();
const result = await fn(data);
Expand Down
Loading