@zjaaal/artemis 0.0.0-stage → 0.1.1
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.
- package/LICENSE +21 -0
- package/README.md +567 -2
- package/dist/cli/constants.d.ts +30 -0
- package/dist/cli/constants.js +34 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +1 -0
- package/dist/cli/init-command.d.ts +8 -0
- package/dist/cli/init-command.js +42 -0
- package/dist/cli/init.d.ts +23 -0
- package/dist/cli/init.js +153 -0
- package/dist/cli/launch.d.ts +10 -0
- package/dist/cli/launch.js +116 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +7 -0
- package/dist/cli/resolve-hook.d.ts +9 -0
- package/dist/cli/resolve-hook.js +21 -0
- package/dist/cli/settings.d.ts +18 -0
- package/dist/cli/settings.js +110 -0
- package/dist/constants.d.ts +94 -0
- package/dist/constants.js +86 -0
- package/dist/context/context.d.ts +6 -0
- package/dist/context/context.js +35 -0
- package/dist/define/define.d.ts +15 -0
- package/dist/define/define.js +99 -0
- package/dist/discover/discover.d.ts +7 -0
- package/dist/discover/discover.js +99 -0
- package/dist/edges/files.d.ts +25 -0
- package/dist/edges/files.js +111 -0
- package/dist/edges/modules.d.ts +2 -0
- package/dist/edges/modules.js +6 -0
- package/dist/edges/process.d.ts +2 -0
- package/dist/edges/process.js +9 -0
- package/dist/edges/prompts.d.ts +2 -0
- package/dist/edges/prompts.js +14 -0
- package/dist/edges/system.d.ts +4 -0
- package/dist/edges/system.js +5 -0
- package/dist/errors/errors.d.ts +25 -0
- package/dist/errors/errors.js +37 -0
- package/dist/help/help.d.ts +10 -0
- package/dist/help/help.js +159 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -0
- package/dist/kits/terminal/constants.d.ts +10 -0
- package/dist/kits/terminal/constants.js +10 -0
- package/dist/kits/terminal/index.d.ts +3 -0
- package/dist/kits/terminal/index.js +2 -0
- package/dist/kits/terminal/models.d.ts +57 -0
- package/dist/kits/terminal/models.js +2 -0
- package/dist/kits/terminal/open-link.d.ts +2 -0
- package/dist/kits/terminal/open-link.js +12 -0
- package/dist/kits/terminal/open-tabs.d.ts +3 -0
- package/dist/kits/terminal/open-tabs.js +5 -0
- package/dist/kits/terminal/toml.d.ts +3 -0
- package/dist/kits/terminal/toml.js +53 -0
- package/dist/kits/terminal/warp.d.ts +6 -0
- package/dist/kits/terminal/warp.js +32 -0
- package/dist/kits/worktrees/constants.d.ts +25 -0
- package/dist/kits/worktrees/constants.js +25 -0
- package/dist/kits/worktrees/git.d.ts +2 -0
- package/dist/kits/worktrees/git.js +7 -0
- package/dist/kits/worktrees/index.d.ts +3 -0
- package/dist/kits/worktrees/index.js +2 -0
- package/dist/kits/worktrees/instances.d.ts +9 -0
- package/dist/kits/worktrees/instances.js +33 -0
- package/dist/kits/worktrees/lock-edges.d.ts +7 -0
- package/dist/kits/worktrees/lock-edges.js +21 -0
- package/dist/kits/worktrees/lock.d.ts +7 -0
- package/dist/kits/worktrees/lock.js +104 -0
- package/dist/kits/worktrees/models.d.ts +123 -0
- package/dist/kits/worktrees/models.js +2 -0
- package/dist/kits/worktrees/names.d.ts +5 -0
- package/dist/kits/worktrees/names.js +9 -0
- package/dist/kits/worktrees/queries.d.ts +20 -0
- package/dist/kits/worktrees/queries.js +61 -0
- package/dist/kits/worktrees/registration.d.ts +15 -0
- package/dist/kits/worktrees/registration.js +107 -0
- package/dist/kits/worktrees/registry-store.d.ts +3 -0
- package/dist/kits/worktrees/registry-store.js +27 -0
- package/dist/kits/worktrees/worktrees.d.ts +10 -0
- package/dist/kits/worktrees/worktrees.js +56 -0
- package/dist/models.d.ts +483 -0
- package/dist/models.js +1 -0
- package/dist/parse/parse.d.ts +8 -0
- package/dist/parse/parse.js +81 -0
- package/dist/resolve/resolve.d.ts +18 -0
- package/dist/resolve/resolve.js +96 -0
- package/dist/run/report.d.ts +6 -0
- package/dist/run/report.js +51 -0
- package/dist/run/run.d.ts +8 -0
- package/dist/run/run.js +160 -0
- package/dist/run/world.d.ts +3 -0
- package/dist/run/world.js +12 -0
- package/dist/utils/error-code.d.ts +3 -0
- package/dist/utils/error-code.js +7 -0
- package/dist/utils/error-message.d.ts +3 -0
- package/dist/utils/error-message.js +2 -0
- package/dist/utils/is-record.d.ts +3 -0
- package/dist/utils/is-record.js +2 -0
- package/dist/utils/tag.d.ts +3 -0
- package/dist/utils/tag.js +8 -0
- package/dist/utils/terminator.d.ts +5 -0
- package/dist/utils/terminator.js +8 -0
- package/package.json +72 -4
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** Exit codes: 0 success, 1 the command failed, 2 the command line was wrong. */
|
|
2
|
+
export declare const EXIT_CODE: {
|
|
3
|
+
readonly OK: 0;
|
|
4
|
+
readonly FAILED: 1;
|
|
5
|
+
readonly USAGE: 2;
|
|
6
|
+
};
|
|
7
|
+
/** The exit codes of a failure: a failed run never exits with OK. */
|
|
8
|
+
export declare const FAILURE_CODES: readonly [1, 2];
|
|
9
|
+
export declare const COMMAND_TAG: unique symbol;
|
|
10
|
+
export declare const TOPIC_TAG: unique symbol;
|
|
11
|
+
export declare const COMMAND_ERROR_TAG: unique symbol;
|
|
12
|
+
export declare const USAGE_ERROR_TAG: unique symbol;
|
|
13
|
+
export declare const FLAG_TYPES: readonly ["boolean", "string"];
|
|
14
|
+
/** A short flag is a single character (`-s`); the parser rejects anything else. */
|
|
15
|
+
export declare const SHORT_FLAG_LENGTH = 1;
|
|
16
|
+
/** The flags every command gets. Their names and shorts are reserved. */
|
|
17
|
+
export declare const GLOBAL_FLAGS: {
|
|
18
|
+
readonly help: {
|
|
19
|
+
readonly type: "boolean";
|
|
20
|
+
readonly short: "h";
|
|
21
|
+
readonly description: "Show help for the command";
|
|
22
|
+
};
|
|
23
|
+
readonly json: {
|
|
24
|
+
readonly type: "boolean";
|
|
25
|
+
readonly description: "Print the result (or the error) as JSON on stdout";
|
|
26
|
+
};
|
|
27
|
+
readonly yes: {
|
|
28
|
+
readonly type: "boolean";
|
|
29
|
+
readonly short: "y";
|
|
30
|
+
readonly description: "Never prompt; use flags, saved values or defaults";
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
export declare const RESERVED_FLAGS: readonly string[];
|
|
34
|
+
/** The global flags as typed on a command line: `--json`, `-y`, ... */
|
|
35
|
+
export declare const GLOBAL_FLAG_TOKENS: readonly string[];
|
|
36
|
+
export declare const RESERVED_SHORTS: readonly string[];
|
|
37
|
+
export declare const HELP_WORD = "help";
|
|
38
|
+
/** Ends the flags: everything after it is a plain argument. */
|
|
39
|
+
export declare const TERMINATOR = "--";
|
|
40
|
+
export declare const JSON_FLAG = "--json";
|
|
41
|
+
export declare const FLAG_PREFIX = "-";
|
|
42
|
+
/** Files and folders starting with one of these are private: helpers, data, the topic file. */
|
|
43
|
+
export declare const PRIVATE_PREFIXES: readonly ["_", "."];
|
|
44
|
+
export declare const COMMAND_EXTENSION = ".js";
|
|
45
|
+
export declare const TOPIC_FILE = "_topic.js";
|
|
46
|
+
/** Words of a command id are joined with this ("uve config set"); "uve:config:set" also works. */
|
|
47
|
+
export declare const ID_SEPARATOR = " ";
|
|
48
|
+
export declare const ALIAS_SEPARATOR = ":";
|
|
49
|
+
/** Node appends a paragraph about "--" to unknown option errors; the first sentence is the useful part. */
|
|
50
|
+
export declare const ERROR_SENTENCE_SEPARATOR = ". ";
|
|
51
|
+
/** A typo is suggested when it is within this many edits, or a third of the name's length. */
|
|
52
|
+
export declare const SUGGEST_MIN_DISTANCE = 2;
|
|
53
|
+
export declare const SUGGEST_LENGTH_DIVISOR = 3;
|
|
54
|
+
export declare const ANSI_ESCAPE = "\u001B[";
|
|
55
|
+
export declare const ANSI_RESET = 0;
|
|
56
|
+
/** SGR codes of each style. */
|
|
57
|
+
export declare const STYLE_CODES: {
|
|
58
|
+
readonly bold: 1;
|
|
59
|
+
readonly dim: 2;
|
|
60
|
+
readonly red: 31;
|
|
61
|
+
readonly green: 32;
|
|
62
|
+
readonly yellow: 33;
|
|
63
|
+
readonly cyan: 36;
|
|
64
|
+
};
|
|
65
|
+
export declare const DEFAULT_COLOR_ENV = "NO_COLOR";
|
|
66
|
+
export declare const NOT_INTERACTIVE_MESSAGE = "This command needs input, but it is not running interactively.";
|
|
67
|
+
export declare const NOT_INTERACTIVE_HINT = "Pass --yes to run without questions, or pass the flags that answer them (see --help).";
|
|
68
|
+
export declare const CANCELLED_MESSAGE = "Cancelled.";
|
|
69
|
+
export declare const TABLE_INDENT = " ";
|
|
70
|
+
export declare const TABLE_GAP = " ";
|
|
71
|
+
export declare const SECTION_SEPARATOR = "\n\n";
|
|
72
|
+
export declare const NOTE_SEPARATOR = "; ";
|
|
73
|
+
export declare const NO_COMMAND: null;
|
|
74
|
+
/** Node system error codes the framework reacts to. */
|
|
75
|
+
export declare const ERROR_CODE: {
|
|
76
|
+
readonly EXISTS: "EEXIST";
|
|
77
|
+
readonly MISSING: "ENOENT";
|
|
78
|
+
readonly PERMISSION_DENIED: "EPERM";
|
|
79
|
+
readonly MODULE_NOT_FOUND: "ERR_MODULE_NOT_FOUND";
|
|
80
|
+
};
|
|
81
|
+
/** The package a command file imports the framework from. */
|
|
82
|
+
export declare const PACKAGE_NAME = "@zjaaal/artemis";
|
|
83
|
+
/** The shipped binary finds its project through the nearest package.json. */
|
|
84
|
+
export declare const MANIFEST_FILE = "package.json";
|
|
85
|
+
export declare const CONFIG_KEY = "artemis";
|
|
86
|
+
/** Where npm puts an installed package: a tool in here is always its own package, whatever it holds. */
|
|
87
|
+
export declare const NODE_MODULES = "node_modules";
|
|
88
|
+
export declare const SCOPE_FOLDER_PREFIX = "@";
|
|
89
|
+
export declare const DEFAULT_COMMANDS_FOLDER = "commands";
|
|
90
|
+
export declare const DEFAULT_BIN_NAME = "art";
|
|
91
|
+
/** npm names the command of a string `bin` after the package, without its `@scope/`. */
|
|
92
|
+
export declare const SCOPE_PREFIX: RegExp;
|
|
93
|
+
/** A string that is a URL, such as `file:///app/bin.js` or `http://x`, and not a path (`C:\\x` is a path). */
|
|
94
|
+
export declare const URL_LIKE: RegExp;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** Exit codes: 0 success, 1 the command failed, 2 the command line was wrong. */
|
|
2
|
+
export const EXIT_CODE = { OK: 0, FAILED: 1, USAGE: 2 };
|
|
3
|
+
/** The exit codes of a failure: a failed run never exits with OK. */
|
|
4
|
+
export const FAILURE_CODES = [EXIT_CODE.FAILED, EXIT_CODE.USAGE];
|
|
5
|
+
// Symbol.for, so a definition is recognised even if two copies of Artemis end up loaded
|
|
6
|
+
export const COMMAND_TAG = Symbol.for('artemis.command');
|
|
7
|
+
export const TOPIC_TAG = Symbol.for('artemis.topic');
|
|
8
|
+
// Errors are tagged the same way, so an error thrown by another copy of Artemis (a project's own
|
|
9
|
+
// local copy under a global `art`) is still recognised: `instanceof` only knows one copy.
|
|
10
|
+
export const COMMAND_ERROR_TAG = Symbol.for('artemis.command-error');
|
|
11
|
+
export const USAGE_ERROR_TAG = Symbol.for('artemis.usage-error');
|
|
12
|
+
export const FLAG_TYPES = ['boolean', 'string'];
|
|
13
|
+
/** A short flag is a single character (`-s`); the parser rejects anything else. */
|
|
14
|
+
export const SHORT_FLAG_LENGTH = 1;
|
|
15
|
+
/** The flags every command gets. Their names and shorts are reserved. */
|
|
16
|
+
export const GLOBAL_FLAGS = {
|
|
17
|
+
help: { type: 'boolean', short: 'h', description: 'Show help for the command' },
|
|
18
|
+
json: { type: 'boolean', description: 'Print the result (or the error) as JSON on stdout' },
|
|
19
|
+
yes: {
|
|
20
|
+
type: 'boolean',
|
|
21
|
+
short: 'y',
|
|
22
|
+
description: 'Never prompt; use flags, saved values or defaults'
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
export const RESERVED_FLAGS = Object.keys(GLOBAL_FLAGS);
|
|
26
|
+
/** The global flags as typed on a command line: `--json`, `-y`, ... */
|
|
27
|
+
export const GLOBAL_FLAG_TOKENS = Object.entries(GLOBAL_FLAGS).flatMap(([name, flag]) => [`--${name}`, ...('short' in flag ? [`-${flag.short}`] : [])]);
|
|
28
|
+
export const RESERVED_SHORTS = Object.values(GLOBAL_FLAGS).flatMap((flag) => 'short' in flag ? [flag.short] : []);
|
|
29
|
+
export const HELP_WORD = 'help';
|
|
30
|
+
/** Ends the flags: everything after it is a plain argument. */
|
|
31
|
+
export const TERMINATOR = '--';
|
|
32
|
+
export const JSON_FLAG = '--json';
|
|
33
|
+
export const FLAG_PREFIX = '-';
|
|
34
|
+
/** Files and folders starting with one of these are private: helpers, data, the topic file. */
|
|
35
|
+
export const PRIVATE_PREFIXES = ['_', '.'];
|
|
36
|
+
export const COMMAND_EXTENSION = '.js';
|
|
37
|
+
export const TOPIC_FILE = '_topic.js';
|
|
38
|
+
/** Words of a command id are joined with this ("uve config set"); "uve:config:set" also works. */
|
|
39
|
+
export const ID_SEPARATOR = ' ';
|
|
40
|
+
export const ALIAS_SEPARATOR = ':';
|
|
41
|
+
/** Node appends a paragraph about "--" to unknown option errors; the first sentence is the useful part. */
|
|
42
|
+
export const ERROR_SENTENCE_SEPARATOR = '. ';
|
|
43
|
+
/** A typo is suggested when it is within this many edits, or a third of the name's length. */
|
|
44
|
+
export const SUGGEST_MIN_DISTANCE = 2;
|
|
45
|
+
export const SUGGEST_LENGTH_DIVISOR = 3;
|
|
46
|
+
export const ANSI_ESCAPE = '\u001b[';
|
|
47
|
+
export const ANSI_RESET = 0;
|
|
48
|
+
/** SGR codes of each style. */
|
|
49
|
+
export const STYLE_CODES = {
|
|
50
|
+
bold: 1,
|
|
51
|
+
dim: 2,
|
|
52
|
+
red: 31,
|
|
53
|
+
green: 32,
|
|
54
|
+
yellow: 33,
|
|
55
|
+
cyan: 36
|
|
56
|
+
};
|
|
57
|
+
export const DEFAULT_COLOR_ENV = 'NO_COLOR';
|
|
58
|
+
export const NOT_INTERACTIVE_MESSAGE = 'This command needs input, but it is not running interactively.';
|
|
59
|
+
export const NOT_INTERACTIVE_HINT = 'Pass --yes to run without questions, or pass the flags that answer them (see --help).';
|
|
60
|
+
export const CANCELLED_MESSAGE = 'Cancelled.';
|
|
61
|
+
export const TABLE_INDENT = ' ';
|
|
62
|
+
export const TABLE_GAP = ' ';
|
|
63
|
+
export const SECTION_SEPARATOR = '\n\n';
|
|
64
|
+
export const NOTE_SEPARATOR = '; ';
|
|
65
|
+
export const NO_COMMAND = null;
|
|
66
|
+
/** Node system error codes the framework reacts to. */
|
|
67
|
+
export const ERROR_CODE = {
|
|
68
|
+
EXISTS: 'EEXIST',
|
|
69
|
+
MISSING: 'ENOENT',
|
|
70
|
+
PERMISSION_DENIED: 'EPERM',
|
|
71
|
+
MODULE_NOT_FOUND: 'ERR_MODULE_NOT_FOUND'
|
|
72
|
+
};
|
|
73
|
+
/** The package a command file imports the framework from. */
|
|
74
|
+
export const PACKAGE_NAME = '@zjaaal/artemis';
|
|
75
|
+
/** The shipped binary finds its project through the nearest package.json. */
|
|
76
|
+
export const MANIFEST_FILE = 'package.json';
|
|
77
|
+
export const CONFIG_KEY = 'artemis';
|
|
78
|
+
/** Where npm puts an installed package: a tool in here is always its own package, whatever it holds. */
|
|
79
|
+
export const NODE_MODULES = 'node_modules';
|
|
80
|
+
export const SCOPE_FOLDER_PREFIX = '@';
|
|
81
|
+
export const DEFAULT_COMMANDS_FOLDER = 'commands';
|
|
82
|
+
export const DEFAULT_BIN_NAME = 'art';
|
|
83
|
+
/** npm names the command of a string `bin` after the package, without its `@scope/`. */
|
|
84
|
+
export const SCOPE_PREFIX = /^@[^/]+\//;
|
|
85
|
+
/** A string that is a URL, such as `file:///app/bin.js` or `http://x`, and not a path (`C:\\x` is a path). */
|
|
86
|
+
export const URL_LIKE = /^(file:|[a-z][a-z0-9+.-]*:\/\/)/i;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CreateContextFn } from '../models.js';
|
|
2
|
+
/**
|
|
3
|
+
* The `ctx` a command receives. It keeps commands honest about their environment: `log` stays
|
|
4
|
+
* quiet under --json so stdout is parseable, and `prompt` refuses to hang when nobody can answer.
|
|
5
|
+
*/
|
|
6
|
+
export declare const createContext: CreateContextFn;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { format } from 'node:util';
|
|
2
|
+
import { CANCELLED_MESSAGE, NOT_INTERACTIVE_HINT, NOT_INTERACTIVE_MESSAGE } from '../constants.js';
|
|
3
|
+
import { askQuestions } from '../edges/prompts.js';
|
|
4
|
+
import { CommandError } from '../errors/errors.js';
|
|
5
|
+
/** `ctx.prompt`: asks the user, or refuses to hang when nobody can answer. */
|
|
6
|
+
const createPrompt = (interactive) => async (questions, { hint } = {}) => {
|
|
7
|
+
if (!interactive) {
|
|
8
|
+
throw new CommandError(NOT_INTERACTIVE_MESSAGE, { hint: hint ?? NOT_INTERACTIVE_HINT });
|
|
9
|
+
}
|
|
10
|
+
const { answers, cancelled } = await askQuestions(questions);
|
|
11
|
+
if (cancelled) {
|
|
12
|
+
throw new CommandError(CANCELLED_MESSAGE);
|
|
13
|
+
}
|
|
14
|
+
return answers;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* The `ctx` a command receives. It keeps commands honest about their environment: `log` stays
|
|
18
|
+
* quiet under --json so stdout is parseable, and `prompt` refuses to hang when nobody can answer.
|
|
19
|
+
*/
|
|
20
|
+
export const createContext = ({ io, global, interactive, style, registry, invoke }) => ({
|
|
21
|
+
json: global.json,
|
|
22
|
+
interactive,
|
|
23
|
+
style,
|
|
24
|
+
log: (...parts) => {
|
|
25
|
+
if (!global.json) {
|
|
26
|
+
io.stdout.write(`${format(...parts)}\n`);
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
warn: (...parts) => {
|
|
30
|
+
io.stderr.write(`${format(...parts)}\n`);
|
|
31
|
+
},
|
|
32
|
+
prompt: createPrompt(interactive),
|
|
33
|
+
commands: [...registry.commands.values()],
|
|
34
|
+
invoke
|
|
35
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { DefineCommandFn, DefineTopicFn, IsCommandFn, IsTopicFn } from '../models.js';
|
|
2
|
+
/**
|
|
3
|
+
* Declares a command. The file's path inside the commands folder gives it its name
|
|
4
|
+
* (`commands/setup/uve.js` is `setup uve`), so the definition only describes what it does.
|
|
5
|
+
*
|
|
6
|
+
* `args` and `flags` are read as constants, so `run` receives them typed: a required or defaulted
|
|
7
|
+
* arg is a string, an optional one may be undefined, and `choices` narrow the type.
|
|
8
|
+
*
|
|
9
|
+
* @throws {TypeError} when the definition breaks a rule (no description, reserved flag, ...).
|
|
10
|
+
*/
|
|
11
|
+
export declare const defineCommand: DefineCommandFn;
|
|
12
|
+
/** Describes a topic folder. Put it in `_topic.js` inside the folder. */
|
|
13
|
+
export declare const defineTopic: DefineTopicFn;
|
|
14
|
+
export declare const isCommand: IsCommandFn;
|
|
15
|
+
export declare const isTopic: IsTopicFn;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { COMMAND_TAG, FLAG_TYPES, RESERVED_FLAGS, RESERVED_SHORTS, SHORT_FLAG_LENGTH, TOPIC_TAG } from '../constants.js';
|
|
2
|
+
import { hasTag } from '../utils/tag.js';
|
|
3
|
+
function fail(message) {
|
|
4
|
+
throw new TypeError(`defineCommand: ${message}`);
|
|
5
|
+
}
|
|
6
|
+
// A JavaScript caller can hand over a null arg, so the optional chaining is deliberate.
|
|
7
|
+
const isOptional = (arg) => !arg?.required;
|
|
8
|
+
const isRequiredAfterOptional = (arg, index, firstOptional) => Boolean(arg.required) && firstOptional !== -1 && index > firstOptional;
|
|
9
|
+
const isRequiredWithDefault = (arg) => Boolean(arg.required) && arg.default !== undefined;
|
|
10
|
+
function validateArgs(args) {
|
|
11
|
+
const firstOptional = args.findIndex(isOptional);
|
|
12
|
+
args.forEach((arg, index) => {
|
|
13
|
+
if (!arg?.name) {
|
|
14
|
+
fail('every arg needs a name');
|
|
15
|
+
}
|
|
16
|
+
if (isRequiredWithDefault(arg)) {
|
|
17
|
+
fail(`arg "${arg.name}" cannot be required and have a default`);
|
|
18
|
+
}
|
|
19
|
+
if (isRequiredAfterOptional(arg, index, firstOptional)) {
|
|
20
|
+
fail(`required arg "${arg.name}" cannot come after an optional one`);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
const isReservedShort = (flag) => Boolean(flag.short) && RESERVED_SHORTS.includes(flag.short ?? '');
|
|
25
|
+
const isMultiCharacterShort = (flag) => flag.short !== undefined && flag.short.length !== SHORT_FLAG_LENGTH;
|
|
26
|
+
/** What the default of a flag has to be for the parser to accept it. */
|
|
27
|
+
function expectedDefault({ type, multiple }) {
|
|
28
|
+
if (type === 'boolean') {
|
|
29
|
+
return 'a boolean';
|
|
30
|
+
}
|
|
31
|
+
return multiple ? 'an array of strings' : 'a string';
|
|
32
|
+
}
|
|
33
|
+
function hasDefaultOfWrongType({ type, multiple, default: value }) {
|
|
34
|
+
if (value === undefined) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
if (type === 'boolean') {
|
|
38
|
+
return typeof value !== 'boolean';
|
|
39
|
+
}
|
|
40
|
+
if (multiple) {
|
|
41
|
+
return !Array.isArray(value) || value.some((item) => typeof item !== 'string');
|
|
42
|
+
}
|
|
43
|
+
return typeof value !== 'string';
|
|
44
|
+
}
|
|
45
|
+
function validateFlag(name, flag) {
|
|
46
|
+
if (RESERVED_FLAGS.includes(name)) {
|
|
47
|
+
fail(`flag "--${name}" is reserved by Artemis`);
|
|
48
|
+
}
|
|
49
|
+
if (isReservedShort(flag)) {
|
|
50
|
+
fail(`short flag "-${flag.short}" is reserved by Artemis`);
|
|
51
|
+
}
|
|
52
|
+
if (isMultiCharacterShort(flag)) {
|
|
53
|
+
fail(`short flag "-${flag.short}" of "--${name}" must be a single character`);
|
|
54
|
+
}
|
|
55
|
+
if (!FLAG_TYPES.includes(flag.type)) {
|
|
56
|
+
fail(`flag "--${name}" needs type "boolean" or "string"`);
|
|
57
|
+
}
|
|
58
|
+
if (hasDefaultOfWrongType(flag)) {
|
|
59
|
+
fail(`the default of flag "--${name}" must be ${expectedDefault(flag)}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Declares a command. The file's path inside the commands folder gives it its name
|
|
64
|
+
* (`commands/setup/uve.js` is `setup uve`), so the definition only describes what it does.
|
|
65
|
+
*
|
|
66
|
+
* `args` and `flags` are read as constants, so `run` receives them typed: a required or defaulted
|
|
67
|
+
* arg is a string, an optional one may be undefined, and `choices` narrow the type.
|
|
68
|
+
*
|
|
69
|
+
* @throws {TypeError} when the definition breaks a rule (no description, reserved flag, ...).
|
|
70
|
+
*/
|
|
71
|
+
export const defineCommand = (definition) => {
|
|
72
|
+
// A JavaScript caller can pass nothing at all; the checks below then name what is missing.
|
|
73
|
+
const { description, run, args: declaredArgs, flags: declaredFlags, aliases = [], examples = [], ...extra } = definition ?? {};
|
|
74
|
+
// Casts, not conversions: an omitted `args` or `flags` is the empty default of the type parameter.
|
|
75
|
+
const args = (declaredArgs ?? []);
|
|
76
|
+
const flags = (declaredFlags ?? {});
|
|
77
|
+
if (typeof description !== 'string' || !description) {
|
|
78
|
+
fail('a description is required');
|
|
79
|
+
}
|
|
80
|
+
if (typeof run !== 'function') {
|
|
81
|
+
fail('a run function is required');
|
|
82
|
+
}
|
|
83
|
+
validateArgs(args);
|
|
84
|
+
Object.entries(flags).forEach(([name, flag]) => validateFlag(name, flag));
|
|
85
|
+
return Object.freeze({
|
|
86
|
+
[COMMAND_TAG]: true,
|
|
87
|
+
description,
|
|
88
|
+
run,
|
|
89
|
+
args,
|
|
90
|
+
flags,
|
|
91
|
+
aliases,
|
|
92
|
+
examples,
|
|
93
|
+
...extra
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
/** Describes a topic folder. Put it in `_topic.js` inside the folder. */
|
|
97
|
+
export const defineTopic = ({ description } = {}) => Object.freeze({ [TOPIC_TAG]: true, description: description ?? '' });
|
|
98
|
+
export const isCommand = (value) => hasTag(value, COMMAND_TAG);
|
|
99
|
+
export const isTopic = (value) => hasTag(value, TOPIC_TAG);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { DiscoverFn } from '../models.js';
|
|
2
|
+
/**
|
|
3
|
+
* Walks the commands folder. Every public `.js` file is a command and every public folder is a
|
|
4
|
+
* topic, so `commands/uve/config/set.js` becomes the command `uve config set` inside the topics
|
|
5
|
+
* `uve` and `uve config`.
|
|
6
|
+
*/
|
|
7
|
+
export declare const discover: DiscoverFn;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { COMMAND_EXTENSION, ID_SEPARATOR, PRIVATE_PREFIXES, TOPIC_FILE } from '../constants.js';
|
|
3
|
+
import { isCommand, isTopic } from '../define/define.js';
|
|
4
|
+
import { listDirectory, pathExists } from '../edges/files.js';
|
|
5
|
+
import { importDefault } from '../edges/modules.js';
|
|
6
|
+
import { messageOf } from '../utils/error-message.js';
|
|
7
|
+
const createRegistry = () => ({
|
|
8
|
+
commands: new Map(),
|
|
9
|
+
topics: new Map(),
|
|
10
|
+
aliases: new Map(),
|
|
11
|
+
warnings: []
|
|
12
|
+
});
|
|
13
|
+
// Files and folders starting with "_" or "." are private: helpers, data, the topic file
|
|
14
|
+
const isPublic = (name) => !PRIVATE_PREFIXES.some((prefix) => name.startsWith(prefix));
|
|
15
|
+
const byName = (a, b) => a.name.localeCompare(b.name);
|
|
16
|
+
const isCommandFile = (name) => name.endsWith(COMMAND_EXTENSION);
|
|
17
|
+
/** Loads one command file. A broken file is a warning, so one bad drop-in does not take the CLI down. */
|
|
18
|
+
async function loadCommand({ registry, root }, file, id) {
|
|
19
|
+
const relative = path.relative(root, file);
|
|
20
|
+
try {
|
|
21
|
+
const definition = await importDefault(file);
|
|
22
|
+
if (!isCommand(definition)) {
|
|
23
|
+
registry.warnings.push(`${relative} is not a command (default export must come from defineCommand); skipped`);
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
registry.commands.set(id, { id, file, definition });
|
|
27
|
+
return 1;
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
registry.warnings.push(`${relative} failed to load and was skipped: ${messageOf(error)}`);
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// A topic file that fails to load only costs the topic its description; the commands still work.
|
|
35
|
+
const ignoreBrokenTopicFile = () => undefined;
|
|
36
|
+
async function registerTopic({ registry }, dir, parts) {
|
|
37
|
+
const topicFile = path.join(dir, TOPIC_FILE);
|
|
38
|
+
const topic = pathExists(topicFile)
|
|
39
|
+
? await importDefault(topicFile).catch(ignoreBrokenTopicFile)
|
|
40
|
+
: undefined;
|
|
41
|
+
const id = parts.join(ID_SEPARATOR);
|
|
42
|
+
registry.topics.set(id, { id, description: isTopic(topic) ? topic.description : '' });
|
|
43
|
+
}
|
|
44
|
+
/** Discovers one folder entry and returns how many commands it contributed. */
|
|
45
|
+
async function discoverEntry(context, dir, parts, entry) {
|
|
46
|
+
const file = path.join(dir, entry.name);
|
|
47
|
+
if (entry.isDirectory) {
|
|
48
|
+
return walk(context, file, [...parts, entry.name]);
|
|
49
|
+
}
|
|
50
|
+
if (!isCommandFile(entry.name)) {
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
const id = [...parts, entry.name.slice(0, -COMMAND_EXTENSION.length)].join(ID_SEPARATOR);
|
|
54
|
+
return loadCommand(context, file, id);
|
|
55
|
+
}
|
|
56
|
+
/** Walks a folder; a folder that held at least one command becomes a topic. */
|
|
57
|
+
async function walk(context, dir, parts) {
|
|
58
|
+
const entries = listDirectory(dir)
|
|
59
|
+
.filter((entry) => isPublic(entry.name))
|
|
60
|
+
.sort(byName);
|
|
61
|
+
let found = 0;
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
found += await discoverEntry(context, dir, parts, entry);
|
|
64
|
+
}
|
|
65
|
+
if (parts.length && found) {
|
|
66
|
+
await registerTopic(context, dir, parts);
|
|
67
|
+
}
|
|
68
|
+
return found;
|
|
69
|
+
}
|
|
70
|
+
/** Aliases are claimed after every command is known, so a command name always beats an alias. */
|
|
71
|
+
function registerAliases(registry) {
|
|
72
|
+
const claims = [...registry.commands.values()].flatMap(({ id, definition }) => definition.aliases.map((alias) => ({ alias, id })));
|
|
73
|
+
claims.forEach(({ alias, id }) => {
|
|
74
|
+
const isTaken = registry.commands.has(alias) ||
|
|
75
|
+
registry.topics.has(alias) ||
|
|
76
|
+
registry.aliases.has(alias);
|
|
77
|
+
if (isTaken) {
|
|
78
|
+
registry.warnings.push(`alias "${alias}" of "${id}" is already taken; ignored`);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
registry.aliases.set(alias, id);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Walks the commands folder. Every public `.js` file is a command and every public folder is a
|
|
87
|
+
* topic, so `commands/uve/config/set.js` becomes the command `uve config set` inside the topics
|
|
88
|
+
* `uve` and `uve config`.
|
|
89
|
+
*/
|
|
90
|
+
export const discover = async (commandsDir) => {
|
|
91
|
+
const registry = createRegistry();
|
|
92
|
+
if (!pathExists(commandsDir)) {
|
|
93
|
+
registry.warnings.push(`commands folder not found: ${commandsDir}`);
|
|
94
|
+
return registry;
|
|
95
|
+
}
|
|
96
|
+
await walk({ registry, root: commandsDir }, commandsDir, []);
|
|
97
|
+
registerAliases(registry);
|
|
98
|
+
return registry;
|
|
99
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { CreateExclusiveFn, IsFolderFn, ListDirectoryFn, MakeDirectoryFn, ModifiedAtFn, PathExistsFn, ReadManifestFn, ReadTextIfExistsFn, RealPathFn, RemoveFileFn, WriteTextFn } from '../models.js';
|
|
2
|
+
export declare const listDirectory: ListDirectoryFn;
|
|
3
|
+
export declare const pathExists: PathExistsFn;
|
|
4
|
+
export declare const makeDirectory: MakeDirectoryFn;
|
|
5
|
+
export declare const writeText: WriteTextFn;
|
|
6
|
+
/** Removing a file that is not there is not an error: the goal is that it is gone. */
|
|
7
|
+
export declare const removeFile: RemoveFileFn;
|
|
8
|
+
/** The text of a file, or undefined when it is not there (someone may remove it at any moment). */
|
|
9
|
+
export declare const readTextIfExists: ReadTextIfExistsFn;
|
|
10
|
+
/**
|
|
11
|
+
* Creates the file with its whole content already in it, or says someone else has it. The text is
|
|
12
|
+
* written to a staging file that is then hard-linked into place, so the file can never be seen
|
|
13
|
+
* empty or half written, and the link fails if the file already exists.
|
|
14
|
+
*/
|
|
15
|
+
export declare const createExclusive: CreateExclusiveFn;
|
|
16
|
+
export declare const modifiedAt: ModifiedAtFn;
|
|
17
|
+
/** The real path of a folder; one that does not exist yet has none, so it is resolved as given. */
|
|
18
|
+
export declare const realPathOrResolve: RealPathFn;
|
|
19
|
+
/**
|
|
20
|
+
* What the package.json of a folder says, or undefined when the folder has none. A file that cannot
|
|
21
|
+
* be read, is not valid JSON or is not an object is returned as an error, not thrown: whether it
|
|
22
|
+
* matters depends on whether it turns out to be the project.
|
|
23
|
+
*/
|
|
24
|
+
export declare const readManifest: ReadManifestFn;
|
|
25
|
+
export declare const isFolder: IsFolderFn;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { ERROR_CODE, MANIFEST_FILE } from '../constants.js';
|
|
5
|
+
import { CommandError } from '../errors/errors.js';
|
|
6
|
+
import { messageOf } from '../utils/error-message.js';
|
|
7
|
+
import { isRecord } from '../utils/is-record.js';
|
|
8
|
+
import { codeOf } from '../utils/error-code.js';
|
|
9
|
+
// Edge: the only place the framework and the terminal kit touch the filesystem.
|
|
10
|
+
export const listDirectory = (dir) => fs
|
|
11
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
12
|
+
.map((entry) => ({ name: entry.name, isDirectory: entry.isDirectory() }));
|
|
13
|
+
export const pathExists = (path) => fs.existsSync(path);
|
|
14
|
+
export const makeDirectory = (dir) => {
|
|
15
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
16
|
+
};
|
|
17
|
+
export const writeText = (file, text) => {
|
|
18
|
+
fs.writeFileSync(file, text);
|
|
19
|
+
};
|
|
20
|
+
/** Removing a file that is not there is not an error: the goal is that it is gone. */
|
|
21
|
+
export const removeFile = (file) => {
|
|
22
|
+
fs.rmSync(file, { force: true });
|
|
23
|
+
};
|
|
24
|
+
/** The text of a file, or undefined when it is not there (someone may remove it at any moment). */
|
|
25
|
+
export const readTextIfExists = (file) => {
|
|
26
|
+
try {
|
|
27
|
+
return fs.readFileSync(file, 'utf8');
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (codeOf(error) === ERROR_CODE.MISSING) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Creates the file with its whole content already in it, or says someone else has it. The text is
|
|
38
|
+
* written to a staging file that is then hard-linked into place, so the file can never be seen
|
|
39
|
+
* empty or half written, and the link fails if the file already exists.
|
|
40
|
+
*/
|
|
41
|
+
export const createExclusive = (file, text) => {
|
|
42
|
+
const staging = `${file}.${randomUUID()}.tmp`;
|
|
43
|
+
fs.writeFileSync(staging, text);
|
|
44
|
+
try {
|
|
45
|
+
fs.linkSync(staging, file);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (codeOf(error) === ERROR_CODE.EXISTS) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
fs.rmSync(staging, { force: true });
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
export const modifiedAt = (file) => fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs;
|
|
59
|
+
/** The real path of a folder; one that does not exist yet has none, so it is resolved as given. */
|
|
60
|
+
export const realPathOrResolve = (folder) => {
|
|
61
|
+
try {
|
|
62
|
+
return fs.realpathSync(folder);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Nothing to lose: a folder that does not exist yet is used by its resolved path.
|
|
66
|
+
return path.resolve(folder);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
/** A package.json that cannot be read: the file is named, and nothing is thrown. */
|
|
70
|
+
const unreadable = (file, error) => ({
|
|
71
|
+
error: new CommandError(`Cannot read ${file}: ${messageOf(error)}`, {
|
|
72
|
+
hint: 'Check that it is a readable file, or run from a folder whose package.json is.'
|
|
73
|
+
})
|
|
74
|
+
});
|
|
75
|
+
function parseManifest(file, text) {
|
|
76
|
+
try {
|
|
77
|
+
const parsed = JSON.parse(text);
|
|
78
|
+
if (isRecord(parsed)) {
|
|
79
|
+
return { manifest: parsed, text };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
return {
|
|
84
|
+
error: new CommandError(`${file} is not valid JSON: ${messageOf(error)}`, {
|
|
85
|
+
hint: 'Fix the file, or run from a folder whose package.json is valid.'
|
|
86
|
+
})
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
error: new CommandError(`${file} must contain a JSON object`, {
|
|
91
|
+
hint: 'A package.json is an object, such as { "name": "my-project" }.'
|
|
92
|
+
})
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* What the package.json of a folder says, or undefined when the folder has none. A file that cannot
|
|
97
|
+
* be read, is not valid JSON or is not an object is returned as an error, not thrown: whether it
|
|
98
|
+
* matters depends on whether it turns out to be the project.
|
|
99
|
+
*/
|
|
100
|
+
export const readManifest = (dir) => {
|
|
101
|
+
const file = path.join(dir, MANIFEST_FILE);
|
|
102
|
+
let text;
|
|
103
|
+
try {
|
|
104
|
+
text = readTextIfExists(file);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
return unreadable(file, error);
|
|
108
|
+
}
|
|
109
|
+
return text === undefined ? undefined : parseManifest(file, text);
|
|
110
|
+
};
|
|
111
|
+
export const isFolder = (target) => fs.statSync(target, { throwIfNoEntry: false })?.isDirectory() ?? false;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url';
|
|
2
|
+
// Edge: loads a command file the way Node loads any ES module, and returns its default export.
|
|
3
|
+
export const importDefault = async (file) => {
|
|
4
|
+
const loaded = await import(pathToFileURL(file).href);
|
|
5
|
+
return loaded.default;
|
|
6
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Edge: the only place a run reads the real process, and only for what the caller did not pass.
|
|
2
|
+
export const currentProcess = () => ({
|
|
3
|
+
stdout: process.stdout,
|
|
4
|
+
stderr: process.stderr,
|
|
5
|
+
env: process.env,
|
|
6
|
+
args: process.argv.slice(2),
|
|
7
|
+
stdinIsTTY: Boolean(process.stdin.isTTY),
|
|
8
|
+
stdoutIsTTY: Boolean(process.stdout.isTTY)
|
|
9
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Edge: the only place `prompts` is loaded, and only when a command really asks.
|
|
2
|
+
export const askQuestions = async (questions) => {
|
|
3
|
+
const { default: prompts } = await import('prompts');
|
|
4
|
+
let cancelled = false;
|
|
5
|
+
const answers = await prompts(questions, {
|
|
6
|
+
// Returning true keeps asking the remaining questions, as the JS version did; the caller
|
|
7
|
+
// turns the cancellation into an error afterwards.
|
|
8
|
+
onCancel: () => {
|
|
9
|
+
cancelled = true;
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
return { answers, cancelled };
|
|
14
|
+
};
|