@zjaaal/artemis 0.0.0-stage → 0.1.2
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 +568 -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 +15 -0
- package/dist/help/help.js +169 -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 +485 -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 +163 -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,34 @@
|
|
|
1
|
+
/** A word the shipped `art` handles itself, like `help`: it makes a folder a project. */
|
|
2
|
+
export const INIT_WORD = 'init';
|
|
3
|
+
export const INIT_USAGE_LINE = `${INIT_WORD} [--json]`;
|
|
4
|
+
export const INIT_HELP = `Usage: ${INIT_USAGE_LINE}\n\nMakes the current folder a project: a package.json with an "artemis" key, a commands/ folder and a sample command. Nothing that exists is overwritten. It takes no arguments: go to the folder first.`;
|
|
5
|
+
/** The only flags \`init\` takes: it is a fixed word of the binary, not a command, so it needs no more. */
|
|
6
|
+
export const INIT_FLAGS = { json: '--json', help: '--help', helpShort: '-h' };
|
|
7
|
+
/** What `init` writes for the declaration, and the fields of a package.json it has to create. */
|
|
8
|
+
export const INIT_DECLARATION = { commandsDir: 'commands' };
|
|
9
|
+
export const NEW_MANIFEST_FIELDS = { private: true, type: 'module' };
|
|
10
|
+
/** The command `init` writes so that `art hello` works right away. */
|
|
11
|
+
export const SAMPLE_COMMAND_FILE = 'hello.js';
|
|
12
|
+
export const SAMPLE_COMMAND_SOURCE = `import { defineCommand } from '@zjaaal/artemis';
|
|
13
|
+
|
|
14
|
+
export default defineCommand({
|
|
15
|
+
description: 'Say hello',
|
|
16
|
+
args: [{ name: 'name', description: 'Who to greet', default: 'world' }],
|
|
17
|
+
run({ args, ctx }) {
|
|
18
|
+
const greeting = \`hello \${args.name}\`;
|
|
19
|
+
|
|
20
|
+
ctx.log(greeting);
|
|
21
|
+
|
|
22
|
+
return { greeting };
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
`;
|
|
26
|
+
export const FALLBACK_PACKAGE_NAME = 'project';
|
|
27
|
+
export const DEFAULT_INDENT_SPACES = 2;
|
|
28
|
+
export const INDENT_PATTERN = /^([ \t]+)"/m;
|
|
29
|
+
export const PACKAGE_NAME_INVALID = /[^a-z0-9._-]+/g;
|
|
30
|
+
export const PACKAGE_NAME_EDGES = /^[-._]+|-+$/g;
|
|
31
|
+
export const TYPE_WARNING = 'Your package.json does not say "type": "module". Command files are ES modules, so Node prints a warning for each one until it does.';
|
|
32
|
+
export const SHADOW_WARNING = 'This project has a command or topic called "init", and the init word of this binary hides it: it cannot be run through here. Rename it.';
|
|
33
|
+
export const CRLF = '\r\n';
|
|
34
|
+
export const LF = '\n';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { launch as cli } from './launch.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { InitCommandFn } from '../models.js';
|
|
2
|
+
/**
|
|
3
|
+
* `init [--json]`: makes the current folder a project. Handled by the shipped binary itself, like
|
|
4
|
+
* `help`, because it has to work where there is no project yet. It is a fixed word and not a command
|
|
5
|
+
* a person writes, so it takes no arguments and no flags beyond `--json` and `--help`: anything else
|
|
6
|
+
* is a UsageError (exit 2).
|
|
7
|
+
*/
|
|
8
|
+
export declare const initCommand: InitCommandFn;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { EXIT_CODE } from '../constants.js';
|
|
2
|
+
import { UsageError } from '../errors/errors.js';
|
|
3
|
+
import { resolveWorld } from '../run/world.js';
|
|
4
|
+
import { INIT_FLAGS, INIT_HELP, INIT_USAGE_LINE, INIT_WORD } from './constants.js';
|
|
5
|
+
import { initProject } from './init.js';
|
|
6
|
+
const KNOWN_WORDS = Object.values(INIT_FLAGS);
|
|
7
|
+
function describeResult({ created, kept, warnings }) {
|
|
8
|
+
const lines = [
|
|
9
|
+
...created.map((file) => `Created ${file}`),
|
|
10
|
+
...kept.map((file) => `Kept ${file} (already there)`),
|
|
11
|
+
...warnings.map((warning) => `Warning: ${warning}`)
|
|
12
|
+
];
|
|
13
|
+
return created.length
|
|
14
|
+
? lines.join('\n')
|
|
15
|
+
: ['Already a project: nothing to create.', ...lines].join('\n');
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* `init [--json]`: makes the current folder a project. Handled by the shipped binary itself, like
|
|
19
|
+
* `help`, because it has to work where there is no project yet. It is a fixed word and not a command
|
|
20
|
+
* a person writes, so it takes no arguments and no flags beyond `--json` and `--help`: anything else
|
|
21
|
+
* is a UsageError (exit 2).
|
|
22
|
+
*/
|
|
23
|
+
export const initCommand = (words, options, cwd) => {
|
|
24
|
+
const unexpected = words.find((word) => !KNOWN_WORDS.includes(word));
|
|
25
|
+
const { io } = resolveWorld(options);
|
|
26
|
+
if (unexpected) {
|
|
27
|
+
throw new UsageError(`${INIT_WORD} takes no arguments, but got "${unexpected}"`, {
|
|
28
|
+
usage: INIT_USAGE_LINE,
|
|
29
|
+
hint: 'Go to the folder you want to make a project, then run it.'
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
if (words.includes(INIT_FLAGS.help) || words.includes(INIT_FLAGS.helpShort)) {
|
|
33
|
+
io.stdout.write(`${INIT_HELP}\n`);
|
|
34
|
+
return EXIT_CODE.OK;
|
|
35
|
+
}
|
|
36
|
+
const result = initProject(cwd);
|
|
37
|
+
const output = words.includes(INIT_FLAGS.json)
|
|
38
|
+
? JSON.stringify({ ok: true, command: INIT_WORD, result })
|
|
39
|
+
: describeResult(result);
|
|
40
|
+
io.stdout.write(`${output}\n`);
|
|
41
|
+
return EXIT_CODE.OK;
|
|
42
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AddKeyFn, DetectIndentFn, InitProjectFn, PackageNameForFn, PlanInitFn } from '../models.js';
|
|
2
|
+
/** The indentation a package.json already uses, so editing it does not reformat the whole file. */
|
|
3
|
+
export declare const detectIndent: DetectIndentFn;
|
|
4
|
+
/** A valid npm package name for a folder: lowercase, no spaces or odd characters. */
|
|
5
|
+
export declare const packageNameFor: PackageNameForFn;
|
|
6
|
+
/**
|
|
7
|
+
* The same JSON object with one more property at the end. Only that changes: every byte before it,
|
|
8
|
+
* the indentation, inline arrays, key order, line endings (CRLF stays CRLF) and the final newline are
|
|
9
|
+
* kept, because the text is edited and not parsed and written back.
|
|
10
|
+
*/
|
|
11
|
+
export declare const addKey: AddKeyFn;
|
|
12
|
+
/**
|
|
13
|
+
* Decides what making a folder a project takes: a package.json with the "artemis" key (created, or
|
|
14
|
+
* the key added to the one that is there), a commands folder, and a sample command so `hello` works
|
|
15
|
+
* at once. Pure, and it never overwrites: files are only planned where there is none, the sample only
|
|
16
|
+
* into a commands folder that is new or empty, and an existing package.json only gains the one key.
|
|
17
|
+
*/
|
|
18
|
+
export declare const planInit: PlanInitFn;
|
|
19
|
+
/**
|
|
20
|
+
* Makes `dir` a project: reads what is there once, plans, and does it. Running it again changes
|
|
21
|
+
* nothing. A package.json that cannot be read or parsed is an error: editing it would be guesswork.
|
|
22
|
+
*/
|
|
23
|
+
export declare const initProject: InitProjectFn;
|
package/dist/cli/init.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { CONFIG_KEY, DEFAULT_COMMANDS_FOLDER, MANIFEST_FILE } from '../constants.js';
|
|
3
|
+
import { createExclusive, isFolder, listDirectory, makeDirectory, pathExists, readManifest, writeText } from '../edges/files.js';
|
|
4
|
+
import { CommandError } from '../errors/errors.js';
|
|
5
|
+
import { CRLF, DEFAULT_INDENT_SPACES, FALLBACK_PACKAGE_NAME, INDENT_PATTERN, INIT_DECLARATION, INIT_WORD, LF, NEW_MANIFEST_FIELDS, PACKAGE_NAME_EDGES, PACKAGE_NAME_INVALID, SAMPLE_COMMAND_FILE, SAMPLE_COMMAND_SOURCE, SHADOW_WARNING, TYPE_WARNING } from './constants.js';
|
|
6
|
+
import { resolveSettings } from './settings.js';
|
|
7
|
+
/** The indentation a package.json already uses, so editing it does not reformat the whole file. */
|
|
8
|
+
export const detectIndent = (text) => INDENT_PATTERN.exec(text)?.[1] ?? ' '.repeat(DEFAULT_INDENT_SPACES);
|
|
9
|
+
/** A valid npm package name for a folder: lowercase, no spaces or odd characters. */
|
|
10
|
+
export const packageNameFor = (dir) => {
|
|
11
|
+
const name = path
|
|
12
|
+
.basename(dir)
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.replace(PACKAGE_NAME_INVALID, '-')
|
|
15
|
+
.replace(PACKAGE_NAME_EDGES, '');
|
|
16
|
+
return name || FALLBACK_PACKAGE_NAME;
|
|
17
|
+
};
|
|
18
|
+
/** `{ "commandsDir": "commands" }`, on one line, for adding to a file without reformatting it. */
|
|
19
|
+
const declarationText = () => `{ ${Object.entries(INIT_DECLARATION)
|
|
20
|
+
.map(([key, value]) => `${JSON.stringify(key)}: ${JSON.stringify(value)}`)
|
|
21
|
+
.join(', ')} }`;
|
|
22
|
+
/**
|
|
23
|
+
* The same JSON object with one more property at the end. Only that changes: every byte before it,
|
|
24
|
+
* the indentation, inline arrays, key order, line endings (CRLF stays CRLF) and the final newline are
|
|
25
|
+
* kept, because the text is edited and not parsed and written back.
|
|
26
|
+
*/
|
|
27
|
+
export const addKey = (text, key, valueText) => {
|
|
28
|
+
const closing = /^([\s\S]*)\}(\s*)$/.exec(text);
|
|
29
|
+
const body = (closing?.[1] ?? text).trimEnd();
|
|
30
|
+
const after = closing?.[2] ?? '';
|
|
31
|
+
const comma = body.endsWith('{') ? '' : ',';
|
|
32
|
+
const property = `${JSON.stringify(key)}: ${valueText}`;
|
|
33
|
+
// A file written on one line stays on one line.
|
|
34
|
+
if (!text.includes(LF)) {
|
|
35
|
+
return `${body}${comma} ${property} }${after}`;
|
|
36
|
+
}
|
|
37
|
+
const eol = text.includes(CRLF) ? CRLF : LF;
|
|
38
|
+
return `${body}${comma}${eol}${detectIndent(text)}${property}${eol}}${after}`;
|
|
39
|
+
};
|
|
40
|
+
const newManifest = (dir) => `${JSON.stringify({ name: packageNameFor(dir), ...NEW_MANIFEST_FIELDS, [CONFIG_KEY]: INIT_DECLARATION }, null, ' '.repeat(DEFAULT_INDENT_SPACES))}\n`;
|
|
41
|
+
function planManifest(input, manifestPath, plan) {
|
|
42
|
+
const { dir, manifest, manifestText } = input;
|
|
43
|
+
if (manifest === undefined || manifestText === undefined) {
|
|
44
|
+
plan.steps.push({
|
|
45
|
+
kind: 'file',
|
|
46
|
+
mode: 'create',
|
|
47
|
+
path: manifestPath,
|
|
48
|
+
text: newManifest(dir)
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (manifest[CONFIG_KEY] !== undefined) {
|
|
53
|
+
plan.kept.push(manifestPath);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
plan.steps.push({
|
|
57
|
+
kind: 'file',
|
|
58
|
+
mode: 'update',
|
|
59
|
+
path: manifestPath,
|
|
60
|
+
text: addKey(manifestText, CONFIG_KEY, declarationText())
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (manifest.type !== 'module') {
|
|
64
|
+
plan.warnings.push(TYPE_WARNING);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Decides what making a folder a project takes: a package.json with the "artemis" key (created, or
|
|
69
|
+
* the key added to the one that is there), a commands folder, and a sample command so `hello` works
|
|
70
|
+
* at once. Pure, and it never overwrites: files are only planned where there is none, the sample only
|
|
71
|
+
* into a commands folder that is new or empty, and an existing package.json only gains the one key.
|
|
72
|
+
*/
|
|
73
|
+
export const planInit = (input) => {
|
|
74
|
+
const plan = { steps: [], kept: [], warnings: [] };
|
|
75
|
+
planManifest(input, path.join(input.dir, MANIFEST_FILE), plan);
|
|
76
|
+
if (input.commandsDirExists) {
|
|
77
|
+
plan.kept.push(input.commandsDir);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
plan.steps.push({ kind: 'folder', path: input.commandsDir });
|
|
81
|
+
}
|
|
82
|
+
if (!input.commandsDirExists || input.commandsDirIsEmpty) {
|
|
83
|
+
plan.steps.push({
|
|
84
|
+
kind: 'file',
|
|
85
|
+
mode: 'create',
|
|
86
|
+
path: path.join(input.commandsDir, SAMPLE_COMMAND_FILE),
|
|
87
|
+
text: SAMPLE_COMMAND_SOURCE
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (input.shadowsCommand) {
|
|
91
|
+
plan.warnings.push(SHADOW_WARNING);
|
|
92
|
+
}
|
|
93
|
+
return plan;
|
|
94
|
+
};
|
|
95
|
+
/** Does one step. A file to create is created exclusively: it fails, never overwrites, if it appeared meanwhile. */
|
|
96
|
+
function applyStep(step) {
|
|
97
|
+
if (step.kind === 'folder') {
|
|
98
|
+
makeDirectory(step.path);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
makeDirectory(path.dirname(step.path));
|
|
102
|
+
if (step.mode === 'update') {
|
|
103
|
+
writeText(step.path, step.text);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!createExclusive(step.path, step.text)) {
|
|
107
|
+
throw new CommandError(`${step.path} already exists`, {
|
|
108
|
+
hint: 'init never overwrites a file. Move it, or run init in another folder.'
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** A commands path that exists as something other than a folder cannot become one. */
|
|
113
|
+
function assertCommandsDirIsUsable(commandsDir) {
|
|
114
|
+
if (pathExists(commandsDir) && !isFolder(commandsDir)) {
|
|
115
|
+
throw new CommandError(`${commandsDir} exists but is not a folder`, {
|
|
116
|
+
hint: `Move it, or set "${CONFIG_KEY}": { "commandsDir" } in ${MANIFEST_FILE} to another folder.`
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Whether the project already has a command or topic called `init`, which the init word would hide. */
|
|
121
|
+
const shadowsInit = (commandsDir) => [`${INIT_WORD}.js`, INIT_WORD].some((name) => pathExists(path.join(commandsDir, name)));
|
|
122
|
+
/**
|
|
123
|
+
* Makes `dir` a project: reads what is there once, plans, and does it. Running it again changes
|
|
124
|
+
* nothing. A package.json that cannot be read or parsed is an error: editing it would be guesswork.
|
|
125
|
+
*/
|
|
126
|
+
export const initProject = (dir) => {
|
|
127
|
+
const read = readManifest(dir);
|
|
128
|
+
if (read && 'error' in read) {
|
|
129
|
+
throw read.error;
|
|
130
|
+
}
|
|
131
|
+
const manifest = read?.manifest;
|
|
132
|
+
const commandsDir = manifest
|
|
133
|
+
? resolveSettings({ root: dir, manifest }, 'shipped').commandsDir
|
|
134
|
+
: path.join(dir, DEFAULT_COMMANDS_FOLDER);
|
|
135
|
+
assertCommandsDirIsUsable(commandsDir);
|
|
136
|
+
const commandsDirExists = isFolder(commandsDir);
|
|
137
|
+
const plan = planInit({
|
|
138
|
+
dir,
|
|
139
|
+
manifest,
|
|
140
|
+
manifestText: read?.text,
|
|
141
|
+
commandsDir,
|
|
142
|
+
commandsDirExists,
|
|
143
|
+
commandsDirIsEmpty: commandsDirExists && listDirectory(commandsDir).length === 0,
|
|
144
|
+
shadowsCommand: shadowsInit(commandsDir)
|
|
145
|
+
});
|
|
146
|
+
plan.steps.forEach(applyStep);
|
|
147
|
+
return {
|
|
148
|
+
root: dir,
|
|
149
|
+
created: plan.steps.map((step) => step.path),
|
|
150
|
+
kept: plan.kept,
|
|
151
|
+
warnings: plan.warnings
|
|
152
|
+
};
|
|
153
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CliFn } from '../models.js';
|
|
2
|
+
/**
|
|
3
|
+
* Finds the project and runs the command line against its commands folder. Returns the exit code
|
|
4
|
+
* and never calls process.exit.
|
|
5
|
+
*
|
|
6
|
+
* With `root` (a project's own bin file: `cli({ root: import.meta.url })`) the project is the one
|
|
7
|
+
* the bin file belongs to, wherever the user is standing. Without it (the shipped `art` and
|
|
8
|
+
* `artemis`) the project is the one of the current folder, or of `cwd`.
|
|
9
|
+
*/
|
|
10
|
+
export declare const launch: CliFn;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { CONFIG_KEY, DEFAULT_COMMANDS_FOLDER, MANIFEST_FILE, URL_LIKE } from '../constants.js';
|
|
4
|
+
import { isFolder, readManifest } from '../edges/files.js';
|
|
5
|
+
import { currentProcess } from '../edges/process.js';
|
|
6
|
+
import { workingDirectory } from '../edges/system.js';
|
|
7
|
+
import { CommandError } from '../errors/errors.js';
|
|
8
|
+
import { reportError } from '../run/report.js';
|
|
9
|
+
import { run } from '../run/run.js';
|
|
10
|
+
import { resolveWorld } from '../run/world.js';
|
|
11
|
+
import { messageOf } from '../utils/error-message.js';
|
|
12
|
+
import { splitLeadingFlags } from '../resolve/resolve.js';
|
|
13
|
+
import { jsonRequested } from '../utils/terminator.js';
|
|
14
|
+
import { INIT_WORD } from './constants.js';
|
|
15
|
+
import { initCommand } from './init-command.js';
|
|
16
|
+
import { findProject, resolveSettings } from './settings.js';
|
|
17
|
+
/** What `root` stands for as a path, or a CommandError that says what to pass instead. */
|
|
18
|
+
function pathOfRoot(root) {
|
|
19
|
+
try {
|
|
20
|
+
if (typeof root === 'string' && root !== '' && !URL_LIKE.test(root)) {
|
|
21
|
+
return root;
|
|
22
|
+
}
|
|
23
|
+
return fileURLToPath(root);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
throw new CommandError(`cli({ root }) needs the location of your bin file, but got ${String(root)} (${messageOf(error)})`, {
|
|
27
|
+
hint: 'Pass import.meta.url from your bin file, or a path.'
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** The folder a bin file is in (`import.meta.url`), or the folder itself when given one. */
|
|
32
|
+
const folderOfRoot = (root) => {
|
|
33
|
+
const target = pathOfRoot(root);
|
|
34
|
+
return isFolder(target) ? target : path.dirname(target);
|
|
35
|
+
};
|
|
36
|
+
/** What to do when no project is found: the end user can create one, the author of a tool must declare its bin. */
|
|
37
|
+
const noProjectError = (start, route) => route === 'own'
|
|
38
|
+
? new CommandError(`No ${MANIFEST_FILE} with a "bin" field found from ${start} upwards`, {
|
|
39
|
+
hint: `A published tool is found through the ${MANIFEST_FILE} that declares its "bin" (npm needs it to create the command anyway). If you maintain this tool, declare "bin" there and ship a ${DEFAULT_COMMANDS_FOLDER}/ folder in the package (see "files" in ${MANIFEST_FILE}).`
|
|
40
|
+
})
|
|
41
|
+
: new CommandError(`No project found from ${start} upwards`, {
|
|
42
|
+
hint: `A project is the nearest ${MANIFEST_FILE} with an "${CONFIG_KEY}" key or a ${DEFAULT_COMMANDS_FOLDER}/ folder next to it. Run it inside one, or use its "${INIT_WORD}" command in this folder to create one.`
|
|
43
|
+
});
|
|
44
|
+
/** Whether the commands folder is already outside the project, so pointing it at a parent is no news. */
|
|
45
|
+
const pointsOutside = (commandsDir, projectRoot) => {
|
|
46
|
+
const relative = path.relative(projectRoot, commandsDir);
|
|
47
|
+
return relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* The commands folder is missing. For a person running a project: create it, or point `commandsDir`
|
|
51
|
+
* at a parent's (unless it already points outside). For a published tool the end user cannot fix
|
|
52
|
+
* anything, so the hint is for its author, and never suggests running another package's commands.
|
|
53
|
+
*/
|
|
54
|
+
const missingCommandsError = (commandsDir, projectRoot, route) => {
|
|
55
|
+
const manifestPath = path.join(projectRoot, MANIFEST_FILE);
|
|
56
|
+
const create = `Create ${commandsDir}, or set "${CONFIG_KEY}": { "commandsDir" } in ${manifestPath}.`;
|
|
57
|
+
const parent = ' If the commands live in a parent folder, point commandsDir at them, for example "../commands".';
|
|
58
|
+
const author = ` If this is a published tool, its author has to ship the commands folder (add it to "files" in ${MANIFEST_FILE}).`;
|
|
59
|
+
if (route === 'own') {
|
|
60
|
+
return new CommandError(`Commands folder not found: ${commandsDir}`, {
|
|
61
|
+
hint: create + author
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return new CommandError(`Commands folder not found: ${commandsDir}`, {
|
|
65
|
+
hint: pointsOutside(commandsDir, projectRoot) ? create : create + parent
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
/** The project of a folder and where its commands are, or a CommandError that says what to create. */
|
|
69
|
+
const settingsFor = (start, route) => {
|
|
70
|
+
// The messages name no binary: it is not known here what the user typed (art, artemis or a tool's name).
|
|
71
|
+
const project = findProject(start, readManifest, isFolder, route);
|
|
72
|
+
if (!project) {
|
|
73
|
+
throw noProjectError(start, route);
|
|
74
|
+
}
|
|
75
|
+
const settings = resolveSettings(project, route);
|
|
76
|
+
if (!isFolder(settings.commandsDir)) {
|
|
77
|
+
throw missingCommandsError(settings.commandsDir, project.root, route);
|
|
78
|
+
}
|
|
79
|
+
return settings;
|
|
80
|
+
};
|
|
81
|
+
/** A setup failure goes out the way any failure does: one object under --json, text otherwise. */
|
|
82
|
+
function reportSetupFailure(argv, error, host) {
|
|
83
|
+
const { io, style } = resolveWorld(host);
|
|
84
|
+
return reportError(error, { io, style, json: jsonRequested(argv), commandId: undefined });
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Finds the project and runs the command line against its commands folder. Returns the exit code
|
|
88
|
+
* and never calls process.exit.
|
|
89
|
+
*
|
|
90
|
+
* With `root` (a project's own bin file: `cli({ root: import.meta.url })`) the project is the one
|
|
91
|
+
* the bin file belongs to, wherever the user is standing. Without it (the shipped `art` and
|
|
92
|
+
* `artemis`) the project is the one of the current folder, or of `cwd`.
|
|
93
|
+
*/
|
|
94
|
+
export const launch = async (options = {}) => {
|
|
95
|
+
const { argv = currentProcess().args, cwd, root, ...host } = options;
|
|
96
|
+
const route = root === undefined ? 'shipped' : 'own';
|
|
97
|
+
const { leading, rest } = splitLeadingFlags(argv);
|
|
98
|
+
// `init` is the shipped binary's own word, like `help`: it has to work where there is no project yet.
|
|
99
|
+
if (route === 'shipped' && rest[0] === INIT_WORD) {
|
|
100
|
+
try {
|
|
101
|
+
return initCommand([...leading, ...rest.slice(1)], host, path.resolve(cwd ?? workingDirectory()));
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
return reportSetupFailure(argv, error, host);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
let settings;
|
|
108
|
+
try {
|
|
109
|
+
const start = path.resolve(root === undefined ? (cwd ?? workingDirectory()) : folderOfRoot(root));
|
|
110
|
+
settings = settingsFor(start, route);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
return reportSetupFailure(argv, error, host);
|
|
114
|
+
}
|
|
115
|
+
return run(argv, { ...host, ...settings });
|
|
116
|
+
};
|
package/dist/cli/main.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The `art` and `artemis` binary. Wiring only: the logic is in launch.ts, settings.ts and resolve-hook.ts.
|
|
3
|
+
import { register } from 'node:module';
|
|
4
|
+
import { launch } from './launch.js';
|
|
5
|
+
// Command files import the framework by its package name; see resolve-hook.ts.
|
|
6
|
+
register('./resolve-hook.js', import.meta.url);
|
|
7
|
+
process.exitCode = await launch();
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ResolveHookFn } from '../models.js';
|
|
2
|
+
/**
|
|
3
|
+
* A command file imports the framework by its package name, but a globally installed `art` is not
|
|
4
|
+
* on the module path of a project's files. Normal resolution always goes first, so a copy the
|
|
5
|
+
* project installed itself wins. Only when that fails with "module not found" for this package (or
|
|
6
|
+
* one of its subpaths) is the name resolved from inside the running copy, so the project needs
|
|
7
|
+
* nothing installed. The `Symbol.for` tags keep definitions from two copies compatible.
|
|
8
|
+
*/
|
|
9
|
+
export declare const resolve: ResolveHookFn;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ERROR_CODE, PACKAGE_NAME } from '../constants.js';
|
|
2
|
+
import { codeOf } from '../utils/error-code.js';
|
|
3
|
+
const isOwnPackage = (specifier) => specifier === PACKAGE_NAME || specifier.startsWith(`${PACKAGE_NAME}/`);
|
|
4
|
+
/**
|
|
5
|
+
* A command file imports the framework by its package name, but a globally installed `art` is not
|
|
6
|
+
* on the module path of a project's files. Normal resolution always goes first, so a copy the
|
|
7
|
+
* project installed itself wins. Only when that fails with "module not found" for this package (or
|
|
8
|
+
* one of its subpaths) is the name resolved from inside the running copy, so the project needs
|
|
9
|
+
* nothing installed. The `Symbol.for` tags keep definitions from two copies compatible.
|
|
10
|
+
*/
|
|
11
|
+
export const resolve = async (specifier, context, nextResolve) => {
|
|
12
|
+
try {
|
|
13
|
+
return await nextResolve(specifier, context);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (!isOwnPackage(specifier) || codeOf(error) !== ERROR_CODE.MODULE_NOT_FOUND) {
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
return nextResolve(specifier, { ...context, parentURL: import.meta.url });
|
|
20
|
+
}
|
|
21
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { FindProjectFn, ResolveBinNameFn, ResolveSettingsFn } from '../models.js';
|
|
2
|
+
/**
|
|
3
|
+
* The project of a folder, looking in `cwd` and then each parent. Shipped route: the nearest
|
|
4
|
+
* package.json with an "artemis" key or a commands folder next to it. Own route (`cli({ root })`):
|
|
5
|
+
* the nearest one with the identity of a package (a name, a `bin` or an "artemis" key), and never
|
|
6
|
+
* past the folder of an installed package, so a tool cannot climb into the project that holds it,
|
|
7
|
+
* with or without a `bin`. A package.json that cannot be read or parsed is an error, never skipped.
|
|
8
|
+
* Pure: the disk is only looked at through the two edges.
|
|
9
|
+
*/
|
|
10
|
+
export declare const findProject: FindProjectFn;
|
|
11
|
+
/**
|
|
12
|
+
* The name help and usage lines show. The shipped binary shows `"artemis".bin` or `art`, never the
|
|
13
|
+
* name of an unrelated CLI the project publishes; a project's own bin file also uses the name of
|
|
14
|
+
* its own `bin` field.
|
|
15
|
+
*/
|
|
16
|
+
export declare const resolveBinName: ResolveBinNameFn;
|
|
17
|
+
/** The settings of a project: where its commands are (absolute) and what to call the CLI. */
|
|
18
|
+
export declare const resolveSettings: ResolveSettingsFn;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { CONFIG_KEY, DEFAULT_BIN_NAME, DEFAULT_COMMANDS_FOLDER, MANIFEST_FILE, NODE_MODULES, SCOPE_FOLDER_PREFIX, SCOPE_PREFIX } from '../constants.js';
|
|
3
|
+
import { CommandError } from '../errors/errors.js';
|
|
4
|
+
import { isRecord } from '../utils/is-record.js';
|
|
5
|
+
const VALID_SHAPE_HINT = `The key looks like "${CONFIG_KEY}": { "commandsDir": "commands", "bin": "mytool" }, and both fields are optional.`;
|
|
6
|
+
const isNonEmptyText = (value) => typeof value === 'string' && value.length > 0;
|
|
7
|
+
/**
|
|
8
|
+
* Shipped route: a package.json is the project when it declares itself, with an "artemis" key (what
|
|
9
|
+
* `init` writes), or follows the convention, with a commands folder next to it.
|
|
10
|
+
*/
|
|
11
|
+
const isProjectOfAPerson = (dir, manifest, isFolder) => manifest[CONFIG_KEY] !== undefined || isFolder(path.join(dir, DEFAULT_COMMANDS_FOLDER));
|
|
12
|
+
/** Whether a package.json declares a `bin`: a string, or an object with at least one command. */
|
|
13
|
+
const declaresBin = ({ bin }) => isRecord(bin) ? Object.keys(bin).length > 0 : isNonEmptyText(bin);
|
|
14
|
+
/**
|
|
15
|
+
* Own route: a package.json says what a tool's package is when it has a name, declares a `bin`, or has
|
|
16
|
+
* an "artemis" key. The package.json files above a bin file are a marker or two in its own folders
|
|
17
|
+
* (no name, no bin, no key), then the tool's package, then the project that holds it, so the first of
|
|
18
|
+
* these is the tool's own, whether or not it declares a `bin`.
|
|
19
|
+
*/
|
|
20
|
+
const hasToolIdentity = (manifest) => isNonEmptyText(manifest.name) || declaresBin(manifest) || manifest[CONFIG_KEY] !== undefined;
|
|
21
|
+
/** Whether a folder is the folder of a package installed by npm: `node_modules/pkg` or `node_modules/@scope/pkg`. */
|
|
22
|
+
const isInstalledPackageRoot = (dir) => {
|
|
23
|
+
const parent = path.dirname(dir);
|
|
24
|
+
const container = path.basename(parent).startsWith(SCOPE_FOLDER_PREFIX)
|
|
25
|
+
? path.dirname(parent)
|
|
26
|
+
: parent;
|
|
27
|
+
return path.basename(container) === NODE_MODULES;
|
|
28
|
+
};
|
|
29
|
+
const isTheProject = (dir, manifest, isFolder, route) => route === 'own' ? hasToolIdentity(manifest) : isProjectOfAPerson(dir, manifest, isFolder);
|
|
30
|
+
function searchUpwards(dir, readManifest, isFolder, route) {
|
|
31
|
+
const read = readManifest(dir);
|
|
32
|
+
// Failing loudly beats silently running the commands of a different project.
|
|
33
|
+
if (read && 'error' in read) {
|
|
34
|
+
throw read.error;
|
|
35
|
+
}
|
|
36
|
+
if (read && isTheProject(dir, read.manifest, isFolder, route)) {
|
|
37
|
+
return { root: dir, manifest: read.manifest };
|
|
38
|
+
}
|
|
39
|
+
// A tool installed by npm is its own package whatever its package.json holds: never look past it.
|
|
40
|
+
if (route === 'own' && isInstalledPackageRoot(dir)) {
|
|
41
|
+
return read ? { root: dir, manifest: read.manifest } : undefined;
|
|
42
|
+
}
|
|
43
|
+
const parent = path.dirname(dir);
|
|
44
|
+
return parent === dir ? undefined : searchUpwards(parent, readManifest, isFolder, route);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The project of a folder, looking in `cwd` and then each parent. Shipped route: the nearest
|
|
48
|
+
* package.json with an "artemis" key or a commands folder next to it. Own route (`cli({ root })`):
|
|
49
|
+
* the nearest one with the identity of a package (a name, a `bin` or an "artemis" key), and never
|
|
50
|
+
* past the folder of an installed package, so a tool cannot climb into the project that holds it,
|
|
51
|
+
* with or without a `bin`. A package.json that cannot be read or parsed is an error, never skipped.
|
|
52
|
+
* Pure: the disk is only looked at through the two edges.
|
|
53
|
+
*/
|
|
54
|
+
export const findProject = (cwd, readManifest, isFolder, route) => searchUpwards(path.resolve(cwd), readManifest, isFolder, route);
|
|
55
|
+
function invalidConfig(project, problem) {
|
|
56
|
+
const manifestPath = path.join(project.root, MANIFEST_FILE);
|
|
57
|
+
return new CommandError(`${problem} (in ${manifestPath})`, { hint: VALID_SHAPE_HINT });
|
|
58
|
+
}
|
|
59
|
+
/** The "artemis" key of the manifest, checked field by field. It is only read here. */
|
|
60
|
+
function readConfig(project) {
|
|
61
|
+
const declared = project.manifest[CONFIG_KEY];
|
|
62
|
+
if (declared === undefined) {
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
if (!isRecord(declared)) {
|
|
66
|
+
throw invalidConfig(project, `"${CONFIG_KEY}" must be an object`);
|
|
67
|
+
}
|
|
68
|
+
for (const field of ['commandsDir', 'bin']) {
|
|
69
|
+
if (declared[field] !== undefined && !isNonEmptyText(declared[field])) {
|
|
70
|
+
throw invalidConfig(project, `"${CONFIG_KEY}".${field} must be a non-empty string`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return declared;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The command a project's own bin file is known by: the single key of its `bin` object. When `bin`
|
|
77
|
+
* does not name one command (a string, or several keys) npm names it after the package, so that is
|
|
78
|
+
* the name, without its `@scope/`. A project with no `bin` at all has no name of its own.
|
|
79
|
+
*/
|
|
80
|
+
function ownBinName(manifest) {
|
|
81
|
+
const { bin, name } = manifest;
|
|
82
|
+
const keys = isRecord(bin) ? Object.keys(bin) : [];
|
|
83
|
+
const [only] = keys;
|
|
84
|
+
if (keys.length === 1) {
|
|
85
|
+
return only;
|
|
86
|
+
}
|
|
87
|
+
const namesNoSingleCommand = keys.length > 1 || isNonEmptyText(bin);
|
|
88
|
+
return namesNoSingleCommand && isNonEmptyText(name)
|
|
89
|
+
? name.replace(SCOPE_PREFIX, '')
|
|
90
|
+
: undefined;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The name help and usage lines show. The shipped binary shows `"artemis".bin` or `art`, never the
|
|
94
|
+
* name of an unrelated CLI the project publishes; a project's own bin file also uses the name of
|
|
95
|
+
* its own `bin` field.
|
|
96
|
+
*/
|
|
97
|
+
export const resolveBinName = (config, manifest, route) => {
|
|
98
|
+
if (config.bin) {
|
|
99
|
+
return config.bin;
|
|
100
|
+
}
|
|
101
|
+
return (route === 'own' ? ownBinName(manifest) : undefined) ?? DEFAULT_BIN_NAME;
|
|
102
|
+
};
|
|
103
|
+
/** The settings of a project: where its commands are (absolute) and what to call the CLI. */
|
|
104
|
+
export const resolveSettings = (project, route) => {
|
|
105
|
+
const config = readConfig(project);
|
|
106
|
+
return {
|
|
107
|
+
bin: resolveBinName(config, project.manifest, route),
|
|
108
|
+
commandsDir: path.resolve(project.root, config.commandsDir ?? DEFAULT_COMMANDS_FOLDER)
|
|
109
|
+
};
|
|
110
|
+
};
|