zephyr-cli 0.0.2 → 0.1.0
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/.eslintignore +3 -0
- package/.eslintrc.json +22 -0
- package/LICENSE +40 -21
- package/README.md +91 -18
- package/dist/cli.d.ts +25 -0
- package/dist/cli.js +141 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/deploy.d.ts +12 -0
- package/dist/commands/deploy.js +60 -0
- package/dist/commands/deploy.js.map +1 -0
- package/dist/commands/run.d.ts +9 -0
- package/dist/commands/run.js +161 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/index.js +48 -60
- package/dist/index.js.map +1 -0
- package/dist/lib/build-stats.d.ts +7 -0
- package/dist/lib/build-stats.js +12 -0
- package/dist/lib/build-stats.js.map +1 -0
- package/dist/lib/command-detector.d.ts +38 -0
- package/dist/lib/command-detector.js +453 -0
- package/dist/lib/command-detector.js.map +1 -0
- package/dist/lib/config-readers.d.ts +37 -0
- package/dist/lib/config-readers.js +239 -0
- package/dist/lib/config-readers.js.map +1 -0
- package/dist/lib/extract-assets.d.ts +6 -0
- package/dist/lib/extract-assets.js +95 -0
- package/dist/lib/extract-assets.js.map +1 -0
- package/dist/lib/shell-parser.d.ts +40 -0
- package/dist/lib/shell-parser.js +190 -0
- package/dist/lib/shell-parser.js.map +1 -0
- package/dist/lib/spawn-helper.d.ts +14 -0
- package/dist/lib/spawn-helper.js +36 -0
- package/dist/lib/spawn-helper.js.map +1 -0
- package/dist/lib/upload.d.ts +14 -0
- package/dist/lib/upload.js +32 -0
- package/dist/lib/upload.js.map +1 -0
- package/dist/package.json +48 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/jest.config.ts +10 -0
- package/package.json +39 -22
- package/project.json +34 -0
- package/src/cli.ts +150 -0
- package/src/commands/deploy.ts +74 -0
- package/src/commands/run.ts +196 -0
- package/src/index.ts +58 -0
- package/src/lib/build-stats.ts +13 -0
- package/src/lib/command-detector.ts +600 -0
- package/src/lib/config-readers.ts +269 -0
- package/src/lib/extract-assets.ts +111 -0
- package/src/lib/shell-parser.ts +229 -0
- package/src/lib/spawn-helper.ts +49 -0
- package/src/lib/upload.ts +39 -0
- package/tsconfig.json +22 -0
- package/tsconfig.lib.json +10 -0
- package/tsconfig.spec.json +14 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { cosmiconfig } from 'cosmiconfig';
|
|
2
|
+
import { parse as parseJsonc } from 'jsonc-parser';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export interface PackageJsonConfig {
|
|
7
|
+
scripts?: Record<string, string>;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface TsConfigJson {
|
|
12
|
+
compilerOptions?: {
|
|
13
|
+
outDir?: string;
|
|
14
|
+
rootDir?: string;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
};
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Read and parse package.json from the given directory */
|
|
21
|
+
export function readPackageJson(cwd: string): PackageJsonConfig | null {
|
|
22
|
+
const packageJsonPath = join(cwd, 'package.json');
|
|
23
|
+
|
|
24
|
+
if (!existsSync(packageJsonPath)) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const content = readFileSync(packageJsonPath, 'utf-8');
|
|
30
|
+
return parseJsonc(content);
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Read and parse tsconfig.json from the given directory */
|
|
37
|
+
export function readTsConfig(
|
|
38
|
+
cwd: string,
|
|
39
|
+
configPath = 'tsconfig.json'
|
|
40
|
+
): TsConfigJson | null {
|
|
41
|
+
const tsConfigPath = join(cwd, configPath);
|
|
42
|
+
|
|
43
|
+
if (!existsSync(tsConfigPath)) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const content = readFileSync(tsConfigPath, 'utf-8');
|
|
49
|
+
// Parse JSON with comments support using jsonc-parser
|
|
50
|
+
return parseJsonc(content);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error(`Error reading tsconfig.json: ${error}`);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Check if a file exists and has a .js or .mjs extension */
|
|
58
|
+
export function isJavaScriptConfig(cwd: string, baseName: string): boolean {
|
|
59
|
+
const jsExtensions = ['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts'];
|
|
60
|
+
|
|
61
|
+
for (const ext of jsExtensions) {
|
|
62
|
+
const configPath = join(cwd, `${baseName}${ext}`);
|
|
63
|
+
if (existsSync(configPath)) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Check if a configuration file exists (any extension) */
|
|
72
|
+
export function configFileExists(cwd: string, baseName: string): string | null {
|
|
73
|
+
const extensions = ['.json', '.js', '.mjs', '.cjs', '.ts'];
|
|
74
|
+
|
|
75
|
+
for (const ext of extensions) {
|
|
76
|
+
const configPath = join(cwd, `${baseName}${ext}`);
|
|
77
|
+
if (existsSync(configPath)) {
|
|
78
|
+
return configPath;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Generic interface for framework configuration with output directory */
|
|
86
|
+
export interface FrameworkConfig {
|
|
87
|
+
/** The detected output directory from the config */
|
|
88
|
+
outputDir?: string | null;
|
|
89
|
+
/** The full configuration object (framework-specific) */
|
|
90
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
91
|
+
config: any;
|
|
92
|
+
/** The file path where the config was loaded from */
|
|
93
|
+
filepath: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Load Webpack configuration using cosmiconfig */
|
|
97
|
+
export async function loadWebpackConfig(cwd: string): Promise<FrameworkConfig | null> {
|
|
98
|
+
try {
|
|
99
|
+
const explorer = cosmiconfig('webpack', {
|
|
100
|
+
searchPlaces: [
|
|
101
|
+
'webpack.config.js',
|
|
102
|
+
'webpack.config.mjs',
|
|
103
|
+
'webpack.config.cjs',
|
|
104
|
+
'webpack.config.ts',
|
|
105
|
+
],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const result = await explorer.search(cwd);
|
|
109
|
+
if (!result || !result.config) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Extract output.path from webpack config
|
|
114
|
+
let outputDir: string | null = null;
|
|
115
|
+
const config = result.config;
|
|
116
|
+
|
|
117
|
+
// Handle function configs (they receive env and argv)
|
|
118
|
+
if (typeof config === 'function') {
|
|
119
|
+
try {
|
|
120
|
+
const resolvedConfig = await config({}, { mode: 'production' });
|
|
121
|
+
outputDir = resolvedConfig?.output?.path || null;
|
|
122
|
+
} catch {
|
|
123
|
+
// If function execution fails, we can't extract the output
|
|
124
|
+
outputDir = null;
|
|
125
|
+
}
|
|
126
|
+
} else if (config?.output?.path) {
|
|
127
|
+
outputDir = config.output.path;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
outputDir,
|
|
132
|
+
config: result.config,
|
|
133
|
+
filepath: result.filepath,
|
|
134
|
+
};
|
|
135
|
+
} catch {
|
|
136
|
+
// If loading fails, return null
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Load Vite configuration using cosmiconfig */
|
|
142
|
+
export async function loadViteConfig(cwd: string): Promise<FrameworkConfig | null> {
|
|
143
|
+
try {
|
|
144
|
+
const explorer = cosmiconfig('vite', {
|
|
145
|
+
searchPlaces: [
|
|
146
|
+
'vite.config.js',
|
|
147
|
+
'vite.config.mjs',
|
|
148
|
+
'vite.config.cjs',
|
|
149
|
+
'vite.config.ts',
|
|
150
|
+
],
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const result = await explorer.search(cwd);
|
|
154
|
+
if (!result || !result.config) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Extract build.outDir from vite config
|
|
159
|
+
let outputDir: string | null = null;
|
|
160
|
+
const config = result.config;
|
|
161
|
+
|
|
162
|
+
// Handle function configs (they receive config env)
|
|
163
|
+
if (typeof config === 'function') {
|
|
164
|
+
try {
|
|
165
|
+
const resolvedConfig = await config({ mode: 'production', command: 'build' });
|
|
166
|
+
outputDir = resolvedConfig?.build?.outDir || 'dist';
|
|
167
|
+
} catch {
|
|
168
|
+
outputDir = 'dist'; // Vite default
|
|
169
|
+
}
|
|
170
|
+
} else if (config?.build?.outDir) {
|
|
171
|
+
outputDir = config.build.outDir;
|
|
172
|
+
} else {
|
|
173
|
+
outputDir = 'dist'; // Vite default
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
outputDir,
|
|
178
|
+
config: result.config,
|
|
179
|
+
filepath: result.filepath,
|
|
180
|
+
};
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Load Rollup configuration using cosmiconfig */
|
|
187
|
+
export async function loadRollupConfig(cwd: string): Promise<FrameworkConfig | null> {
|
|
188
|
+
try {
|
|
189
|
+
const explorer = cosmiconfig('rollup', {
|
|
190
|
+
searchPlaces: [
|
|
191
|
+
'rollup.config.js',
|
|
192
|
+
'rollup.config.mjs',
|
|
193
|
+
'rollup.config.cjs',
|
|
194
|
+
'rollup.config.ts',
|
|
195
|
+
],
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const result = await explorer.search(cwd);
|
|
199
|
+
if (!result || !result.config) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Extract output.dir or output.file from rollup config
|
|
204
|
+
let outputDir: string | null = null;
|
|
205
|
+
const config = result.config;
|
|
206
|
+
|
|
207
|
+
// Handle function configs
|
|
208
|
+
if (typeof config === 'function') {
|
|
209
|
+
try {
|
|
210
|
+
const resolvedConfig = await config({});
|
|
211
|
+
if (Array.isArray(resolvedConfig)) {
|
|
212
|
+
// Multiple outputs - take the first one
|
|
213
|
+
outputDir =
|
|
214
|
+
resolvedConfig[0]?.output?.dir || resolvedConfig[0]?.output?.file || null;
|
|
215
|
+
} else {
|
|
216
|
+
outputDir = resolvedConfig?.output?.dir || resolvedConfig?.output?.file || null;
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
outputDir = null;
|
|
220
|
+
}
|
|
221
|
+
} else if (Array.isArray(config)) {
|
|
222
|
+
// Multiple outputs - take the first one
|
|
223
|
+
outputDir = config[0]?.output?.dir || config[0]?.output?.file || null;
|
|
224
|
+
} else if (config?.output) {
|
|
225
|
+
const output = Array.isArray(config.output) ? config.output[0] : config.output;
|
|
226
|
+
outputDir = output?.dir || output?.file || null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// If outputDir is a file path, extract the directory
|
|
230
|
+
if (outputDir && outputDir.includes('.')) {
|
|
231
|
+
const lastSlash = Math.max(outputDir.lastIndexOf('/'), outputDir.lastIndexOf('\\'));
|
|
232
|
+
if (lastSlash !== -1) {
|
|
233
|
+
outputDir = outputDir.substring(0, lastSlash);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
outputDir,
|
|
239
|
+
config: result.config,
|
|
240
|
+
filepath: result.filepath,
|
|
241
|
+
};
|
|
242
|
+
} catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Load SWC configuration using cosmiconfig */
|
|
248
|
+
export async function loadSwcConfig(cwd: string): Promise<FrameworkConfig | null> {
|
|
249
|
+
try {
|
|
250
|
+
const explorer = cosmiconfig('swc', {
|
|
251
|
+
searchPlaces: ['.swcrc', '.swcrc.json', '.swcrc.js', '.swcrc.mjs', '.swcrc.cjs'],
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const result = await explorer.search(cwd);
|
|
255
|
+
if (!result || !result.config) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// SWC doesn't have a standard output directory config
|
|
260
|
+
// Most projects use a custom output directory
|
|
261
|
+
return {
|
|
262
|
+
outputDir: null,
|
|
263
|
+
config: result.config,
|
|
264
|
+
filepath: result.filepath,
|
|
265
|
+
};
|
|
266
|
+
} catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, relative, sep } from 'node:path';
|
|
3
|
+
import { buildAssetsMap, logFn, type ZeBuildAssetsMap } from 'zephyr-agent';
|
|
4
|
+
|
|
5
|
+
interface DirectoryAsset {
|
|
6
|
+
content: Buffer | string;
|
|
7
|
+
type: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function extractBuffer(asset: DirectoryAsset): Buffer | string | undefined {
|
|
11
|
+
return asset.content;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getAssetType(asset: DirectoryAsset): string {
|
|
15
|
+
return asset.type;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Normalize path separators to forward slashes for cross-platform consistency */
|
|
19
|
+
function normalizePath(filePath: string): string {
|
|
20
|
+
return filePath.split(sep).join('/');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Extract assets map from a directory by recursively walking through it. Similar to
|
|
25
|
+
* extractAstroAssetsMap but for any directory.
|
|
26
|
+
*/
|
|
27
|
+
export async function extractAssetsFromDirectory(
|
|
28
|
+
buildDir: string
|
|
29
|
+
): Promise<ZeBuildAssetsMap> {
|
|
30
|
+
const assets: Record<string, DirectoryAsset> = {};
|
|
31
|
+
|
|
32
|
+
// Recursively walk through the build directory
|
|
33
|
+
async function walkDir(dirPath: string): Promise<void> {
|
|
34
|
+
try {
|
|
35
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
36
|
+
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
const fullPath = join(dirPath, entry.name);
|
|
39
|
+
|
|
40
|
+
if (entry.isDirectory()) {
|
|
41
|
+
await walkDir(fullPath);
|
|
42
|
+
} else if (entry.isFile()) {
|
|
43
|
+
// Get relative path from build directory
|
|
44
|
+
const relativePath = normalizePath(relative(buildDir, fullPath));
|
|
45
|
+
|
|
46
|
+
// Skip certain files that shouldn't be uploaded
|
|
47
|
+
if (shouldSkipFile(relativePath)) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const content = await readFile(fullPath);
|
|
53
|
+
const fileType = getFileType(relativePath);
|
|
54
|
+
|
|
55
|
+
assets[relativePath] = {
|
|
56
|
+
content,
|
|
57
|
+
type: fileType,
|
|
58
|
+
};
|
|
59
|
+
} catch (readError) {
|
|
60
|
+
logFn('warn', `Failed to read file ${fullPath}: ${readError}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} catch (error) {
|
|
65
|
+
logFn('warn', `Failed to walk directory ${dirPath}: ${error}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
await walkDir(buildDir);
|
|
70
|
+
|
|
71
|
+
return buildAssetsMap(assets, extractBuffer, getAssetType);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function shouldSkipFile(filePath: string): boolean {
|
|
75
|
+
// Skip common files that shouldn't be uploaded
|
|
76
|
+
const skipPatterns = [
|
|
77
|
+
/\.map$/, // Source maps
|
|
78
|
+
/node_modules/, // Node modules
|
|
79
|
+
/\.git/, // Git files
|
|
80
|
+
/\.DS_Store$/, // macOS files
|
|
81
|
+
/thumbs\.db$/i, // Windows files
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
return skipPatterns.some((pattern) => pattern.test(filePath));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getFileType(filePath: string): string {
|
|
88
|
+
const extension = filePath.split('.').pop()?.toLowerCase() || '';
|
|
89
|
+
|
|
90
|
+
const typeMap: Record<string, string> = {
|
|
91
|
+
html: 'text/html',
|
|
92
|
+
css: 'text/css',
|
|
93
|
+
js: 'application/javascript',
|
|
94
|
+
mjs: 'application/javascript',
|
|
95
|
+
json: 'application/json',
|
|
96
|
+
png: 'image/png',
|
|
97
|
+
jpg: 'image/jpeg',
|
|
98
|
+
jpeg: 'image/jpeg',
|
|
99
|
+
gif: 'image/gif',
|
|
100
|
+
svg: 'image/svg+xml',
|
|
101
|
+
ico: 'image/x-icon',
|
|
102
|
+
woff: 'font/woff',
|
|
103
|
+
woff2: 'font/woff2',
|
|
104
|
+
ttf: 'font/ttf',
|
|
105
|
+
eot: 'application/vnd.ms-fontobject',
|
|
106
|
+
xml: 'text/xml',
|
|
107
|
+
txt: 'text/plain',
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return typeMap[extension] || 'application/octet-stream';
|
|
111
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { ZeErrors, ZephyrError } from 'zephyr-agent';
|
|
2
|
+
|
|
3
|
+
export interface ParsedCommand {
|
|
4
|
+
/** The main command to execute (without environment variables) */
|
|
5
|
+
command: string;
|
|
6
|
+
/** Environment variables set for this command */
|
|
7
|
+
envVars: Record<string, string>;
|
|
8
|
+
/** Arguments passed to the command */
|
|
9
|
+
args: string[];
|
|
10
|
+
/** The full command line as a string */
|
|
11
|
+
fullCommand: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse a shell command to extract the actual command, args, and environment variables.
|
|
16
|
+
* Uses simple regex-based parsing to handle common cases like:
|
|
17
|
+
*
|
|
18
|
+
* - `pnpm build`
|
|
19
|
+
* - `NODE_ENV=production webpack --mode production`
|
|
20
|
+
* - `KEY1=value1 KEY2=value2 command arg1 arg2`
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* parseShellCommand('NODE_ENV=production webpack --mode production');
|
|
24
|
+
* // Returns: { command: 'webpack', envVars: { NODE_ENV: 'production' }, args: ['--mode', 'production'] }
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* parseShellCommand('pnpm build');
|
|
28
|
+
* // Returns: { command: 'pnpm', envVars: {}, args: ['build'] }
|
|
29
|
+
*/
|
|
30
|
+
export function parseShellCommand(commandLine: string): ParsedCommand {
|
|
31
|
+
const envVars: Record<string, string> = {};
|
|
32
|
+
const trimmed = commandLine.trim();
|
|
33
|
+
|
|
34
|
+
if (!trimmed) {
|
|
35
|
+
throw new ZephyrError(ZeErrors.ERR_UNKNOWN, {
|
|
36
|
+
message: 'Empty command line',
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Split by whitespace while respecting quotes
|
|
41
|
+
const tokens = tokenizeCommand(trimmed);
|
|
42
|
+
|
|
43
|
+
if (tokens.length === 0) {
|
|
44
|
+
throw new ZephyrError(ZeErrors.ERR_UNKNOWN, {
|
|
45
|
+
message: `Failed to parse command: ${commandLine}`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let i = 0;
|
|
50
|
+
|
|
51
|
+
// Extract environment variables (KEY=VALUE format at the beginning)
|
|
52
|
+
while (i < tokens.length) {
|
|
53
|
+
const token = tokens[i];
|
|
54
|
+
const match = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
55
|
+
|
|
56
|
+
if (match) {
|
|
57
|
+
const [, name, value] = match;
|
|
58
|
+
envVars[name] = value;
|
|
59
|
+
i++;
|
|
60
|
+
} else {
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The next token should be the command
|
|
66
|
+
if (i >= tokens.length) {
|
|
67
|
+
throw new ZephyrError(ZeErrors.ERR_UNKNOWN, {
|
|
68
|
+
message: `Failed to parse command: ${commandLine}\nNo command found after environment variables`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const command = tokens[i];
|
|
73
|
+
i++;
|
|
74
|
+
|
|
75
|
+
// Remaining tokens are arguments
|
|
76
|
+
const args = tokens.slice(i);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
command,
|
|
80
|
+
envVars,
|
|
81
|
+
args,
|
|
82
|
+
fullCommand: commandLine,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Split a command line into multiple commands based on shell operators (;, &&) Respects
|
|
88
|
+
* quotes and escapes.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* splitCommands('npm run build && npm run test');
|
|
92
|
+
* // Returns: ['npm run build', 'npm run test']
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* splitCommands('echo "hello; world" && npm run build');
|
|
96
|
+
* // Returns: ['echo "hello; world"', 'npm run build']
|
|
97
|
+
*/
|
|
98
|
+
export function splitCommands(commandLine: string): string[] {
|
|
99
|
+
const commands: string[] = [];
|
|
100
|
+
let current = '';
|
|
101
|
+
let inSingleQuote = false;
|
|
102
|
+
let inDoubleQuote = false;
|
|
103
|
+
let escaped = false;
|
|
104
|
+
|
|
105
|
+
for (let i = 0; i < commandLine.length; i++) {
|
|
106
|
+
const char = commandLine[i];
|
|
107
|
+
const nextChar = i + 1 < commandLine.length ? commandLine[i + 1] : '';
|
|
108
|
+
|
|
109
|
+
if (escaped) {
|
|
110
|
+
current += char;
|
|
111
|
+
escaped = false;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (char === '\\') {
|
|
116
|
+
current += char;
|
|
117
|
+
escaped = true;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (char === "'" && !inDoubleQuote) {
|
|
122
|
+
inSingleQuote = !inSingleQuote;
|
|
123
|
+
current += char;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (char === '"' && !inSingleQuote) {
|
|
128
|
+
inDoubleQuote = !inDoubleQuote;
|
|
129
|
+
current += char;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check for shell operators outside quotes
|
|
134
|
+
if (!inSingleQuote && !inDoubleQuote) {
|
|
135
|
+
// Check for &&
|
|
136
|
+
if (char === '&' && nextChar === '&') {
|
|
137
|
+
const trimmed = current.trim();
|
|
138
|
+
if (trimmed) {
|
|
139
|
+
commands.push(trimmed);
|
|
140
|
+
}
|
|
141
|
+
current = '';
|
|
142
|
+
i++; // Skip the next &
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Check for ;
|
|
147
|
+
if (char === ';') {
|
|
148
|
+
const trimmed = current.trim();
|
|
149
|
+
if (trimmed) {
|
|
150
|
+
commands.push(trimmed);
|
|
151
|
+
}
|
|
152
|
+
current = '';
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
current += char;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Add the last command if any
|
|
161
|
+
const trimmed = current.trim();
|
|
162
|
+
if (trimmed) {
|
|
163
|
+
commands.push(trimmed);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (inSingleQuote || inDoubleQuote) {
|
|
167
|
+
throw new ZephyrError(ZeErrors.ERR_UNKNOWN, {
|
|
168
|
+
message: 'Unmatched quote in command line',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return commands;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Tokenize a command line string, respecting quotes and escapes */
|
|
176
|
+
function tokenizeCommand(commandLine: string): string[] {
|
|
177
|
+
const tokens: string[] = [];
|
|
178
|
+
let current = '';
|
|
179
|
+
let inSingleQuote = false;
|
|
180
|
+
let inDoubleQuote = false;
|
|
181
|
+
let escaped = false;
|
|
182
|
+
|
|
183
|
+
for (let i = 0; i < commandLine.length; i++) {
|
|
184
|
+
const char = commandLine[i];
|
|
185
|
+
|
|
186
|
+
if (escaped) {
|
|
187
|
+
current += char;
|
|
188
|
+
escaped = false;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (char === '\\') {
|
|
193
|
+
escaped = true;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (char === "'" && !inDoubleQuote) {
|
|
198
|
+
inSingleQuote = !inSingleQuote;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (char === '"' && !inSingleQuote) {
|
|
203
|
+
inDoubleQuote = !inDoubleQuote;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (/\s/.test(char) && !inSingleQuote && !inDoubleQuote) {
|
|
208
|
+
if (current) {
|
|
209
|
+
tokens.push(current);
|
|
210
|
+
current = '';
|
|
211
|
+
}
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
current += char;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (current) {
|
|
219
|
+
tokens.push(current);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (inSingleQuote || inDoubleQuote) {
|
|
223
|
+
throw new ZephyrError(ZeErrors.ERR_UNKNOWN, {
|
|
224
|
+
message: 'Unmatched quote in command line',
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return tokens;
|
|
229
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import type { ParsedCommand } from './shell-parser';
|
|
3
|
+
|
|
4
|
+
export interface SpawnResult {
|
|
5
|
+
exitCode: number;
|
|
6
|
+
signal: NodeJS.Signals | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Execute a command with full stdio passthrough. All stdin, stdout, and stderr are
|
|
11
|
+
* proxied between the parent and child process.
|
|
12
|
+
*
|
|
13
|
+
* @param parsed - The parsed command to execute
|
|
14
|
+
* @param cwd - The working directory
|
|
15
|
+
* @returns Promise that resolves with exit code and signal
|
|
16
|
+
*/
|
|
17
|
+
export async function executeCommand(
|
|
18
|
+
parsed: ParsedCommand,
|
|
19
|
+
cwd: string
|
|
20
|
+
): Promise<SpawnResult> {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const { command, args, envVars } = parsed;
|
|
23
|
+
|
|
24
|
+
// Merge environment variables
|
|
25
|
+
const env = {
|
|
26
|
+
...process.env,
|
|
27
|
+
...envVars,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Spawn the command
|
|
31
|
+
const child = spawn(command, args, {
|
|
32
|
+
cwd,
|
|
33
|
+
env,
|
|
34
|
+
stdio: 'inherit', // This passes stdin, stdout, and stderr through
|
|
35
|
+
shell: true, // Use shell to handle complex commands
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
child.on('error', (error) => {
|
|
39
|
+
reject(error);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
child.on('close', (code, signal) => {
|
|
43
|
+
resolve({
|
|
44
|
+
exitCode: code ?? 1,
|
|
45
|
+
signal,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ZeBuildAssetsMap } from 'zephyr-edge-contract';
|
|
2
|
+
import type { ZephyrEngine } from 'zephyr-agent';
|
|
3
|
+
import { logFn, ZephyrError } from 'zephyr-agent';
|
|
4
|
+
import { getBuildStats } from './build-stats';
|
|
5
|
+
|
|
6
|
+
export interface UploadOptions {
|
|
7
|
+
zephyr_engine: ZephyrEngine;
|
|
8
|
+
assetsMap: ZeBuildAssetsMap;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Orchestrate the upload process:
|
|
13
|
+
*
|
|
14
|
+
* 1. Start a new build
|
|
15
|
+
* 2. Upload assets with build stats
|
|
16
|
+
* 3. Finish the build
|
|
17
|
+
*/
|
|
18
|
+
export async function uploadAssets(options: UploadOptions): Promise<void> {
|
|
19
|
+
const { zephyr_engine, assetsMap } = options;
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
// Start a new build
|
|
23
|
+
await zephyr_engine.start_new_build();
|
|
24
|
+
|
|
25
|
+
// Generate build stats
|
|
26
|
+
const buildStats = await getBuildStats(zephyr_engine);
|
|
27
|
+
|
|
28
|
+
// Upload assets and finish the build
|
|
29
|
+
await zephyr_engine.upload_assets({
|
|
30
|
+
assetsMap,
|
|
31
|
+
buildStats,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
await zephyr_engine.build_finished();
|
|
35
|
+
} catch (error) {
|
|
36
|
+
logFn('error', ZephyrError.format(error));
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|