@bachbntdev/devcmd 0.0.0-stage → 2.7.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.
Files changed (66) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +849 -2
  3. package/dev +33 -0
  4. package/package.json +47 -4
  5. package/src/cli.js +217 -0
  6. package/src/commands/completion.js +97 -0
  7. package/src/commands/devices.js +26 -0
  8. package/src/commands/docker.js +35 -0
  9. package/src/commands/env.js +52 -0
  10. package/src/commands/frameworks.js +36 -0
  11. package/src/commands/history.js +59 -0
  12. package/src/commands/inspect.js +48 -0
  13. package/src/commands/lifecycle.js +70 -0
  14. package/src/config/index.js +35 -0
  15. package/src/config/user.js +101 -0
  16. package/src/constants.js +23 -0
  17. package/src/devices/android.js +89 -0
  18. package/src/devices/index.js +14 -0
  19. package/src/devices/ios.js +47 -0
  20. package/src/generators/clean-project.js +52 -0
  21. package/src/generators/python-project.js +175 -0
  22. package/src/generators/reset-project.js +16 -0
  23. package/src/handlers/clone.js +73 -0
  24. package/src/handlers/devices.js +78 -0
  25. package/src/handlers/history.js +75 -0
  26. package/src/handlers/json.js +68 -0
  27. package/src/handlers/lifecycle.js +119 -0
  28. package/src/handlers/mcp.js +10 -0
  29. package/src/handlers/scaffold.js +166 -0
  30. package/src/handlers/system.js +135 -0
  31. package/src/handlers/tools.js +66 -0
  32. package/src/handlers/workflow.js +121 -0
  33. package/src/mcp/processes.js +164 -0
  34. package/src/mcp/server.js +32 -0
  35. package/src/mcp/tools.js +389 -0
  36. package/src/openers/built-ins.json +80 -0
  37. package/src/openers/engine.js +51 -0
  38. package/src/openers/index.js +26 -0
  39. package/src/openers/loader.js +89 -0
  40. package/src/openers/schema.js +81 -0
  41. package/src/projects/adapters/android.js +24 -0
  42. package/src/projects/adapters/common.js +24 -0
  43. package/src/projects/adapters/flutter.js +29 -0
  44. package/src/projects/adapters/index.js +30 -0
  45. package/src/projects/adapters/ios.js +35 -0
  46. package/src/projects/adapters/node.js +112 -0
  47. package/src/projects/adapters/python.js +90 -0
  48. package/src/projects/adapters/recipe.js +13 -0
  49. package/src/projects/detect.js +187 -0
  50. package/src/projects/profile.js +175 -0
  51. package/src/projects/recent.js +50 -0
  52. package/src/projects/workspaces.js +96 -0
  53. package/src/recipes/action-runner.js +24 -0
  54. package/src/recipes/built-ins.json +417 -0
  55. package/src/recipes/engine.js +159 -0
  56. package/src/recipes/index.js +34 -0
  57. package/src/recipes/loader.js +82 -0
  58. package/src/recipes/schema.js +225 -0
  59. package/src/runner/captured.js +125 -0
  60. package/src/runner/check.js +93 -0
  61. package/src/runner/command.js +43 -0
  62. package/src/runner/parallel.js +110 -0
  63. package/src/runner/process.js +80 -0
  64. package/src/runtime/execute.js +99 -0
  65. package/src/utils/args.js +219 -0
  66. package/src/utils/interpolate.js +10 -0
@@ -0,0 +1,82 @@
1
+ // Copyright (c) 2026 bachbnt
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const builtInRecipes = require('./built-ins.json');
7
+ const { validateRecipe } = require('./schema');
8
+
9
+ function attachSource(recipe, source, baseDir) {
10
+ return { ...recipe, source, baseDir };
11
+ }
12
+
13
+ function loadBuiltInRecipes() {
14
+ return builtInRecipes.map((recipe) => attachSource(validateRecipe(recipe), 'built-in', __dirname));
15
+ }
16
+
17
+ function loadRecipeFile(file, source, fsImpl = fs) {
18
+ let parsed;
19
+ try {
20
+ parsed = JSON.parse(fsImpl.readFileSync(file, 'utf8'));
21
+ } catch (error) {
22
+ throw new Error(`Unable to read recipe ${file}: ${error.message}`);
23
+ }
24
+ const recipes = Array.isArray(parsed) ? parsed : [parsed];
25
+ return recipes.map((recipe) => {
26
+ try {
27
+ return attachSource(validateRecipe(recipe), source, path.dirname(file));
28
+ } catch (error) {
29
+ throw new Error(`Invalid recipe ${file}: ${error.message}`);
30
+ }
31
+ });
32
+ }
33
+
34
+ function loadRecipeDirectory(directory, source, options = {}) {
35
+ const fsImpl = options.fsImpl || fs;
36
+ let entries;
37
+ try {
38
+ entries = fsImpl.readdirSync(directory, { withFileTypes: true });
39
+ } catch (error) {
40
+ if (error.code === 'ENOENT') return [];
41
+ throw new Error(`Unable to read recipe directory ${directory}: ${error.message}`);
42
+ }
43
+ return entries
44
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
45
+ .sort((left, right) => left.name.localeCompare(right.name))
46
+ .flatMap((entry) => loadRecipeFile(path.join(directory, entry.name), source, fsImpl));
47
+ }
48
+
49
+ function createRegistry(recipes) {
50
+ const registry = new Map();
51
+ for (const recipe of recipes) registry.set(recipe.name, recipe);
52
+ return registry;
53
+ }
54
+
55
+ function loadRecipeRegistry(options = {}) {
56
+ const home = options.home || os.homedir();
57
+ const userDirectory = options.userDirectory || path.join(home, '.devcmd', 'recipes');
58
+ const builtIns = options.builtIns || loadBuiltInRecipes();
59
+ const userRecipes = options.includeUser === false
60
+ ? []
61
+ : loadRecipeDirectory(userDirectory, 'user', options);
62
+ return createRegistry([...builtIns, ...userRecipes]);
63
+ }
64
+
65
+ function getRecipeDefinitions(registry) {
66
+ return Object.fromEntries(
67
+ [...registry.entries()].map(([name, recipe]) => [name, {
68
+ description: recipe.description,
69
+ capabilities: recipe.capabilities || {},
70
+ source: recipe.source,
71
+ }])
72
+ );
73
+ }
74
+
75
+ module.exports = {
76
+ createRegistry,
77
+ getRecipeDefinitions,
78
+ loadBuiltInRecipes,
79
+ loadRecipeDirectory,
80
+ loadRecipeFile,
81
+ loadRecipeRegistry,
82
+ };
@@ -0,0 +1,225 @@
1
+ // Copyright (c) 2026 bachbnt
2
+
3
+ const { LIFECYCLE_COMMANDS, SYSTEM_COMMANDS, DEVICE_COMMANDS } = require('../constants');
4
+
5
+ const RECIPE_NAME = /^[a-z][a-z0-9_]*$/;
6
+ const INPUT_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
7
+ const ACTION_TYPES = new Set(['run', 'mkdir', 'write', 'copy']);
8
+ const CAPABILITIES = new Set(['language', 'packageManager', 'git', 'eslint', 'noInstall', 'python']);
9
+ const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn']);
10
+ const RESERVED_RECIPES = new Set([...LIFECYCLE_COMMANDS, ...SYSTEM_COMMANDS, ...DEVICE_COMMANDS, 'help']);
11
+ const RESERVED_INPUTS = new Set([
12
+ 'target', 'packageManager', 'typescript', 'git', 'eslint', 'vueDefault', 'noInstall',
13
+ 'python', 'venvDirectory', 'projectPython', 'pythonGenerator',
14
+ ]);
15
+
16
+ function assertPlainObject(value, label) {
17
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
18
+ throw new Error(`${label} must be an object.`);
19
+ }
20
+ }
21
+
22
+ function validateCondition(condition, label) {
23
+ assertPlainObject(condition, label);
24
+ if (!INPUT_NAME.test(condition.option || '')) {
25
+ throw new Error(`${label}.option must be a valid option name.`);
26
+ }
27
+ const keys = Object.keys(condition);
28
+ if (!keys.includes('equals') || keys.some((key) => !['option', 'equals'].includes(key))) {
29
+ throw new Error(`${label} must contain only option and equals.`);
30
+ }
31
+ }
32
+
33
+ function validateValueSpec(spec, label) {
34
+ if (typeof spec === 'string') return;
35
+ if (Array.isArray(spec)) {
36
+ spec.forEach((item, index) => validateValueSpec(item, `${label}[${index}]`));
37
+ return;
38
+ }
39
+ assertPlainObject(spec, label);
40
+ if (Object.hasOwn(spec, 'value')) {
41
+ const keys = Object.keys(spec);
42
+ if (!spec.when || keys.some((key) => !['value', 'when'].includes(key))) {
43
+ throw new Error(`${label} conditional value must contain only value and when.`);
44
+ }
45
+ validateCondition(spec.when, `${label}.when`);
46
+ validateValueSpec(spec.value, `${label}.value`);
47
+ return;
48
+ }
49
+ if (Object.hasOwn(spec, 'option')) {
50
+ const keys = Object.keys(spec);
51
+ if (!INPUT_NAME.test(spec.option || '') || !spec.map || keys.some((key) => !['option', 'map'].includes(key))) {
52
+ throw new Error(`${label} option mapping must contain only a valid option and map.`);
53
+ }
54
+ assertPlainObject(spec.map, `${label}.map`);
55
+ for (const [key, value] of Object.entries(spec.map)) {
56
+ validateValueSpec(value, `${label}.map.${key}`);
57
+ }
58
+ return;
59
+ }
60
+ throw new Error(`${label} must be a string, array, conditional value, or option mapping.`);
61
+ }
62
+
63
+ function validateAction(action, index, prefix = 'actions') {
64
+ const label = `${prefix}[${index}]`;
65
+ assertPlainObject(action, label);
66
+ if (!ACTION_TYPES.has(action.type)) throw new Error(`${label}.type is unsupported: ${action.type}`);
67
+ if (action.when) validateCondition(action.when, `${label}.when`);
68
+
69
+ if (action.type === 'run') {
70
+ const allowed = ['type', 'executable', 'args', 'cwd', 'when'];
71
+ if (Object.keys(action).some((key) => !allowed.includes(key))) {
72
+ throw new Error(`${label} contains unsupported run fields.`);
73
+ }
74
+ validateValueSpec(action.executable, `${label}.executable`);
75
+ if (!Array.isArray(action.args)) throw new Error(`${label}.args must be an array.`);
76
+ action.args.forEach((arg, argIndex) => validateValueSpec(arg, `${label}.args[${argIndex}]`));
77
+ if (action.cwd !== undefined) validateValueSpec(action.cwd, `${label}.cwd`);
78
+ return;
79
+ }
80
+
81
+ const allowedByType = {
82
+ mkdir: ['type', 'path', 'when'],
83
+ write: ['type', 'path', 'content', 'when'],
84
+ copy: ['type', 'source', 'destination', 'when'],
85
+ };
86
+ if (Object.keys(action).some((key) => !allowedByType[action.type].includes(key))) {
87
+ throw new Error(`${label} contains unsupported ${action.type} fields.`);
88
+ }
89
+ if (action.type === 'mkdir') validateValueSpec(action.path, `${label}.path`);
90
+ if (action.type === 'write') {
91
+ validateValueSpec(action.path, `${label}.path`);
92
+ validateValueSpec(action.content, `${label}.content`);
93
+ }
94
+ if (action.type === 'copy') {
95
+ validateValueSpec(action.source, `${label}.source`);
96
+ validateValueSpec(action.destination, `${label}.destination`);
97
+ }
98
+ }
99
+
100
+ function validateDetect(detect, recipeName) {
101
+ assertPlainObject(detect, `Recipe ${recipeName} detect`);
102
+ if (Object.keys(detect).some((key) => !['priority', 'rules'].includes(key))) {
103
+ throw new Error(`Recipe ${recipeName} detect contains unsupported fields.`);
104
+ }
105
+ if (detect.priority !== undefined && !Number.isInteger(detect.priority)) {
106
+ throw new Error(`Recipe ${recipeName} detect priority must be an integer.`);
107
+ }
108
+ if (!Array.isArray(detect.rules) || detect.rules.length === 0) {
109
+ throw new Error(`Recipe ${recipeName} detect rules must be a non-empty array.`);
110
+ }
111
+ detect.rules.forEach((rule, index) => {
112
+ const label = `Recipe ${recipeName} detect.rules[${index}]`;
113
+ assertPlainObject(rule, label);
114
+ if (Object.keys(rule).some((key) => !['files', 'dependencies', 'contains'].includes(key))) {
115
+ throw new Error(`${label} contains unsupported fields.`);
116
+ }
117
+ if (!rule.files && !rule.dependencies && !rule.contains) {
118
+ throw new Error(`${label} must define files, dependencies, or contains.`);
119
+ }
120
+ for (const key of ['files', 'dependencies']) {
121
+ if (rule[key] !== undefined && (
122
+ !Array.isArray(rule[key]) ||
123
+ rule[key].length === 0 ||
124
+ rule[key].some((item) => typeof item !== 'string' || !item || pathIsUnsafe(item))
125
+ )) {
126
+ throw new Error(`${label}.${key} must contain safe relative names.`);
127
+ }
128
+ }
129
+ if (rule.contains !== undefined) {
130
+ assertPlainObject(rule.contains, `${label}.contains`);
131
+ if (Object.entries(rule.contains).some(([file, text]) => pathIsUnsafe(file) || typeof text !== 'string' || !text)) {
132
+ throw new Error(`${label}.contains must map safe relative files to non-empty text.`);
133
+ }
134
+ }
135
+ });
136
+ }
137
+
138
+ function pathIsUnsafe(value) {
139
+ return value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value) || value.split(/[\\/]/).includes('..');
140
+ }
141
+
142
+ function validateLifecycle(lifecycle, recipeName) {
143
+ assertPlainObject(lifecycle, `Recipe ${recipeName} lifecycle`);
144
+ const actionNames = new Set(LIFECYCLE_COMMANDS);
145
+ if (lifecycle.adapter !== undefined) {
146
+ if (Object.keys(lifecycle).length !== 1 || !RECIPE_NAME.test(lifecycle.adapter)) {
147
+ throw new Error(`Recipe ${recipeName} lifecycle adapter must be a valid adapter name.`);
148
+ }
149
+ return;
150
+ }
151
+ if (Object.keys(lifecycle).length === 0) {
152
+ throw new Error(`Recipe ${recipeName} lifecycle must define an adapter or actions.`);
153
+ }
154
+ for (const [action, actions] of Object.entries(lifecycle)) {
155
+ if (!actionNames.has(action) || !Array.isArray(actions) || actions.length === 0) {
156
+ throw new Error(`Recipe ${recipeName} lifecycle action is unsupported: ${action}`);
157
+ }
158
+ actions.forEach((item, index) => validateAction(item, index, `lifecycle.${action}`));
159
+ }
160
+ }
161
+
162
+ function validateRecipe(recipe) {
163
+ assertPlainObject(recipe, 'Recipe');
164
+ const allowed = [
165
+ 'name', 'description', 'capabilities', 'inputs', 'requirements', 'actions', 'detect', 'lifecycle',
166
+ ];
167
+ const unknown = Object.keys(recipe).filter((key) => !allowed.includes(key));
168
+ if (unknown.length > 0) throw new Error(`Recipe contains unsupported fields: ${unknown.join(', ')}`);
169
+ if (!RECIPE_NAME.test(recipe.name || '')) {
170
+ throw new Error('Recipe name must use lowercase letters, numbers, and underscores.');
171
+ }
172
+ if (RESERVED_RECIPES.has(recipe.name)) {
173
+ throw new Error(`Recipe name is reserved by DevCmd: ${recipe.name}`);
174
+ }
175
+ if (typeof recipe.description !== 'string' || !recipe.description.trim()) {
176
+ throw new Error(`Recipe ${recipe.name} requires a description.`);
177
+ }
178
+
179
+ assertPlainObject(recipe.capabilities || {}, `Recipe ${recipe.name} capabilities`);
180
+ for (const [key, value] of Object.entries(recipe.capabilities || {})) {
181
+ if (!CAPABILITIES.has(key)) throw new Error(`Recipe ${recipe.name} has unsupported capability: ${key}`);
182
+ if (key === 'packageManager' && Array.isArray(value)) {
183
+ if (value.length === 0 || value.some((item) => !PACKAGE_MANAGERS.has(item))) {
184
+ throw new Error(`Recipe ${recipe.name} packageManager capability must list supported managers.`);
185
+ }
186
+ } else if (value !== true && value !== false) {
187
+ throw new Error(`Recipe ${recipe.name} capability ${key} must be true or false.`);
188
+ }
189
+ }
190
+
191
+ assertPlainObject(recipe.inputs || {}, `Recipe ${recipe.name} inputs`);
192
+ for (const [name, input] of Object.entries(recipe.inputs || {})) {
193
+ if (!INPUT_NAME.test(name)) throw new Error(`Recipe ${recipe.name} has invalid input name: ${name}`);
194
+ if (RESERVED_INPUTS.has(name)) throw new Error(`Recipe ${recipe.name} input is reserved: ${name}`);
195
+ assertPlainObject(input, `Recipe ${recipe.name} input ${name}`);
196
+ if (Object.keys(input).some((key) => !['description', 'required', 'default'].includes(key))) {
197
+ throw new Error(`Recipe ${recipe.name} input ${name} contains unsupported fields.`);
198
+ }
199
+ if (input.description !== undefined && typeof input.description !== 'string') {
200
+ throw new Error(`Recipe ${recipe.name} input ${name} description must be a string.`);
201
+ }
202
+ if (input.required !== undefined && typeof input.required !== 'boolean') {
203
+ throw new Error(`Recipe ${recipe.name} input ${name} required must be true or false.`);
204
+ }
205
+ if (input.default !== undefined && typeof input.default !== 'string') {
206
+ throw new Error(`Recipe ${recipe.name} input ${name} default must be a string.`);
207
+ }
208
+ }
209
+
210
+ if (!Array.isArray(recipe.requirements)) throw new Error(`Recipe ${recipe.name} requirements must be an array.`);
211
+ recipe.requirements.forEach((item, index) => validateValueSpec(item, `requirements[${index}]`));
212
+ if (!Array.isArray(recipe.actions) || recipe.actions.length === 0) {
213
+ throw new Error(`Recipe ${recipe.name} must define at least one action.`);
214
+ }
215
+ recipe.actions.forEach((action, index) => validateAction(action, index));
216
+ if (recipe.detect !== undefined) validateDetect(recipe.detect, recipe.name);
217
+ if (recipe.lifecycle !== undefined) validateLifecycle(recipe.lifecycle, recipe.name);
218
+ return recipe;
219
+ }
220
+
221
+ module.exports = {
222
+ validateAction,
223
+ validateRecipe,
224
+ validateValueSpec,
225
+ };
@@ -0,0 +1,125 @@
1
+ // Copyright (c) 2026 bachbnt
2
+
3
+ const { spawn } = require('child_process');
4
+ const { SIGNAL_EXIT_CODES } = require('./process');
5
+
6
+ function runProcessCaptured(command, options = {}) {
7
+ const spawnImpl = options.spawnImpl || spawn;
8
+ const maxOutputBytes = Number.isInteger(options.maxOutputBytes) && options.maxOutputBytes >= 0
9
+ ? options.maxOutputBytes
10
+ : Infinity;
11
+ const timeoutMs = Number.isInteger(options.timeoutMs) && options.timeoutMs > 0
12
+ ? options.timeoutMs
13
+ : 0;
14
+ return new Promise((resolve) => {
15
+ let settled = false;
16
+ let stdout = '';
17
+ let stderr = '';
18
+ let outputBytes = 0;
19
+ let outputTruncated = false;
20
+ let exitResult;
21
+ let timedOut = false;
22
+ let child;
23
+ let timer;
24
+
25
+ const finish = (result) => {
26
+ if (settled) return;
27
+ settled = true;
28
+ if (timer) clearTimeout(timer);
29
+ resolve({ ...result, stdout, stderr, outputTruncated });
30
+ };
31
+
32
+ const appendOutput = (stream, data) => {
33
+ const buffer = Buffer.isBuffer(data) ? data : Buffer.from(String(data));
34
+ const remaining = maxOutputBytes - outputBytes;
35
+ if (remaining <= 0) {
36
+ if (buffer.length > 0) outputTruncated = true;
37
+ return;
38
+ }
39
+ const chunkBuffer = buffer.length > remaining ? buffer.subarray(0, remaining) : buffer;
40
+ const chunk = chunkBuffer.toString();
41
+ if (stream === 'stdout') stdout += chunk;
42
+ else stderr += chunk;
43
+ outputBytes += chunkBuffer.length;
44
+ if (chunkBuffer.length < buffer.length) outputTruncated = true;
45
+ };
46
+
47
+ try {
48
+ const spawnOptions = { shell: false, stdio: 'pipe' };
49
+ if (command.cwd) spawnOptions.cwd = command.cwd;
50
+ child = spawnImpl(command.executable, command.args, spawnOptions);
51
+ } catch (error) {
52
+ finish({ ok: false, exitCode: error.code === 'ENOENT' ? 127 : 1, error });
53
+ return;
54
+ }
55
+
56
+ if (timeoutMs) {
57
+ timer = setTimeout(() => {
58
+ timedOut = true;
59
+ try {
60
+ if (child && !child.killed) child.kill('SIGTERM');
61
+ } catch {}
62
+ finish({
63
+ ok: false,
64
+ exitCode: SIGNAL_EXIT_CODES.SIGTERM || 143,
65
+ signal: 'SIGTERM',
66
+ timedOut: true,
67
+ });
68
+ }, timeoutMs);
69
+ }
70
+
71
+ child.stdout?.on('data', (data) => appendOutput('stdout', data));
72
+ child.stderr?.on('data', (data) => appendOutput('stderr', data));
73
+
74
+ child.once('error', (error) => {
75
+ finish({ ok: false, exitCode: error.code === 'ENOENT' ? 127 : 1, error });
76
+ });
77
+ child.once('exit', (code, signal) => {
78
+ if (signal) {
79
+ exitResult = {
80
+ ok: false,
81
+ exitCode: SIGNAL_EXIT_CODES[signal] || 1,
82
+ signal,
83
+ timedOut,
84
+ };
85
+ if (!child.stdout && !child.stderr) finish(exitResult);
86
+ return;
87
+ }
88
+ exitResult = { ok: code === 0, exitCode: code ?? 1 };
89
+ if (!child.stdout && !child.stderr) finish(exitResult);
90
+ });
91
+ child.once('close', (code, signal) => {
92
+ if (exitResult) {
93
+ finish(exitResult);
94
+ return;
95
+ }
96
+ if (signal) {
97
+ finish({ ok: false, exitCode: SIGNAL_EXIT_CODES[signal] || 1, signal, timedOut });
98
+ return;
99
+ }
100
+ finish({ ok: code === 0, exitCode: code ?? 1 });
101
+ });
102
+ });
103
+ }
104
+
105
+ async function runSequenceCaptured(commands, options = {}) {
106
+ let output = '';
107
+ let outputTruncated = false;
108
+ for (let index = 0; index < commands.length; index += 1) {
109
+ const remaining = Number.isInteger(options.maxOutputBytes) && options.maxOutputBytes >= 0
110
+ ? Math.max(options.maxOutputBytes - output.length, 0)
111
+ : undefined;
112
+ const result = await runProcessCaptured(commands[index], {
113
+ ...options,
114
+ maxOutputBytes: remaining,
115
+ });
116
+ output += result.stdout + result.stderr;
117
+ outputTruncated ||= result.outputTruncated;
118
+ if (!result.ok) {
119
+ return { ...result, output, outputTruncated, failedCommand: commands[index], failedIndex: index };
120
+ }
121
+ }
122
+ return { ok: true, exitCode: 0, output, outputTruncated };
123
+ }
124
+
125
+ module.exports = { runProcessCaptured, runSequenceCaptured };
@@ -0,0 +1,93 @@
1
+ // Copyright (c) 2026 bachbnt
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+
7
+ function findExecutable(executable, options = {}) {
8
+ const env = options.env || process.env;
9
+ const fsImpl = options.fsImpl || fs;
10
+ if (executable.includes(path.sep)) {
11
+ const candidate = path.resolve(options.cwd || process.cwd(), executable);
12
+ return fsImpl.existsSync(candidate) ? candidate : undefined;
13
+ }
14
+
15
+ const extensions = process.platform === 'win32'
16
+ ? (env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';')
17
+ : [''];
18
+ for (const directory of (env.PATH || '').split(path.delimiter).filter(Boolean)) {
19
+ for (const extension of extensions) {
20
+ const candidate = path.join(directory, `${executable}${extension}`);
21
+ try {
22
+ fsImpl.accessSync(candidate, fs.constants.X_OK);
23
+ return candidate;
24
+ } catch (error) {
25
+ // Continue searching PATH.
26
+ }
27
+ }
28
+ }
29
+ return undefined;
30
+ }
31
+
32
+ function getVersion(executable, args = ['--version'], options = {}) {
33
+ const spawnSyncImpl = options.spawnSyncImpl || spawnSync;
34
+ const result = spawnSyncImpl(executable, args, { encoding: 'utf8' });
35
+ if (result.error || result.status !== 0) return undefined;
36
+ const output = `${result.stdout || result.stderr || ''}`.trim();
37
+ try {
38
+ const data = JSON.parse(output);
39
+ return data.frameworkVersion || data.version || 'available';
40
+ } catch (error) {
41
+ return output
42
+ .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '')
43
+ .split('\n')
44
+ .map((line) => line.trim())
45
+ .find(Boolean) || 'available';
46
+ }
47
+ }
48
+
49
+ function checkTools(config, options = {}) {
50
+ const tools = [
51
+ { name: 'Node.js', executable: 'node', args: ['--version'], required: true, hint: 'Install from https://nodejs.org or run: nvm install --lts' },
52
+ { name: 'npm', executable: 'npm', args: ['--version'], required: true, hint: 'npm ships with Node.js. Reinstall Node.js.' },
53
+ { name: 'Git', executable: 'git', args: ['--version'], required: true, hint: 'macOS: xcode-select --install | Linux: sudo apt install git' },
54
+ { name: 'pnpm', executable: 'pnpm', args: ['--version'], hint: 'Run: corepack enable pnpm (or npm install --global pnpm)' },
55
+ { name: 'Yarn', executable: 'yarn', args: ['--version'], hint: 'Run: corepack enable yarn' },
56
+ { name: 'Python', executable: config.python, args: ['--version'], hint: 'macOS: brew install python | Linux: sudo apt install python3 python3-venv' },
57
+ { name: 'Docker', executable: 'docker', args: ['--version'], hint: 'Install Docker Desktop from https://docs.docker.com/get-docker/' },
58
+ { name: 'Flutter', executable: 'flutter', args: ['--version', '--machine'], hint: 'See https://docs.flutter.dev/get-started/install' },
59
+ { name: 'Android adb', executable: 'adb', args: ['version'], hint: 'Install Android Studio and add platform-tools to PATH' },
60
+ { name: 'Android emulator', executable: 'emulator', args: ['-version'], hint: 'Install Android Studio and add emulator to PATH' },
61
+ ];
62
+ if (process.platform === 'darwin') {
63
+ tools.push({ name: 'Xcode', executable: 'xcodebuild', args: ['-version'], hint: 'Install Xcode from the App Store, then: xcode-select --install' });
64
+ }
65
+
66
+ return tools.map((tool) => {
67
+ const found = findExecutable(tool.executable, options);
68
+ return {
69
+ ...tool,
70
+ found: Boolean(found),
71
+ path: found,
72
+ version: found ? getVersion(tool.executable, tool.args, options) : undefined,
73
+ };
74
+ });
75
+ }
76
+
77
+ function assertExecutables(requirements, options = {}) {
78
+ const missing = [...new Set(requirements)].filter(
79
+ (executable) => !findExecutable(executable, options)
80
+ );
81
+ if (missing.length > 0) {
82
+ throw new Error(
83
+ `Missing required executable${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}. Run dev doctor for setup details.`
84
+ );
85
+ }
86
+ }
87
+
88
+ module.exports = {
89
+ assertExecutables,
90
+ checkTools,
91
+ findExecutable,
92
+ getVersion,
93
+ };
@@ -0,0 +1,43 @@
1
+ // Copyright (c) 2026 bachbnt
2
+
3
+ function createCommand(executable, args = [], options = {}) {
4
+ const command = { executable, args: args.map(String) };
5
+ if (options.cwd) command.cwd = options.cwd;
6
+ return command;
7
+ }
8
+
9
+ function formatArgument(value) {
10
+ const text = String(value);
11
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(text)) return text;
12
+ return `'${text.replace(/'/g, `'\\''`)}'`;
13
+ }
14
+
15
+ function formatCommand(command) {
16
+ const formatted = [command.executable, ...command.args].map(formatArgument).join(' ');
17
+ return command.cwd ? `(cd ${formatArgument(command.cwd)} && ${formatted})` : formatted;
18
+ }
19
+
20
+ function formatSequence(commands) {
21
+ return commands.map(formatCommand).join(' && ');
22
+ }
23
+
24
+ function isCommand(value) {
25
+ return Boolean(
26
+ value &&
27
+ typeof value.executable === 'string' &&
28
+ Array.isArray(value.args) &&
29
+ value.args.every((arg) => typeof arg === 'string') &&
30
+ (value.cwd === undefined || typeof value.cwd === 'string')
31
+ );
32
+ }
33
+
34
+ function isCommandSequence(value) {
35
+ return Array.isArray(value) && value.length > 0 && value.every(isCommand);
36
+ }
37
+
38
+ module.exports = {
39
+ createCommand,
40
+ formatCommand,
41
+ formatSequence,
42
+ isCommandSequence,
43
+ };
@@ -0,0 +1,110 @@
1
+ // Copyright (c) 2026 bachbnt
2
+
3
+ const { spawn } = require('child_process');
4
+ const { SIGNAL_EXIT_CODES } = require('./process');
5
+
6
+ function createLinePrefixer(label, write) {
7
+ let pending = '';
8
+ return {
9
+ push(data) {
10
+ pending += data.toString();
11
+ const lines = pending.split(/\r?\n/);
12
+ pending = lines.pop();
13
+ for (const line of lines) write(`[${label}] ${line}\n`);
14
+ },
15
+ flush() {
16
+ if (pending) write(`[${label}] ${pending}\n`);
17
+ pending = '';
18
+ },
19
+ };
20
+ }
21
+
22
+ function runJob(job, context) {
23
+ const prefixer = createLinePrefixer(job.label, context.write);
24
+ return new Promise((resolve) => {
25
+ let index = 0;
26
+ const next = () => {
27
+ if (context.stopped) {
28
+ prefixer.flush();
29
+ resolve({ label: job.label, ok: false, exitCode: context.signalCode || 1 });
30
+ return;
31
+ }
32
+ if (index >= job.commands.length) {
33
+ prefixer.flush();
34
+ resolve({ label: job.label, ok: true, exitCode: 0 });
35
+ return;
36
+ }
37
+ const command = job.commands[index];
38
+ let child;
39
+ try {
40
+ const spawnOptions = { shell: false, stdio: ['ignore', 'pipe', 'pipe'] };
41
+ if (command.cwd) spawnOptions.cwd = command.cwd;
42
+ child = context.spawnImpl(command.executable, command.args, spawnOptions);
43
+ } catch (error) {
44
+ prefixer.push(`${error.message}\n`);
45
+ prefixer.flush();
46
+ resolve({ label: job.label, ok: false, exitCode: error.code === 'ENOENT' ? 127 : 1 });
47
+ return;
48
+ }
49
+ context.children.add(child);
50
+ child.stdout?.on('data', (data) => prefixer.push(data));
51
+ child.stderr?.on('data', (data) => prefixer.push(data));
52
+ let settled = false;
53
+ const done = (result) => {
54
+ if (settled) return;
55
+ settled = true;
56
+ context.children.delete(child);
57
+ if (!result.ok) {
58
+ prefixer.flush();
59
+ resolve({ label: job.label, ...result });
60
+ return;
61
+ }
62
+ index += 1;
63
+ next();
64
+ };
65
+ child.once('error', (error) => {
66
+ prefixer.push(`${error.message}\n`);
67
+ done({ ok: false, exitCode: error.code === 'ENOENT' ? 127 : 1 });
68
+ });
69
+ child.once('close', (code, signal) => {
70
+ if (signal) done({ ok: false, exitCode: SIGNAL_EXIT_CODES[signal] || 1, signal });
71
+ else done({ ok: code === 0, exitCode: code ?? 1 });
72
+ });
73
+ };
74
+ next();
75
+ });
76
+ }
77
+
78
+ async function runParallel(jobs, options = {}) {
79
+ const signalSource = options.signalSource || process;
80
+ const context = {
81
+ spawnImpl: options.spawnImpl || spawn,
82
+ write: options.write || ((text) => process.stdout.write(text)),
83
+ children: new Set(),
84
+ stopped: false,
85
+ signalCode: undefined,
86
+ };
87
+ const stop = (signal) => {
88
+ context.stopped = true;
89
+ context.signalCode = SIGNAL_EXIT_CODES[signal];
90
+ for (const child of context.children) {
91
+ try {
92
+ child.kill(signal);
93
+ } catch {}
94
+ }
95
+ };
96
+ const onSigint = () => stop('SIGINT');
97
+ const onSigterm = () => stop('SIGTERM');
98
+ signalSource.once('SIGINT', onSigint);
99
+ signalSource.once('SIGTERM', onSigterm);
100
+ try {
101
+ const results = await Promise.all(jobs.map((job) => runJob(job, context)));
102
+ const failed = results.find((result) => !result.ok);
103
+ return { ok: !failed, exitCode: failed ? failed.exitCode : 0, results };
104
+ } finally {
105
+ signalSource.removeListener('SIGINT', onSigint);
106
+ signalSource.removeListener('SIGTERM', onSigterm);
107
+ }
108
+ }
109
+
110
+ module.exports = { createLinePrefixer, runParallel };