Skip to content

Shell Commands

opencode-agent[bot] edited this page Sep 27, 2026 · 5 revisions

Shell & Commands

JNode's command-line environment provides a Bash-like shell experience, implemented entirely in Java, with a custom command framework for parsing arguments and handling I/O redirection.

Overview

Unlike Unix, where commands are typically separate executable binaries launched via fork() and exec(), JNode commands are Java classes executed within the same JVM (often within their own Isolates or Proclets).

The shell subsystem handles interactive input, script execution (e.g., jnode.ini), tab-completion, aliasing, and I/O redirection.

Architecture

Location: shell/src/shell/org/jnode/shell/

1. The Shell (CommandShell)

The main interactive loop. It reads input from the user (via CommandShellReader), parses it, and executes it using an interpreter.

2. The Interpreter (CommandInterpreter)

JNode supports different interpreters.

  • DefaultInterpreter: Executes command sequences (a && b, a || b, a ; b). No pipelines, no redirection; a bare | is a literal character.
  • RedirectingInterpreter: Extends DefaultInterpreter with Unix-style pipelines (|) and I/O redirection (>, <). This is the shell's initial interpreter.
  • bjorne: A Bourne-compatible shell script interpreter capable of running more complex shell scripts; has its own tokenizer and AST.

See Shell-Interpreter for the tokenizer, sequence grammar, exit-status rules, and escapeWord round-trip.

3. The Invoker (CommandInvoker)

Decides how the Java class representing the command is run.

  • DefaultCommandInvoker: Runs the command in the current context.
  • ThreadCommandInvoker: Runs the command in a new VmThread.
  • ProcletCommandInvoker: Runs the command in a new Proclet (a lightweight isolated context sharing the same address space but with separate standard streams and environment variables).

Writing a Command

Commands are implemented by extending AbstractCommand (or implementing Command).

public class MyCommand extends AbstractCommand {
    private final StringArgument arg = new StringArgument("name", Argument.MANDATORY, "Your name");

    public MyCommand() {
        super("Prints a greeting");
        registerArguments(arg);
    }

    public void execute() throws Exception {
        PrintWriter out = getOutput().getPrintWriter();
        out.println("Hello, " + arg.getValue());
    }
}

Argument Parsing (org.jnode.shell.syntax)

JNode commands do not typically parse String[] args manually. Instead, they register Argument objects (e.g., FileArgument, StringArgument, FlagArgument). The shell's syntax parser automatically validates input against these arguments before execute() is called. This also automatically provides --help text and tab-completion.

For detailed information on how the syntax system works, see Syntax.

Command Registration

Commands must be registered to be accessible from the shell. This is done via Plugin Extension Points.

In a plugin's plugin.xml descriptor:

<extension point="org.jnode.shell.aliases">
  <alias name="mycmd" class="com.example.MyCommand"/>
</extension>

This tells the AliasManager to map the keyword mycmd to the given class. When the user types mycmd, the shell instantiates the class and invokes it.

Built-in Commands

Many standard Unix-like commands are implemented in shell/src/shell/org/jnode/shell/command/ and cli/src/.

  • File operations: ls, cp, mv, rm, cat, mkdir
  • System info: free, ps, threads, gc, dmesg
  • Network: ping, ifconfig, route
  • Disk: mount, fdisk, format

mkdir -p Recursive Creation

cli/src/commands/org/jnode/command/file/MkdirCommand.java (84 lines) supports the POSIX -p / --parents flag:

private final FileArgument argDir;
private final FlagArgument argParents;
// constructor:
argDir     = new FileArgument("directory", Argument.MANDATORY, help_dir);  // NONEXISTENT was REMOVED
argParents = new FlagArgument("parents", Argument.OPTIONAL, help_parents);

execute() semantics:

  • With -p: if the target already exists and is a directory, return silently (idempotent); if it exists and is not a directory, print err_exists_not_dir and exit(1); otherwise dir.mkdirs(), exit(1) on failure.
  • Without -p: unchanged — an existing path prints "%s already exists%n" and exit(1); dir.mkdir() failure prints err_cant_create and exit(1).

Argument.NONEXISTENT was dropped from argDir because that flag only affects tab completion (Argument.isNonExistent() → FileArgument.complete()); with it set, an existing directory could not be completed. It has no runtime validation effect.

Note that mkdir uses only java.io.File.exists()/isDirectory()/mkdir()/mkdirs() — it does not go through the JNode-native FSDirectory.createDirectoryEntry / AbstractFileSystem.createDirectory SPI (see VFS-Layer). java.io.File itself comes from the external classlib.jar.

Descriptor (cli/descriptors/org.jnode.command.file.xml:339-346):

<syntax alias="mkdir">
  <sequence description="create a new directory">
    <optionSet>
      <option argLabel="parents" shortName="p" longName="parents"/>
    </optionSet>
    <argument argLabel="directory" description="create a new directory"/>
  </sequence>
</syntax>

The ant Command

ant is exposed as a shell command (cli/descriptors/org.jnode.command.dev.ant.xml) and needs a source-level override of one Apache Ant class.

cli/src/commands/org/apache/tools/ant/taskdefs/ExecuteJava.java (277 lines) is a byte-for-byte copy of Apache Ant 1.6.3's ExecuteJava with a single added line:

// ExecuteJava.java:131
main.setAccessible(true);

Everything else is upstream: setJavaCommand, setClasspath, setSystemProperties, setPermissions, setOutput (deprecated no-op), setTimeout, execute(Project), run(), timeoutOccured, killedProcess, fork.

Why the Override Is Needed

execute(Project) resolves the main class and its main method, then run() invokes it:

main = target.getMethod("main", param);              // :121  succeeds
if (main == null) { throw new BuildException(...); }  // :122-125  dead code
if ((main.getModifiers() & Modifier.STATIC) == 0) { ... }
main.setAccessible(true);                             // :131  THE FIX
...
main.invoke(null, argument);                          // :201

A public static void main(String[]) declared in a package-private class passes both getMethod and the STATIC check, but Method.invoke performs the standard caller→declaring-class accessibility check. ExecuteJava lives in a different classloader/runtime-package, so invoke throws IllegalAccessException; run()'s catch (Throwable) swallows it into caught, and execute() rethrows it wrapped in a BuildException. setAccessible(true) suppresses the check.

Regression tests (shell/src/test/org/jnode/test/shell/ExecuteJavaPackagePrivateTest.java) pin both halves: one asserts the IllegalAccessException without setAccessible, the other asserts the side-effect system property is set with it. Fixtures PackagePrivateMain and PublicMain signal via System.setProperty("<FQCN>.ran", "true") rather than stdout. The suite runs on the host JVM (shell/build-tests.xml:62-74, fork="on" haltonfailure="on"), validating standard-JVM reflection semantics, not JNode's VM.

Classpath and Permissions

core/descriptors/org.apache.tools.ant.xml was changed in three ways:

<runtime>
  <library name="jnode-cli.jar">                              <!-- NEW -->
    <export name="org.apache.tools.ant.taskdefs.ExecuteJava"/>
  </library>
  <library name="ant.jar">
    ...
    <exclude name="org.apache.tools.ant.taskdefs.ExecuteJava"/>  <!-- NEW -->
  </library>
</runtime>
<permission class="java.lang.RuntimePermission"  name="accessDeclaredMembers"/>        <!-- NEW -->
<permission class="java.lang.reflect.ReflectPermission" name="suppressAccessChecks"/> <!-- NEW -->

cli/build.xml:29,56-59 compiles all of cli/src/commands into jnode-cli.jar, so this file is a source-level shadow of the class inside the prebuilt core/lib/ant.jar (which still contains the unpatched 2008-06-27 .class).

Gotcha: <export>/<exclude> are declarative only in this codebase. LibraryModel parses them and Library exposes getExports()/getExcludes(), but nothing consumes them. Real resolution is PluginsClassLoader.findClass (:40-50), which iterates the plugin registry in order and returns the first loader whose containsClass(name) is true, with PluginClassLoaderImpl.findPluginClass (:162-238) checking prerequisite loaders first. The override therefore works because of plugin-list order: all/conf/full-plugin-list.xml:28 includes default-plugin-list.xml, which places the org.jnode.command.* plugins sharing jnode-cli.jar at lines 73-80, well before org.apache.tools.ant at full-plugin-list.xml:46. See Plugin-Descriptor-Schema and Plugin-System.

The two new <permission> elements are exactly what Method.setAccessible(true) needs. They are correctness-by-declaration today, because JNodeSecurityManagerSettings.ENABLED = false (core/src/template/org/jnode/security/JNodeSecurityManagerSettings.java:29) compiles enforcement out. Separately, JNodeSecurityManager.defaultCheckPermission (:41-46) unconditionally forbids setSecurityManager, so the ant descriptors' <permission ... name="setSecurityManager" actions="*"/> can never take effect.

I/O Redirection

Because all commands run in the same JVM, System.out and System.in cannot be safely redirected globally. Instead, commands use getInput(), getOutput(), and getError() provided by AbstractCommand. The Proclet system manages these streams per-thread-group, allowing CommandA | CommandB to work correctly without polluting the global System.out.

Black-Box Command Tests

shell/src/test/org/jnode/shell/harness/TestHarness.java (386 lines) is the black-box command test runner used by the all-blackbox / bjorne targets in shell/build-tests.xml:40-60 (TestHarness -E -F -v -s <root.dir>). It reads XML testSpec files and dispatches by runMode to ClassTestRunner, CommandTestRunner or ScriptTestRunner (TestHarness.execute :247-259).

Temporary Directory Lifecycle

TestHarness uses a single fixed path under java.io.tmpdir:

// prepareTmpDir() :150-160
tempDir = new File(System.getProperty("java.io.tmpdir"), "jnodeTestDir");
if (tempDir.isDirectory()) cleanDir(tempDir);
else if (tempDir.isFile()) { tempDir.delete(); tempDir.mkdir(); }
else tempDir.mkdirs();

Wiped on entry, so parallel test runs collide.

cleanupTempDir() (:174-184) is new and runs in a finally around the whole spec loop (:123-148), so the directory is removed even on TestsAbandonedException, on diagnose() for a bad spec file, and on unexpected errors:

if (tempDir == null || preserveTempFiles) return;
if (tempDir.isDirectory()) cleanDir(tempDir);
if (!tempDir.delete() && tempDir.exists())
    report("Could not remove temporary test directory: " + tempDir);

cleanDir(File) (:162-172) also gained a null guard on directory.listFiles() — previously an unreadable directory caused an NPE — and recurses into subdirectories.

preserveTempFiles is now user-settable via --preserveTemp or --keep-temp (:98-99, documented in usage() :222). The old behaviour of implicitly preserving temp files when stopOnFailure tripped was removed: -F and --preserveTemp are orthogonal, and TestHarness.preserveTempFiles() (:370-372) is the single source of truth. TestRunnerBase.cleanup() (:101-114) honours the same flag for per-test file deletion.

Fixture Assertions in Spec Files

<file name="…" directory="true" input="true"/> means set up, don't assert. TestSpecificationParser.parseFile (:160-180) forces the 2-arg FileSpecification(file, isInput) constructor for directory="true", and TestRunnerBase.setup() (:145-161) creates it with f.mkdirs(). TestRunnerBase.checkFiles() (:170-200) asserts only non-input specs — so a spec without input="true" is a positive assertion that the command created it.

@TEMP_DIR@ expansion happens in ScriptTestRunner.run() (:47-78): the property is set from tempDir.getAbsolutePath(), then expand(props, scriptContent, bw, '@') (:110-146) substitutes @name@, turns @@ into @, and throws TestSpecificationException on CR/LF/EOF inside an @…@. The temp script file is deliberately written to java.io.tmpdir, not @TEMP_DIR@, and deleted in cleanup() (:88-94). check(rc) (:80-86) is a non-short-circuiting & of rc, stdout, stderr and file assertions, so one run reports every mismatch.

Example — cli/src/test/org/jnode/test/command/file/mkdir-command-tests.xml:

<testSpec title="nested-create-without-parents-fails" rc="1">
    mkdir @TEMP_DIR@/no-parents/a/b/c
    <error><![CDATA[Cannot create directory
]]></error>
</testSpec>
<testSpec title="parents-no-error-if-existing">
    mkdir @TEMP_DIR@/existing/a
    mkdir -p @TEMP_DIR@/existing/a
    <file name="existing"   directory="true" input="true"/>
    <file name="existing/a" directory="true"/>
</testSpec>

Because prepareTmpDir wipes the directory at startup, nothing creates the parent of a path a spec expects to be created — hence the explicit input="true" parent fixtures.

Two disjoint test tiers exist in shell: host-JVM JUnit (AllTests.java via all-junit) and host-JVM black-box XML scripts via TestHarness. Neither runs on JNode itself; TestEmu.initEmu (:50-77) picks org.jnode.emu.Emu on the host vs ShellUtils.getCurrentShell() on JNode. See Testing.

Install Command

The install command provides an interactive shell-based installer:

install [device-name]

This launches CommandLineInstaller (via InstallCommand), which:

  • Accepts an optional device name argument (e.g., install hda0)
  • Auto-discovers a single JFAT partition if no argument is given
  • Falls back to an interactive prompt if multiple or no candidates are found
  • Registers two actions: CopyFilesAction → GrubInstallerAction (files first, then GRUB)
  • Provides console-based readline I/O via System.in/System.out
  • Drives the same action sequence as the boot-time installer but from within a live JNode shell
  • Supports Step.back navigation during collect() for revisiting previous steps

Device Auto-Discovery

CommandLineInstaller resolves the target device via three strategies:

  1. CLI argument — passed directly to the constructor
  2. Auto-discovery — scans mounted filesystems for a single JFAT partition under /devices/
  3. Interactive prompt — asks the user to enter a device name

The resolved device ID and mount point are cached in InputContext so downstream actions (GrubInstallerAction, CopyFilesAction) can read them without re-prompting.

Action Ordering

CopyFilesAction runs before GrubInstallerAction so that GRUB's stage1.5 write does not corrupt the freshly written FAT filesystem. The mount point is resolved by filesystem identity (same approach as JGrub.getMountPoint) rather than by path substring matching.

GrubInstallerAction

Handles device selection and GRUB installation:

  • Reads DEVICE_ID from the action context (set by CommandLineInstaller)
  • Only prompts for device input if DEVICE_ID is absent
  • execute() installs GRUB stage1/stage1.5/stage2 on the selected device
  • Guards against no-device-selected with IllegalStateException

JGrub

JGrub handles whole-disk device names (no partition suffix):

  • If partitionSuffix is empty, defaults partitionNumber to 0
  • If parentDeviceName is empty, uses the device itself as the parent

Related Pages

  • Plugin-System — How commands are registered via <alias> extensions.
  • Code-Conventions — Best practices for writing robust commands.
  • Shell-Interpreter — Tokenizer, &&/||/; sequences, pipelines, redirection, escapeWord.
  • Syntax — Declarative Argument parsing that runs after a CommandLine is produced.
  • Testing — JUnit and black-box command test tiers.

Clone this wiki locally