Skip to content

Feat/dual executable packmind packmind-cli#262

Open
MaloPromyze wants to merge 6 commits intomainfrom
feat/dual-executable-packmind
Open

Feat/dual executable packmind packmind-cli#262
MaloPromyze wants to merge 6 commits intomainfrom
feat/dual-executable-packmind

Conversation

@MaloPromyze
Copy link
Copy Markdown
Contributor

Summary

  • Register packmind as a second bin entry in package.json, pointing to the same main.cjs entrypoint
  • Create a packmind symlink during install (install.sh) and maintain it on self-update (updateHandler.ts)
  • No user-facing change: packmind-cli remains the primary name, all output strings unchanged

This is the first step toward renaming the CLI to packmind. By silently shipping the alias now, we ensure both names work before any deprecation or user-visible switch happens.

What's changed

Area Detail
apps/cli/package.json Added "packmind": "./main.cjs" to bin
apps/cli/scripts/install.sh Creates packmind symlink after installing packmind-cli binary
apps/cli/src/infra/commands/updateHandler.ts New createForwardCompatSymlink() recreates the alias after self-update
apps/cli/src/infra/commands/updateHandler.spec.ts 7 new test cases covering symlink creation, removal, Windows .exe, and error handling
CHANGELOG.MD + apps/cli/CHANGELOG.MD Entries under [Unreleased] > Added

What does NOT change

  • CLI identity (packmind-cli), version output, usage messages
  • Install script download URLs and BINARY_NAME
  • Build artifacts, CI workflows, frontend, MCP, docs

MaloPromyze and others added 4 commits March 27, 2026 15:04
Both `packmind-cli` and `packmind` now point to the same entrypoint.
`packmind-cli` remains primary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
After installing the primary `packmind-cli` binary, the installer now
creates a `packmind` symlink pointing to it for forward compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add `createForwardCompatSymlink()` to maintain a `packmind` symlink
alongside the primary `packmind-cli` binary after self-update, mirroring
the install script behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@MaloPromyze MaloPromyze marked this pull request as ready for review March 27, 2026 15:02
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 27, 2026

Greptile Summary

This PR introduces a packmind alias for the packmind-cli binary as a first step toward a CLI rename. The changes span package.json (new bin entry), install.sh (symlink creation post-install), updateHandler.ts (symlink recreation post-update), and corresponding tests.\n\nKey changes:\n- apps/cli/package.json: Adds \"packmind\": \"./main.cjs\" alongside the existing packmind-cli bin entry, so npm install -g registers both names.\n- apps/cli/scripts/install.sh: After placing the primary packmind-cli binary, calls ln -sf \"$target_name\" \"$alias_path\" to create a packmind → packmind-cli relative symlink in the same directory.\n- apps/cli/src/infra/commands/updateHandler.ts: Adds createForwardCompatSymlink(), which is called at the end of updateViaExecutableReplace. It inspects the current executable's basename and creates the missing counterpart (alias or primary) as a relative symlink, wrapping both unlinkSync and symlinkSync in try-catch to make the operation non-critical.\n- apps/cli/src/infra/commands/updateHandler.spec.ts: 7 new test cases covering the normal linux path (running as packmind-cli and as packmind), Windows .exe extension handling, unknown binary names, and ENOENT tolerance on unlinkSync.\n\nThe implementation is generally clean and the test coverage is solid. Several concerns were raised in previous review rounds (symlink-role swap when self-updating via the alias, missing lstat guard before unlinkSync, set -eu aborting install.sh on symlink failure, and a missing test assertion for the reverse unlink case) — those threads remain open and the code has not yet been updated to address them.

Confidence Score: 4/5

Not yet safe to merge — open P1 concerns from prior review rounds (binary-role swap on alias self-update, missing lstat guard before unlinkSync, set -eu aborting install on symlink failure) remain unaddressed in the code.

All four issues flagged in the previous review threads are still present in the code unchanged. Two of them (binary-role swap and destructive unlinkSync without lstat check) are P1 defects that can cause silent data loss or a permanently inverted symlink layout for users who run packmind update. The install.sh abort risk is also P1. These warrant resolution before merging.

apps/cli/src/infra/commands/updateHandler.ts and apps/cli/scripts/install.sh need the most attention.

Important Files Changed

Filename Overview
apps/cli/src/infra/commands/updateHandler.ts Adds createForwardCompatSymlink() with non-critical error handling; the binary-role-swap bug (when self-updating via the packmind alias) and the missing lstat guard before unlinkSync flagged in prior review rounds remain unresolved.
apps/cli/scripts/install.sh Creates the packmind symlink after install using ln -sf; set -eu means a symlink failure aborts an otherwise-successful install (flagged in prior review), and ln -sf on Windows MSYS2/Git Bash may silently produce a file copy rather than a real symlink.
apps/cli/src/infra/commands/updateHandler.spec.ts Good coverage for the new function; the missing unlinkSync assertion for the running-as-packmind reverse case was flagged in a prior review round and is still absent.
apps/cli/package.json Straightforward addition of a second bin entry; no issues found.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    subgraph install["install.sh — fresh install"]
        A["Download packmind-cli binary"] --> B["mv → INSTALL_DIR/packmind-cli"]
        B --> C["chmod +x packmind-cli"]
        C --> D["ln -sf packmind-cli INSTALL_DIR/packmind"]
    end

    subgraph update_primary["updateViaExecutableReplace — running as packmind-cli"]
        E["Download to packmind-cli.update-tmp"] --> F["renameSync → packmind-cli"]
        F --> G["chmodSync packmind-cli"]
        G --> H["createForwardCompatSymlink(packmind-cli)"]
        H --> I["unlinkSync packmind (existing alias)"]
        I --> J["symlinkSync packmind-cli → packmind ✓"]
    end

    subgraph update_alias["updateViaExecutableReplace — running as packmind (alias)"]
        K["Download to packmind.update-tmp"] --> L["renameSync → packmind (replaces symlink with real binary)"]
        L --> M["chmodSync packmind"]
        M --> N["createForwardCompatSymlink(packmind)"]
        N --> O["unlinkSync packmind-cli (real binary!)"]
        O --> P["symlinkSync packmind → packmind-cli ⚠️ roles swapped"]
    end

    install --> update_primary
    install --> update_alias
Loading

Reviews (3): Last reviewed commit: "Revert "🐛 fix(types): accept .mdc exten..." | Re-trigger Greptile

Comment on lines +256 to +258
alias_path="${INSTALL_DIR}/${alias_name}"
ln -sf "$target_name" "$alias_path"
info "Created symlink: $alias_path -> $target_name"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Symlink failure aborts install under set -eu

The script runs with set -eu (line 11), which means any non-zero exit from ln -sf will abort the entire script. If symlink creation fails for any reason (e.g. filesystem that doesn't support symlinks, a directory already named packmind, or insufficient write permissions), the script exits with a non-zero code — even though the primary packmind-cli binary was already successfully installed at this point.

This is inconsistent with the TypeScript createForwardCompatSymlink function, which explicitly wraps symlinkSync in a try/catch and treats the failure as non-critical.

Consider protecting the ln call against failure:

Suggested change
alias_path="${INSTALL_DIR}/${alias_name}"
ln -sf "$target_name" "$alias_path"
info "Created symlink: $alias_path -> $target_name"
ln -sf "$target_name" "$alias_path" || warn "Could not create packmind symlink (non-critical)"
info "Created symlink: $alias_path -> $target_name"

Comment on lines +161 to +166
} else if (currentBasename === aliasName) {
targetName = aliasName;
symlinkName = primaryName;
} else {
return;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Self-update via packmind alias silently swaps binary roles

When the user runs packmind update (using the alias), deps.executablePath resolves to the path of the packmind symlink (e.g. /usr/local/bin/packmind). The update sequence then:

  1. Writes the new binary to /usr/local/bin/packmind (replacing the symlink with a real file via renameSync).
  2. Calls createForwardCompatSymlink('/usr/local/bin/packmind', ...), which detects currentBasename === aliasName, so it unlinks /usr/local/bin/packmind-cli and creates a symlink packmind-cli → packmind.

This permanently deletes the original packmind-cli real binary and replaces it with a symlink, silently swapping which binary is canonical. The layout after update via alias would be the inverse of what install.sh sets up (packmind-cli pointing to packmind instead of vice-versa). While both names keep working, repeated alternating updates between the two commands would repeatedly swap roles, and tooling that inspects the install layout could be confused.

A safer approach would be to unconditionally create packmind → packmind-cli regardless of which binary invoked the update:

export function createForwardCompatSymlink(
  currentPath: string,
  platform: string,
): void {
  const dir = path.dirname(currentPath);
  const ext = platform === 'win32' ? '.exe' : '';
  const primaryName = `packmind-cli${ext}`;
  const aliasName = `packmind${ext}`;
  const currentBasename = path.basename(currentPath);

  // Only operate when the real binary is the primary name
  if (currentBasename !== primaryName) {
    return;
  }

  const symlinkPath = path.join(dir, aliasName);
  try {
    unlinkSync(symlinkPath);
  } catch {
    // May not exist
  }
  try {
    symlinkSync(primaryName, symlinkPath);
    logInfoConsole(
      `Created forward-compatible symlink: ${symlinkPath} -> ${primaryName}`,
    );
  } catch {
    // Non-critical
  }
}

Comment on lines +477 to +486
describe('when running as packmind', () => {
it('creates packmind-cli symlink', () => {
createForwardCompatSymlink('/usr/local/bin/packmind', 'linux');

expect(fs.symlinkSync).toHaveBeenCalledWith(
'packmind',
'/usr/local/bin/packmind-cli',
);
});
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Missing unlinkSync assertion for the reverse case

The "when running as packmind-cli" block explicitly verifies that unlinkSync is called before the new symlink is written (test: "removes existing symlink before creating new one"). The symmetric "when running as packmind" block only checks that symlinkSync is invoked, leaving the cleanup/teardown path for packmind-cli untested.

Consider adding the corresponding assertion:

Suggested change
describe('when running as packmind', () => {
it('creates packmind-cli symlink', () => {
createForwardCompatSymlink('/usr/local/bin/packmind', 'linux');
expect(fs.symlinkSync).toHaveBeenCalledWith(
'packmind',
'/usr/local/bin/packmind-cli',
);
});
});
describe('when running as packmind', () => {
beforeEach(() => {
createForwardCompatSymlink('/usr/local/bin/packmind', 'linux');
});
it('creates packmind-cli symlink', () => {
expect(fs.symlinkSync).toHaveBeenCalledWith(
'packmind',
'/usr/local/bin/packmind-cli',
);
});
it('removes existing symlink before creating new one', () => {
expect(fs.unlinkSync).toHaveBeenCalledWith('/usr/local/bin/packmind-cli');
});
});

Copy link
Copy Markdown
Contributor

@vincent-psarga vincent-psarga left a comment

Choose a reason for hiding this comment

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

Shouldn't this file: https://raw.githubusercontent.com/PackmindHub/packmind/main/apps/cli/scripts/install.sh be updated too to create both packmind-cli and packmind executables?


## Added

- CLI can now also be invoked as `packmind` (alias for `packmind-cli`)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Drop this - it's already in cli Changelog

Comment on lines +168 to +173
const symlinkPath = path.join(dir, symlinkName);
try {
unlinkSync(symlinkPath);
} catch {
// May not exist
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 unlinkSync does not guard against removing a real binary

The unlinkSync(symlinkPath) call removes whatever exists at symlinkPath — symlink or real file — without first verifying it is a symlink. If a user happens to have an independently-installed binary named packmind (e.g. from an earlier npm global install of a package of that name) living in the same directory as packmind-cli, a packmind-cli update will silently delete that binary and replace it with a symlink.

Guarding with lstatSync before unlinking makes the intent explicit and prevents unintended data loss:

Suggested change
const symlinkPath = path.join(dir, symlinkName);
try {
unlinkSync(symlinkPath);
} catch {
// May not exist
}
const symlinkPath = path.join(dir, symlinkName);
try {
const stat = lstatSync(symlinkPath);
if (stat.isSymbolicLink()) {
unlinkSync(symlinkPath);
}
} catch {
// May not exist
}

(You would need to import lstatSync from 'fs' alongside the existing imports.)

@sonarqubecloud
Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants