Implement git help, git version, and subcommand infrastructure

Each subcommand is its own file, modeled after Phoenix's coreutils.
This commit is contained in:
Sam Atkins 2024-05-22 11:03:09 +01:00
parent b75c42b39f
commit 85b7587c42
8 changed files with 615 additions and 15 deletions

163
package-lock.json generated
View File

@ -3357,6 +3357,14 @@
"node": ">=10.0.0"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"engines": {
"node": ">=14"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@ -4547,6 +4555,11 @@
"resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz",
"integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg=="
},
"node_modules/async-lock": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz",
"integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ=="
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@ -5148,6 +5161,11 @@
"node": ">= 10.0"
}
},
"node_modules/clean-git-ref": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz",
"integrity": "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw=="
},
"node_modules/clean-stack": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
@ -5499,6 +5517,17 @@
"node": ">=10.0.0"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-fetch": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz",
@ -5768,6 +5797,11 @@
"node": ">=0.3.1"
}
},
"node_modules/diff3": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/diff3/-/diff3-0.0.3.tgz",
"integrity": "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g=="
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@ -7226,7 +7260,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
"integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==",
"dev": true,
"engines": {
"node": ">= 4"
}
@ -7615,6 +7648,43 @@
"whatwg-fetch": "^3.4.1"
}
},
"node_modules/isomorphic-git": {
"version": "1.25.10",
"resolved": "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.25.10.tgz",
"integrity": "sha512-IxGiaKBwAdcgBXwIcxJU6rHLk+NrzYaaPKXXQffcA0GW3IUrQXdUPDXDo+hkGVcYruuz/7JlGBiuaeTCgIgivQ==",
"dependencies": {
"async-lock": "^1.4.1",
"clean-git-ref": "^2.0.1",
"crc-32": "^1.2.0",
"diff3": "0.0.3",
"ignore": "^5.1.4",
"minimisted": "^2.0.0",
"pako": "^1.0.10",
"pify": "^4.0.1",
"readable-stream": "^3.4.0",
"sha.js": "^2.4.9",
"simple-get": "^4.0.1"
},
"bin": {
"isogit": "cli.cjs"
},
"engines": {
"node": ">=12"
}
},
"node_modules/isomorphic-git/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
@ -8402,6 +8472,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minimisted": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/minimisted/-/minimisted-2.0.1.tgz",
"integrity": "sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==",
"dependencies": {
"minimist": "^1.2.5"
}
},
"node_modules/minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
@ -9303,6 +9381,11 @@
"node": ">= 0.8"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@ -9423,6 +9506,14 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pify": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
"integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
"engines": {
"node": ">=6"
}
},
"node_modules/pixelmatch": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-4.0.2.tgz",
@ -10280,6 +10371,18 @@
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
"node_modules/sha.js": {
"version": "2.4.11",
"resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
"integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==",
"dependencies": {
"inherits": "^2.0.1",
"safe-buffer": "^5.0.1"
},
"bin": {
"sha.js": "bin.js"
}
},
"node_modules/shallow-clone": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz",
@ -11947,6 +12050,11 @@
"packages/git": {
"version": "1.0.0",
"license": "AGPL-3.0-only",
"dependencies": {
"@pkgjs/parseargs": "^0.11.0",
"buffer": "^6.0.3",
"isomorphic-git": "^1.25.10"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^24.1.0",
"@rollup/plugin-node-resolve": "^15.0.2",
@ -11956,6 +12064,48 @@
"rollup-plugin-copy": "^3.4.0"
}
},
"packages/git/node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"packages/git/node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"packages/phoenix": {
"name": "@heyputer/phoenix",
"version": "0.0.0",
@ -11988,13 +12138,6 @@
"node-pty": "^1.0.0"
}
},
"packages/phoenix/node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"packages/phoenix/node_modules/@sinonjs/fake-timers": {
"version": "11.2.2",
"license": "BSD-3-Clause",
@ -12079,10 +12222,6 @@
"version": "3.0.9",
"license": "MIT"
},
"packages/phoenix/node_modules/path-browserify": {
"version": "1.0.1",
"license": "MIT"
},
"packages/phoenix/node_modules/randomstring": {
"version": "1.3.0",
"license": "MIT",

View File

@ -16,5 +16,10 @@
"mocha": "^10.2.0",
"rollup": "^3.21.4",
"rollup-plugin-copy": "^3.4.0"
},
"dependencies": {
"@pkgjs/parseargs": "^0.11.0",
"buffer": "^6.0.3",
"isomorphic-git": "^1.25.10"
}
}

View File

@ -0,0 +1,39 @@
/*
* Copyright (C) 2024 Puter Technologies Inc.
*
* This file is part of Puter's Git client.
*
* Puter's Git client is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* The command definition for `git` itself, in the same format as subcommands.
*/
export default {
name: 'git',
usage: 'git [--version] [--help] [command] [command-args...]',
description: 'Git version-control client for Puter.',
args: {
options: {
help: {
description: 'Display help information for git itself, or a subcommand.',
type: 'boolean',
},
version: {
description: 'Display version information about git.',
type: 'boolean',
},
},
},
};

143
packages/git/src/help.js Normal file
View File

@ -0,0 +1,143 @@
/*
* Copyright (C) 2024 Puter Technologies Inc.
*
* This file is part of Puter's Git client.
*
* Puter's Git client is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Throw this from a subcommand's execute() in order to print its usage text to stderr.
* @type {symbol}
*/
export const SHOW_USAGE = Symbol('SHOW_USAGE');
/**
* Full manual page for the command.
* @param command
* @returns {string}
*/
export const produce_help_string = (command) => {
const { name, usage, description, args } = command;
const options = args?.options;
let s = '';
const indent = ' ';
const heading = (text) => {
s += `\n\x1B[34;1m${text}:\x1B[0m\n`
};
heading('SYNOPSIS');
if (!usage) {
s += `${indent}git ${name}\n`;
} else if (typeof usage === 'string') {
s += `${indent}${usage}\n`;
} else {
let first = true;
for (const usage_line of usage) {
if (first) {
first = false;
s += `${indent}${usage_line}\n`;
} else {
s += `${indent}${usage_line}\n`;
}
}
}
if (description) {
heading('DESCRIPTION');
s += `${indent}${description}\n`;
}
if (typeof options === 'object' && Object.keys(options).length > 0) {
heading('OPTIONS');
// Figure out how long each invocation is, so we can align the descriptions
for (const [name, option] of Object.entries(options)) {
// Invocation
s += indent;
if (option.short)
s += `-${option.short}, `;
s += `--${name}`;
if (option.type !== 'boolean')
s += ` <${option.type}>`;
s += '\n';
// Description
s += `${indent}${indent}${option.description}\n\n`;
}
}
if (!s.endsWith('\n\n'))
s += '\n';
return s;
}
/**
* Usage for the command, which is a short summary.
* @param command
* @returns {string}
*/
export const produce_usage_string = (command) => {
const { name, usage, args } = command;
const options = args?.options;
let s = '';
// Usage
if (!usage) {
s += `usage: git ${name}\n`;
} else if (typeof usage === 'string') {
s += `usage: ${usage}\n`;
} else {
let first = true;
for (const usage_line of usage) {
if (first) {
first = false;
s += `usage: ${usage_line}\n`;
} else {
s += ` or: ${usage_line}\n`;
}
}
}
// List of options
if (typeof options === 'object' && Object.keys(options).length > 0) {
// Figure out how long each invocation is, so we can align the descriptions
const option_strings = Object.entries(options).map(([name, option]) => {
let invocation = '';
if (option.short)
invocation += `-${option.short}, `;
invocation += `--${name}`;
if (option.type !== 'boolean')
invocation += ` <${option.type}>`;
return [invocation, option.description];
});
const indent_size = 2 + option_strings.reduce(
(max_length, option) => Math.max(max_length, option[0].length), 0);
s += '\n';
for (const [invocation, description] of option_strings) {
s += ` ${invocation}`;
if (indent_size - invocation.length > 0)
s += ' '.repeat(indent_size - invocation.length);
s += `${description}\n`;
}
}
return s;
}

View File

@ -16,6 +16,147 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
window.main = () => {
console.log('Well hello friends!');
import { parseArgs } from '@pkgjs/parseargs';
import subcommands from './subcommands/__exports__.js';
import git_command from './git-command-definition.js';
import fs from './filesystem.js';
import git from 'isomorphic-git';
import { Buffer } from 'buffer';
import { produce_usage_string, SHOW_USAGE } from './help.js';
const encoder = new TextEncoder();
window.Buffer = Buffer;
window.main = async () => {
const shell = puter.ui.parentApp();
if (!shell) {
await puter.ui.alert('Git must be run from a terminal. Try `git --help`');
puter.exit();
return;
}
shell.on('close', () => {
console.log('Shell closed; exiting git...');
puter.exit();
});
const stdout = (message) => {
shell.postMessage({
$: 'stdout',
data: encoder.encode(message + '\n'),
});
};
// TODO: Separate stderr message?
const stderr = stdout;
const url_params = new URL(document.location).searchParams;
const puter_args = JSON.parse(url_params.get('puter.args')) ?? {};
const { command_line, env } = puter_args;
// isomorphic-git assumes the Node.js process object exists,
// so fill-in the parts it uses.
window.process = {
cwd: () => env.PWD,
platform: 'puter',
}
// Git's command structure is a little unusual:
// > git [options-for-git] [subcommand [options-and-args-for-subcommand]]
// Also, a couple of options (--help and --version) are syntactic sugar for `help` and `version` subcommands.
// The approach here is to first try and parse these top-level options, and then based on that, run a subcommand.
// If no raw args, just print help and exit
const raw_args = command_line?.args ?? [];
if (raw_args.length === 0) {
stdout(produce_usage_string(git_command));
puter.exit();
return;
}
const { values: global_options, positionals: global_positionals } = parseArgs({
options: git_command.args.options,
allowPositionals: true,
args: raw_args,
strict: false,
});
let subcommand_name = null;
let first_positional_is_subcommand = false;
if (global_options.help) {
subcommand_name = 'help';
} else if (global_options.version) {
subcommand_name = 'version';
}
if (!subcommand_name) {
subcommand_name = global_positionals[0];
first_positional_is_subcommand = true;
}
// See if we're running a subcommand we recognize
let exit_code = 0;
const subcommand = subcommands[subcommand_name];
if (!subcommand) {
stderr(`git: '${subcommand_name}' is not a recognized git command. See 'git --help'`);
puter.exit(1);
return;
}
// Try and remove the subcommand positional arg, and any global options, from args.
const subcommand_args = raw_args;
const remove_arg = (arg) => {
const index = subcommand_args.indexOf(arg);
if (index >= 0)
subcommand_args.splice(index, 1);
}
remove_arg('--help');
remove_arg('--version');
if (first_positional_is_subcommand) {
// TODO: This is not a 100% reliable way to do this, as it may also match the value of `--option-with-value value`
// But that's not a problem until we add some global options that take a value.
remove_arg(subcommand_name);
}
// Parse the remaining args scoped to this subcommand, and run it.
let parsed_args;
try {
parsed_args = parseArgs({
...subcommand.args,
args: subcommand_args,
});
} catch (e) {
stderr(produce_usage_string(subcommand));
puter.exit(1);
return;
}
const ctx = {
io: {
stdout,
stderr,
},
fs,
args: {
options: parsed_args.values,
positionals: parsed_args.positionals,
},
env,
};
try {
exit_code = await subcommand.execute(ctx) ?? 0;
} catch (e) {
if (e === SHOW_USAGE) {
stderr(produce_usage_string(subcommand));
} else {
stderr(`fatal: ${e.message}`);
console.error(e);
}
exit_code = 1;
}
// TODO: Support passing an exit code to puter.exit();
puter.exit(exit_code);
}

View File

@ -0,0 +1,26 @@
/*
* Copyright (C) 2024 Puter Technologies Inc.
*
* This file is part of Phoenix Shell.
*
* Phoenix Shell is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Generated by /tools/gen.js
import module_help from './help.js'
import module_version from './version.js'
export default {
"help": module_help,
"version": module_version,
};

View File

@ -0,0 +1,67 @@
/*
* Copyright (C) 2024 Puter Technologies Inc.
*
* This file is part of Puter's Git client.
*
* Puter's Git client is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import git from 'isomorphic-git';
import { ErrorCodes } from '@heyputer/puter-js-common/src/PosixError.js';
import subcommands from './__exports__.js';
import git_command from '../git-command-definition.js';
import { produce_help_string } from '../help.js';
export default {
name: 'help',
usage: ['git help [-a|--all]', 'git help <command>'],
description: `Display help information for git itself, or a subcommand.`,
args: {
allowPositionals: true,
options: {
all: {
description: 'List all available subcommands.',
type: 'boolean',
}
},
},
execute: async (ctx) => {
const { io, fs, env, args } = ctx;
const { stdout, stderr } = io;
const { options, positionals } = args;
if (options.all) {
stdout(`See 'git help <command>' for more information.\n`);
const max_name_length = Object.keys(subcommands).reduce((max, name) => Math.max(max, name.length), 0);
for (const [name, command] of Object.entries(subcommands)) {
stdout(` ${name} ${' '.repeat(Math.max(max_name_length - name.length, 0))} ${command.description || ''}`);
}
return;
}
if (positionals.length > 0) {
// Try and display help page for the subcommand
const subcommand_name = positionals[0];
const subcommand = subcommands[subcommand_name];
if (!subcommand)
throw new Error(`No manual entry for ${subcommand_name}`);
stdout(produce_help_string(subcommand));
return;
}
// No subcommand name, so show general help
stdout(produce_help_string(git_command));
}
}

View File

@ -0,0 +1,40 @@
/*
* Copyright (C) 2024 Puter Technologies Inc.
*
* This file is part of Puter's Git client.
*
* Puter's Git client is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import git from 'isomorphic-git';
import path from 'path-browserify';
import { ErrorCodes } from '@heyputer/puter-js-common/src/PosixError.js';
const VERSION = '1.0.0';
export default {
name: 'version',
usage: 'git version',
description: `Display version information about git.`,
args: {
allowPositionals: false,
options: {},
},
execute: async (ctx) => {
const { io, fs, env, args } = ctx;
const { stdout, stderr } = io;
const { options, positionals } = args;
stdout(`Puter git version ${VERSION} (isomorphic git version ${git.version()})`);
}
}