@runium/cli 0.0.1 → 0.0.3
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/build.js +21 -0
- package/lib/app.js +2 -2
- package/lib/commands/plugin/plugin-add.js +1 -1
- package/lib/commands/plugin/plugin-disable.js +1 -1
- package/lib/commands/plugin/plugin-enable.js +1 -1
- package/lib/commands/plugin/plugin-remove.js +1 -1
- package/lib/commands/plugin/plugin.js +1 -1
- package/lib/commands/project/project-add.js +1 -1
- package/lib/commands/project/project-command.js +1 -1
- package/lib/commands/project/project-start.js +1 -1
- package/lib/commands/project/project-state-command.js +1 -1
- package/lib/commands/project/project-status.js +1 -1
- package/lib/commands/project/project-stop.js +1 -1
- package/lib/commands/project/project-validate.js +4 -1
- package/lib/commands/project/project.js +1 -1
- package/lib/commands/runium-command.js +1 -1
- package/lib/constants/error-code.js +1 -1
- package/lib/index.js +1 -1
- package/lib/macros/date.js +1 -0
- package/lib/macros/index.js +1 -1
- package/lib/macros/path.js +1 -1
- package/lib/package.json +3 -2
- package/lib/services/command.js +1 -0
- package/lib/services/config.js +1 -1
- package/lib/services/file.js +1 -0
- package/lib/services/index.js +1 -1
- package/lib/services/output.js +3 -3
- package/lib/services/plugin-context.js +1 -1
- package/lib/services/plugin.js +1 -1
- package/lib/services/profile.js +1 -1
- package/lib/services/project.js +1 -1
- package/lib/services/shutdown.js +1 -1
- package/lib/utils/get-version.js +1 -0
- package/lib/utils/index.js +1 -1
- package/lib/validation/create-validator.js +1 -0
- package/lib/validation/get-config-schema.js +1 -0
- package/lib/validation/get-error-messages.js +1 -0
- package/lib/validation/get-plugin-schema.js +1 -0
- package/lib/validation/index.js +1 -0
- package/package.json +5 -2
- package/src/app.ts +35 -20
- package/src/commands/plugin/plugin-add.ts +2 -2
- package/src/commands/plugin/plugin-disable.ts +1 -1
- package/src/commands/plugin/plugin-enable.ts +2 -2
- package/src/commands/plugin/plugin-remove.ts +1 -1
- package/src/commands/plugin/plugin.ts +11 -16
- package/src/commands/project/project-add.ts +23 -5
- package/src/commands/project/project-command.ts +8 -1
- package/src/commands/project/project-start.ts +18 -12
- package/src/commands/project/project-state-command.ts +4 -19
- package/src/commands/project/project-status.ts +16 -3
- package/src/commands/project/project-stop.ts +5 -1
- package/src/commands/project/project-validate.ts +23 -10
- package/src/commands/project/project.ts +13 -18
- package/src/commands/runium-command.ts +18 -16
- package/src/constants/error-code.ts +15 -2
- package/src/global.d.ts +6 -0
- package/src/index.ts +5 -2
- package/src/macros/date.ts +15 -0
- package/src/macros/index.ts +6 -1
- package/src/macros/path.ts +17 -2
- package/src/services/command.ts +171 -0
- package/src/services/config.ts +47 -4
- package/src/services/file.ts +272 -0
- package/src/services/index.ts +2 -0
- package/src/services/output.ts +5 -1
- package/src/services/plugin-context.ts +68 -9
- package/src/services/plugin.ts +122 -18
- package/src/services/profile.ts +37 -49
- package/src/services/project.ts +31 -3
- package/src/services/shutdown.ts +25 -8
- package/src/utils/get-version.ts +13 -0
- package/src/utils/index.ts +1 -0
- package/src/validation/create-validator.ts +27 -0
- package/src/validation/get-config-schema.ts +59 -0
- package/src/validation/get-error-messages.ts +35 -0
- package/src/validation/get-plugin-schema.ts +137 -0
- package/src/validation/index.ts +4 -0
- package/tsconfig.json +8 -10
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { Container, Service } from 'typedi';
|
|
3
|
+
import { isRuniumError, RuniumError } from '@runium/core';
|
|
4
|
+
import { ErrorCode } from '@constants';
|
|
5
|
+
import { PluginService } from '@services';
|
|
6
|
+
import {
|
|
7
|
+
RuniumCommand,
|
|
8
|
+
RuniumCommandConstructor,
|
|
9
|
+
} from '@commands/runium-command.js';
|
|
10
|
+
|
|
11
|
+
const PROGRAM_NAME = 'runium';
|
|
12
|
+
|
|
13
|
+
@Service()
|
|
14
|
+
export class CommandService {
|
|
15
|
+
/**
|
|
16
|
+
* Full path commands
|
|
17
|
+
*/
|
|
18
|
+
private fullPathCommands: Map<string, RuniumCommand> = new Map();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get command full path recursively
|
|
22
|
+
* @param command
|
|
23
|
+
*/
|
|
24
|
+
private getCommandFullPath(command: RuniumCommand): string {
|
|
25
|
+
const commandName = command.command?.name();
|
|
26
|
+
|
|
27
|
+
if (commandName === PROGRAM_NAME || !command.command.parent) {
|
|
28
|
+
return '';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// find the parent command instance
|
|
32
|
+
const parentCommand = Array.from(this.fullPathCommands.values()).find(
|
|
33
|
+
cmd => cmd.command === command.command.parent
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
if (parentCommand) {
|
|
37
|
+
// recursively get parent's full path
|
|
38
|
+
const parentPath = this.getCommandFullPath(parentCommand);
|
|
39
|
+
return [parentPath, commandName].filter(Boolean).join(' ').trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// fallback: if parent not found in map, check if parent is the program
|
|
43
|
+
const parentName = command.command.parent.name();
|
|
44
|
+
if (parentName === PROGRAM_NAME) {
|
|
45
|
+
return commandName;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return [parentName, commandName].join(' ').trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Register command
|
|
53
|
+
* @param command
|
|
54
|
+
* @param program
|
|
55
|
+
* @param context
|
|
56
|
+
*/
|
|
57
|
+
registerCommand(
|
|
58
|
+
command: RuniumCommandConstructor,
|
|
59
|
+
program: Command,
|
|
60
|
+
context: string = 'app'
|
|
61
|
+
): void {
|
|
62
|
+
const addCommand = (
|
|
63
|
+
CommandConstructor: RuniumCommandConstructor,
|
|
64
|
+
parent: Command
|
|
65
|
+
) => {
|
|
66
|
+
try {
|
|
67
|
+
if (!(CommandConstructor.prototype instanceof RuniumCommand)) {
|
|
68
|
+
throw new RuniumError(
|
|
69
|
+
`Command "${CommandConstructor.name}" for "${context}" must be a subclass of "RuniumCommand"`,
|
|
70
|
+
ErrorCode.COMMAND_INCORRECT,
|
|
71
|
+
{
|
|
72
|
+
context,
|
|
73
|
+
CommandConstructor,
|
|
74
|
+
}
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const commandInstance: RuniumCommand = new CommandConstructor(parent);
|
|
79
|
+
|
|
80
|
+
const commandPath = this.getCommandFullPath(commandInstance);
|
|
81
|
+
this.fullPathCommands.set(commandPath, commandInstance);
|
|
82
|
+
|
|
83
|
+
if (commandInstance.subcommands.length > 0) {
|
|
84
|
+
commandInstance.subcommands.forEach(subcommand => {
|
|
85
|
+
addCommand(subcommand, commandInstance.command);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (isRuniumError(error)) {
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
throw new RuniumError(
|
|
93
|
+
`Failed to register command "${CommandConstructor.name}" for "${context}"`,
|
|
94
|
+
ErrorCode.COMMAND_REGISTRATION_ERROR,
|
|
95
|
+
{ original: error }
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
addCommand(command, program);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Create run command
|
|
105
|
+
* @param handle
|
|
106
|
+
* @param command
|
|
107
|
+
*/
|
|
108
|
+
createRunCommand(
|
|
109
|
+
handle: (...args: unknown[]) => Promise<void>,
|
|
110
|
+
command: RuniumCommand
|
|
111
|
+
): (...args: unknown[]) => Promise<void> {
|
|
112
|
+
const pluginService = Container.get(PluginService);
|
|
113
|
+
return async (...args: unknown[]): Promise<void> => {
|
|
114
|
+
// run from command action contains command
|
|
115
|
+
// run from plugin context does not contain command
|
|
116
|
+
if (args?.length > 0 && args[args.length - 1] instanceof Command) {
|
|
117
|
+
args.pop();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const commandPath = this.getCommandFullPath(command);
|
|
121
|
+
|
|
122
|
+
await pluginService.runHook('app.beforeCommandRun', {
|
|
123
|
+
command: commandPath,
|
|
124
|
+
args,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await handle.call(command, ...args);
|
|
128
|
+
|
|
129
|
+
await pluginService.runHook('app.afterCommandRun', {
|
|
130
|
+
command: commandPath,
|
|
131
|
+
args,
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Check if command exists
|
|
138
|
+
* @param path
|
|
139
|
+
*/
|
|
140
|
+
hasCommand(path: string): boolean {
|
|
141
|
+
return this.fullPathCommands.has(path);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Run command
|
|
146
|
+
* @param path
|
|
147
|
+
* @param args
|
|
148
|
+
*/
|
|
149
|
+
async runCommand(path: string, ...args: unknown[]): Promise<void> {
|
|
150
|
+
const command = this.fullPathCommands.get(path);
|
|
151
|
+
if (!command) {
|
|
152
|
+
throw new RuniumError(
|
|
153
|
+
`Command "${path}" not found`,
|
|
154
|
+
ErrorCode.COMMAND_NOT_FOUND,
|
|
155
|
+
{ path }
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
await command.run(...args);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (isRuniumError(error)) {
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
throw new RuniumError(
|
|
165
|
+
`Failed to run command "${path}"`,
|
|
166
|
+
ErrorCode.COMMAND_RUN_ERROR,
|
|
167
|
+
{ original: error }
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
package/src/services/config.ts
CHANGED
|
@@ -2,7 +2,13 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
import { Service } from 'typedi';
|
|
5
|
-
import { readJsonFile } from '@runium/core';
|
|
5
|
+
import { readJsonFile, RuniumError } from '@runium/core';
|
|
6
|
+
import { ErrorCode } from '@constants';
|
|
7
|
+
import {
|
|
8
|
+
createValidator,
|
|
9
|
+
getConfigSchema,
|
|
10
|
+
getErrorMessages,
|
|
11
|
+
} from '@validation';
|
|
6
12
|
|
|
7
13
|
interface ConfigEnv {
|
|
8
14
|
path: string[];
|
|
@@ -16,10 +22,20 @@ interface ConfigProfile {
|
|
|
16
22
|
path: string;
|
|
17
23
|
}
|
|
18
24
|
|
|
25
|
+
interface ConfigPlugin {
|
|
26
|
+
disabled: boolean;
|
|
27
|
+
options: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ConfigPlugins {
|
|
31
|
+
[name: string]: ConfigPlugin;
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
interface ConfigData {
|
|
20
35
|
env: ConfigEnv;
|
|
21
36
|
output: ConfigOutput;
|
|
22
37
|
profile: ConfigProfile;
|
|
38
|
+
plugins: ConfigPlugins;
|
|
23
39
|
}
|
|
24
40
|
|
|
25
41
|
const CONFIG_FILE_NAME = '.runiumrc.json';
|
|
@@ -28,10 +44,31 @@ const PROFILE_DIR_NAME = '.runium';
|
|
|
28
44
|
const HOME_PROFILE_PATH = join(homedir(), PROFILE_DIR_NAME);
|
|
29
45
|
const CWD_PROFILE_PATH = join(process.cwd(), PROFILE_DIR_NAME);
|
|
30
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Validate config data
|
|
49
|
+
* @param data
|
|
50
|
+
*/
|
|
51
|
+
function validateConfigData(data: unknown): void {
|
|
52
|
+
const schema = getConfigSchema();
|
|
53
|
+
const validate = createValidator(schema);
|
|
54
|
+
const result = validate(data);
|
|
55
|
+
|
|
56
|
+
if (!result && validate.errors) {
|
|
57
|
+
const errorMessages = getErrorMessages(validate.errors);
|
|
58
|
+
|
|
59
|
+
throw new RuniumError(
|
|
60
|
+
`Invalid "${CONFIG_FILE_PATH}" configuration data`,
|
|
61
|
+
ErrorCode.CONFIG_INVALID_DATA,
|
|
62
|
+
errorMessages
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
31
67
|
@Service()
|
|
32
68
|
export class ConfigService {
|
|
33
69
|
private data: ConfigData = {
|
|
34
70
|
profile: { path: HOME_PROFILE_PATH },
|
|
71
|
+
plugins: {},
|
|
35
72
|
output: { debug: false },
|
|
36
73
|
env: { path: [] },
|
|
37
74
|
};
|
|
@@ -45,10 +82,11 @@ export class ConfigService {
|
|
|
45
82
|
}
|
|
46
83
|
|
|
47
84
|
if (existsSync(CONFIG_FILE_PATH)) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
await readJsonFile<ConfigData>(CONFIG_FILE_PATH);
|
|
85
|
+
const configData: Partial<ConfigData> =
|
|
86
|
+
await readJsonFile<Partial<ConfigData>>(CONFIG_FILE_PATH);
|
|
51
87
|
if (configData) {
|
|
88
|
+
validateConfigData(configData);
|
|
89
|
+
|
|
52
90
|
const data = {
|
|
53
91
|
env: Object.assign({}, this.data.env, configData.env ?? {}),
|
|
54
92
|
output: Object.assign({}, this.data.output, configData.output ?? {}),
|
|
@@ -57,6 +95,11 @@ export class ConfigService {
|
|
|
57
95
|
this.data.profile,
|
|
58
96
|
configData.profile ?? {}
|
|
59
97
|
),
|
|
98
|
+
plugins: Object.assign(
|
|
99
|
+
{},
|
|
100
|
+
this.data.plugins,
|
|
101
|
+
configData.plugins ?? {}
|
|
102
|
+
),
|
|
60
103
|
};
|
|
61
104
|
|
|
62
105
|
data.env.path = data.env.path.map(envPath => resolve(envPath));
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { PathLike } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
access,
|
|
5
|
+
constants,
|
|
6
|
+
mkdir,
|
|
7
|
+
readFile,
|
|
8
|
+
rename,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from 'node:fs/promises';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
import { Service } from 'typedi';
|
|
14
|
+
import { JSONObject, RuniumError } from '@runium/core';
|
|
15
|
+
import { ErrorCode } from '@constants';
|
|
16
|
+
|
|
17
|
+
type Resolve = () => void;
|
|
18
|
+
type Reject = (error: Error) => void;
|
|
19
|
+
type Data = Parameters<typeof writeFile>[1];
|
|
20
|
+
|
|
21
|
+
@Service()
|
|
22
|
+
export class FileService {
|
|
23
|
+
/**
|
|
24
|
+
* Reads a file as text
|
|
25
|
+
* @param path
|
|
26
|
+
* @param options
|
|
27
|
+
*/
|
|
28
|
+
async read(
|
|
29
|
+
path: string,
|
|
30
|
+
options: { encoding?: BufferEncoding } = {}
|
|
31
|
+
): Promise<string> {
|
|
32
|
+
try {
|
|
33
|
+
return await readFile(path, { encoding: options.encoding || 'utf-8' });
|
|
34
|
+
} catch (ex) {
|
|
35
|
+
throw new RuniumError(
|
|
36
|
+
`Can not read file ${path}`,
|
|
37
|
+
ErrorCode.FILE_READ_ERROR,
|
|
38
|
+
{ path, options, original: ex }
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Writes data to a file
|
|
45
|
+
* @param path
|
|
46
|
+
* @param data
|
|
47
|
+
* @param options
|
|
48
|
+
*/
|
|
49
|
+
async write(
|
|
50
|
+
path: string,
|
|
51
|
+
data: string,
|
|
52
|
+
options: { encoding?: BufferEncoding } = {}
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
try {
|
|
55
|
+
await writeFile(path, data, { encoding: options.encoding || 'utf-8' });
|
|
56
|
+
} catch (ex) {
|
|
57
|
+
throw new RuniumError(
|
|
58
|
+
`Can not write file ${path}`,
|
|
59
|
+
ErrorCode.FILE_WRITE_ERROR,
|
|
60
|
+
{ path, data, options, original: ex }
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Reads a JSON file
|
|
67
|
+
* @param path
|
|
68
|
+
*/
|
|
69
|
+
async readJson<T = JSONObject>(path: string): Promise<T> {
|
|
70
|
+
try {
|
|
71
|
+
const data = await readFile(path, { encoding: 'utf-8' });
|
|
72
|
+
return JSON.parse(data);
|
|
73
|
+
} catch (ex) {
|
|
74
|
+
throw new RuniumError(
|
|
75
|
+
`Can not read JSON file ${path}`,
|
|
76
|
+
ErrorCode.FILE_READ_JSON_ERROR,
|
|
77
|
+
{ path, original: ex }
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Writes a JSON file
|
|
84
|
+
* @param path
|
|
85
|
+
* @param data
|
|
86
|
+
*/
|
|
87
|
+
async writeJson<T = JSONObject>(path: string, data: T): Promise<void> {
|
|
88
|
+
try {
|
|
89
|
+
await writeFile(path, JSON.stringify(data, null, 2), {
|
|
90
|
+
encoding: 'utf-8',
|
|
91
|
+
});
|
|
92
|
+
} catch (ex) {
|
|
93
|
+
throw new RuniumError(
|
|
94
|
+
`Can not write JSON file ${path}`,
|
|
95
|
+
ErrorCode.FILE_WRITE_JSON_ERROR,
|
|
96
|
+
{ path, data, original: ex }
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Checks if a file or directory exists
|
|
103
|
+
* @param path
|
|
104
|
+
*/
|
|
105
|
+
async isExists(path: string): Promise<boolean> {
|
|
106
|
+
try {
|
|
107
|
+
await access(path, constants.F_OK);
|
|
108
|
+
return true;
|
|
109
|
+
} catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Create directory recursively if it does not exist
|
|
116
|
+
* @param path
|
|
117
|
+
*/
|
|
118
|
+
async ensureDirExists(path: string): Promise<void> {
|
|
119
|
+
try {
|
|
120
|
+
await mkdir(path, { recursive: true });
|
|
121
|
+
} catch (ex) {
|
|
122
|
+
throw new RuniumError(
|
|
123
|
+
`Can not create directory ${path}`,
|
|
124
|
+
ErrorCode.FILE_CREATE_DIR_ERROR,
|
|
125
|
+
{ path, original: ex }
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Create an atomic file writer
|
|
132
|
+
* @param path
|
|
133
|
+
*/
|
|
134
|
+
createAtomicWriter(path: PathLike): AtomicWriter {
|
|
135
|
+
return new AtomicWriter(path);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Creates a temporary file name for a given file
|
|
141
|
+
* @param file
|
|
142
|
+
*/
|
|
143
|
+
function getTempFilename(file: PathLike): string {
|
|
144
|
+
const f = file instanceof URL ? fileURLToPath(file) : file.toString();
|
|
145
|
+
return join(dirname(f), `.${basename(f)}.tmp`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Retries an asynchronous operation with a delay between retries and a maximum retry count
|
|
150
|
+
* @param fn
|
|
151
|
+
* @param maxRetries
|
|
152
|
+
* @param delayMs
|
|
153
|
+
*/
|
|
154
|
+
async function retryAsyncOperation(
|
|
155
|
+
fn: () => Promise<void>,
|
|
156
|
+
maxRetries: number,
|
|
157
|
+
delayMs: number
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
160
|
+
try {
|
|
161
|
+
return await fn();
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (i < maxRetries - 1) {
|
|
164
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
165
|
+
} else {
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Atomic file writer
|
|
174
|
+
*
|
|
175
|
+
* Allows writing to a file atomically
|
|
176
|
+
* based on https://github.com/typicode/steno
|
|
177
|
+
*/
|
|
178
|
+
export class AtomicWriter {
|
|
179
|
+
private readonly filename: PathLike;
|
|
180
|
+
private readonly tempFilename: PathLike;
|
|
181
|
+
private locked = false;
|
|
182
|
+
private prev: [Resolve, Reject] | null = null;
|
|
183
|
+
private next: [Resolve, Reject] | null = null;
|
|
184
|
+
private nextPromise: Promise<void> | null = null;
|
|
185
|
+
private nextData: Data | null = null;
|
|
186
|
+
|
|
187
|
+
constructor(filename: PathLike) {
|
|
188
|
+
this.filename = filename;
|
|
189
|
+
this.tempFilename = getTempFilename(filename);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Add data for later write
|
|
194
|
+
* @param data
|
|
195
|
+
*/
|
|
196
|
+
private addData(data: Data): Promise<void> {
|
|
197
|
+
// keep only most recent data
|
|
198
|
+
this.nextData = data;
|
|
199
|
+
|
|
200
|
+
// create a singleton promise to resolve all next promises once next data is written
|
|
201
|
+
this.nextPromise ||= new Promise((resolve, reject) => {
|
|
202
|
+
this.next = [resolve, reject];
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// return a promise that will resolve at the same time as next promise
|
|
206
|
+
return new Promise((resolve, reject) => {
|
|
207
|
+
this.nextPromise?.then(resolve).catch(reject);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Write data to a file atomically
|
|
213
|
+
* @param data
|
|
214
|
+
*/
|
|
215
|
+
private async writeData(data: Data): Promise<void> {
|
|
216
|
+
this.locked = true;
|
|
217
|
+
try {
|
|
218
|
+
await writeFile(this.tempFilename, data, 'utf-8');
|
|
219
|
+
await retryAsyncOperation(
|
|
220
|
+
async () => {
|
|
221
|
+
await rename(this.tempFilename, this.filename);
|
|
222
|
+
},
|
|
223
|
+
10,
|
|
224
|
+
100
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
// resolve
|
|
228
|
+
this.prev?.[0]();
|
|
229
|
+
} catch (err) {
|
|
230
|
+
// reject
|
|
231
|
+
if (err instanceof Error) {
|
|
232
|
+
this.prev?.[1](err);
|
|
233
|
+
}
|
|
234
|
+
throw err;
|
|
235
|
+
} finally {
|
|
236
|
+
this.locked = false;
|
|
237
|
+
|
|
238
|
+
this.prev = this.next;
|
|
239
|
+
this.next = this.nextPromise = null;
|
|
240
|
+
|
|
241
|
+
if (this.nextData !== null) {
|
|
242
|
+
const nextData = this.nextData;
|
|
243
|
+
this.nextData = null;
|
|
244
|
+
await this.write(nextData);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Write data to a file atomically
|
|
251
|
+
* @param data
|
|
252
|
+
*/
|
|
253
|
+
async write(data: Data): Promise<void> {
|
|
254
|
+
try {
|
|
255
|
+
await (this.locked ? this.addData(data) : this.writeData(data));
|
|
256
|
+
} catch (ex) {
|
|
257
|
+
throw new RuniumError(
|
|
258
|
+
`Can not write file ${this.filename}`,
|
|
259
|
+
ErrorCode.FILE_WRITE_ERROR,
|
|
260
|
+
{ path: this.filename, data, original: ex }
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Writes a JSON file
|
|
267
|
+
* @param data
|
|
268
|
+
*/
|
|
269
|
+
async writeJson<T = JSONObject>(data: T): Promise<void> {
|
|
270
|
+
return this.write(JSON.stringify(data, null, 2));
|
|
271
|
+
}
|
|
272
|
+
}
|
package/src/services/index.ts
CHANGED
package/src/services/output.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Console } from 'node:console';
|
|
2
2
|
import { Transform } from 'node:stream';
|
|
3
|
+
import { inspect } from 'node:util';
|
|
3
4
|
|
|
4
5
|
import { Service } from 'typedi';
|
|
5
6
|
|
|
@@ -20,6 +21,8 @@ class ConsoleDumper extends Console {
|
|
|
20
21
|
private readonly transform: Transform;
|
|
21
22
|
|
|
22
23
|
constructor() {
|
|
24
|
+
inspect.defaultOptions.depth = 5;
|
|
25
|
+
|
|
23
26
|
const transform = new Transform({
|
|
24
27
|
transform: (chunk, _, cb) => cb(null, chunk),
|
|
25
28
|
});
|
|
@@ -50,7 +53,8 @@ class ConsoleDumper extends Console {
|
|
|
50
53
|
return original
|
|
51
54
|
.split('\n')
|
|
52
55
|
.map((line: string) => line.charAt(0) + line.slice(columnWidth))
|
|
53
|
-
.join('\n')
|
|
56
|
+
.join('\n')
|
|
57
|
+
.replace(/'([^']*)'/g, '$1 ');
|
|
54
58
|
}
|
|
55
59
|
}
|
|
56
60
|
|
|
@@ -1,34 +1,80 @@
|
|
|
1
|
+
import { delimiter } from 'node:path';
|
|
1
2
|
import { Inject, Service } from 'typedi';
|
|
2
|
-
|
|
3
3
|
import {
|
|
4
4
|
RuniumError,
|
|
5
5
|
isRuniumError,
|
|
6
6
|
RuniumTask,
|
|
7
7
|
RuniumTrigger,
|
|
8
|
-
readJsonFile,
|
|
9
|
-
writeJsonFile,
|
|
10
8
|
applyMacros,
|
|
9
|
+
TaskEvent,
|
|
11
10
|
TaskStatus,
|
|
12
11
|
ProjectEvent,
|
|
13
12
|
ProjectStatus,
|
|
14
13
|
} from '@runium/core';
|
|
15
14
|
import { RuniumCommand } from '@commands/runium-command.js';
|
|
16
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
CommandService,
|
|
17
|
+
FileService,
|
|
18
|
+
OutputLevel,
|
|
19
|
+
OutputService,
|
|
20
|
+
ProfileService,
|
|
21
|
+
ShutdownService,
|
|
22
|
+
} from '@services';
|
|
23
|
+
import { getVersion } from '@utils';
|
|
24
|
+
import { ErrorCode } from '@constants';
|
|
17
25
|
|
|
18
|
-
// @ts-expect-error global object access
|
|
19
26
|
global.runium = null;
|
|
20
27
|
|
|
21
28
|
@Service()
|
|
22
29
|
export class PluginContextService {
|
|
23
30
|
constructor(
|
|
31
|
+
@Inject() private commandService: CommandService,
|
|
24
32
|
@Inject() private outputService: OutputService,
|
|
25
|
-
@Inject() private shutdownService: ShutdownService
|
|
33
|
+
@Inject() private shutdownService: ShutdownService,
|
|
34
|
+
@Inject() private fileService: FileService,
|
|
35
|
+
@Inject() private profileService: ProfileService
|
|
26
36
|
) {}
|
|
27
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Create a wrapper for a file service method that processes path parts
|
|
40
|
+
* @param methodName
|
|
41
|
+
*/
|
|
42
|
+
private createStorageWrapper<T extends keyof FileService>(
|
|
43
|
+
methodName: T
|
|
44
|
+
): FileService[T] {
|
|
45
|
+
return ((...args: unknown[]) => {
|
|
46
|
+
const [pathParts, ...rest] = args;
|
|
47
|
+
const resolvedPath = this.resolveProfilePath(
|
|
48
|
+
pathParts as string | string[]
|
|
49
|
+
);
|
|
50
|
+
const method = this.fileService[methodName] as (
|
|
51
|
+
...args: unknown[]
|
|
52
|
+
) => unknown;
|
|
53
|
+
return method(resolvedPath, ...rest);
|
|
54
|
+
}) as FileService[T];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve path parts using profileService
|
|
59
|
+
* @param pathParts
|
|
60
|
+
*/
|
|
61
|
+
private resolveProfilePath(pathParts: string | string[]): string {
|
|
62
|
+
const parts = Array.isArray(pathParts)
|
|
63
|
+
? pathParts
|
|
64
|
+
: pathParts.split(delimiter);
|
|
65
|
+
if (parts.length === 0 || parts.every(part => part.trim() === '')) {
|
|
66
|
+
throw new RuniumError('Invalid path', ErrorCode.INVALID_PATH, {
|
|
67
|
+
path: pathParts,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return this.profileService.getPath(...parts);
|
|
71
|
+
}
|
|
72
|
+
|
|
28
73
|
/**
|
|
29
74
|
* Initialize the plugin context service
|
|
30
75
|
*/
|
|
31
76
|
async init(): Promise<void> {
|
|
77
|
+
const command = this.commandService;
|
|
32
78
|
const output = this.outputService;
|
|
33
79
|
const shutdown = this.shutdownService;
|
|
34
80
|
|
|
@@ -51,13 +97,12 @@ export class PluginContextService {
|
|
|
51
97
|
),
|
|
52
98
|
ProjectEvent,
|
|
53
99
|
ProjectStatus,
|
|
100
|
+
TaskEvent,
|
|
54
101
|
TaskStatus,
|
|
55
102
|
},
|
|
56
103
|
utils: {
|
|
57
104
|
applyMacros,
|
|
58
105
|
isRuniumError,
|
|
59
|
-
readJsonFile,
|
|
60
|
-
writeJsonFile,
|
|
61
106
|
},
|
|
62
107
|
output: {
|
|
63
108
|
getLevel: output.getLevel.bind(output),
|
|
@@ -74,8 +119,22 @@ export class PluginContextService {
|
|
|
74
119
|
addBlocker: shutdown.addBlocker.bind(shutdown),
|
|
75
120
|
removeBlocker: shutdown.removeBlocker.bind(shutdown),
|
|
76
121
|
},
|
|
122
|
+
command: {
|
|
123
|
+
has: command.hasCommand.bind(command),
|
|
124
|
+
run: command.runCommand.bind(command),
|
|
125
|
+
},
|
|
126
|
+
storage: {
|
|
127
|
+
read: this.createStorageWrapper('read'),
|
|
128
|
+
write: this.createStorageWrapper('write'),
|
|
129
|
+
readJson: this.createStorageWrapper('readJson'),
|
|
130
|
+
writeJson: this.createStorageWrapper('writeJson'),
|
|
131
|
+
isExists: this.createStorageWrapper('isExists'),
|
|
132
|
+
ensureDirExists: this.createStorageWrapper('ensureDirExists'),
|
|
133
|
+
createAtomicWriter: this.createStorageWrapper('createAtomicWriter'),
|
|
134
|
+
getPath: this.resolveProfilePath.bind(this),
|
|
135
|
+
},
|
|
136
|
+
version: getVersion(),
|
|
77
137
|
};
|
|
78
|
-
// @ts-expect-error global object access
|
|
79
138
|
global.runium = Object.freeze(runium);
|
|
80
139
|
}
|
|
81
140
|
}
|