buildx-cli 1.0.8 → 1.0.10

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.
@@ -0,0 +1,703 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import ora from "ora";
4
+ import fs from "fs-extra";
5
+ import path from "path";
6
+ import { configManager } from "../config/index";
7
+ import { apiService } from "../services/api";
8
+ import { loadEnvConfig } from "../utils/env";
9
+ import { matchByFilters, parseFilters, readJsonIfExists, sanitizeFileName, sha256, toAbsolutePath } from "../utils/sync";
10
+
11
+ type FunctionManifestEntry = {
12
+ name: string;
13
+ file: string;
14
+ remoteChecksum?: string;
15
+ localChecksum: string;
16
+ metadata: {
17
+ group_path?: string;
18
+ compatible?: ("api" | "flow")[];
19
+ collection_id?: string;
20
+ allow_anonymous?: boolean;
21
+ allow_method?: string[];
22
+ input_schema?: string;
23
+ output_schema?: string;
24
+ output_format?: {
25
+ format: "json" | "csv" | "xml" | "xlsx" | "text";
26
+ as_attachment?: boolean;
27
+ filename?: string;
28
+ content_type?: string;
29
+ };
30
+ };
31
+ };
32
+
33
+ type FunctionManifest = {
34
+ version: 1;
35
+ projectId: string;
36
+ generatedAt: string;
37
+ context: {
38
+ file: string;
39
+ checksum?: string;
40
+ version?: number;
41
+ };
42
+ functions: FunctionManifestEntry[];
43
+ };
44
+
45
+ type FunctionCodeValidationResult = {
46
+ valid: boolean;
47
+ reason?: string;
48
+ };
49
+
50
+ type FunctionCodeLintResult = {
51
+ valid: boolean;
52
+ reason?: string;
53
+ };
54
+
55
+ const ASYNC_ARROW_FUNCTION_REGEX = /^\s*async\s*\(\s*[A-Za-z_$][\w$]*(?:\s*:[^,]+)?\s*,\s*[A-Za-z_$][\w$]*(?:\s*:[^)]+)?\s*\)\s*=>\s*\{([\s\S]*)\}\s*;?\s*$/;
56
+ const FUNCTION_DECLARATION_REGEX = /^\s*(?:async\s+)?function(?:\s+[A-Za-z_$][\w$]*)?\s*\(\s*[A-Za-z_$][\w$]*(?:\s*:[^,]+)?\s*,\s*[A-Za-z_$][\w$]*(?:\s*:[^)]+)?\s*\)\s*\{[\s\S]*\}\s*;?\s*$/;
57
+
58
+ function applyFunctionPullOptions(command: Command): Command {
59
+ return command
60
+ .option("-p, --project-id <project-id>", "Project ID to sync functions from (uses default if not specified)")
61
+ .option("-t, --target-dir <path>", "Base target directory for function artifacts")
62
+ .option("--context-output <path>", "Output path for function context schema", "./buildx/generated/function-context.d.ts")
63
+ .option("--functions-dir <path>", "Directory to store function source files", "./buildx/functions")
64
+ .option("-u, --api-url <url>", "Custom API base URL")
65
+ .option("--filter <pattern...>", "Only include functions matching wildcard pattern(s)")
66
+ .option("--full-context", "Write full context schema from API (default writes compact schema)")
67
+ .option("--module-wrap", "Wrap function code into ESM module format (experimental)")
68
+ .option("--no-validate-code", "Skip runtime compatibility validation")
69
+ .option("--no-lint", "Skip basic syntax lint checks")
70
+ .option("--dry-run", "Preview sync result without writing local files")
71
+ .option("-f, --force", "Force overwrite existing local files");
72
+ }
73
+
74
+ function printDryRunFilePreview(filePath: string, content: string): void {
75
+ console.log(chalk.cyan(`\n--- DRY RUN: ${filePath} ---`));
76
+ console.log(content);
77
+ console.log(chalk.cyan(`--- END DRY RUN: ${filePath} ---\n`));
78
+ }
79
+
80
+ function ensureConfigured(): void {
81
+ if (!apiService.isConfigured()) {
82
+ console.error(chalk.red("❌ API not configured"));
83
+ console.log(chalk.yellow("Please configure your API endpoint and API key first:"));
84
+ console.log(chalk.cyan(" buildx config:setup"));
85
+ process.exit(1);
86
+ }
87
+
88
+ if (!configManager.isAuthenticated()) {
89
+ console.error(chalk.red("Error: Not authenticated"));
90
+ console.log(chalk.yellow("Run \"buildx auth:login\" to authenticate first"));
91
+ process.exit(1);
92
+ }
93
+ }
94
+
95
+ function resolveProjectId(projectId?: string): string {
96
+ if (projectId) return projectId;
97
+
98
+ const defaultProjectId = configManager.getDefaultProject();
99
+ if (defaultProjectId) {
100
+ console.log(chalk.blue("Using project ID:"), defaultProjectId);
101
+ return defaultProjectId;
102
+ }
103
+
104
+ const envConfig = loadEnvConfig();
105
+ if (envConfig.BUILDX_PROJECT_ID) {
106
+ console.log(chalk.blue("Using project ID from environment variable:"), envConfig.BUILDX_PROJECT_ID);
107
+ return envConfig.BUILDX_PROJECT_ID;
108
+ }
109
+
110
+ console.error(chalk.red("Error: No project ID specified"));
111
+ console.log(chalk.yellow("Use --project-id option, set a default project with 'buildx projects:set-default', or set BUILDX_PROJECT_ID environment variable"));
112
+ process.exit(1);
113
+ }
114
+
115
+ function toFunctionFilename(functionName: string): string {
116
+ return `${sanitizeFileName(functionName)}.ts`;
117
+ }
118
+
119
+ function getFunctionManifestPath(functionsDirPath: string): string {
120
+ return path.join(functionsDirPath, "functions.manifest.json");
121
+ }
122
+
123
+ function filterFunctions(functions: any[], filters: string[]): any[] {
124
+ if (!filters.length) return functions;
125
+ return functions.filter((fn) => matchByFilters(String(fn.name || ""), filters));
126
+ }
127
+
128
+ function ensureFlowDataType(schema: string): string {
129
+ if (/\btype\s+FlowData\b/.test(schema) || /\binterface\s+FlowData\b/.test(schema)) {
130
+ return schema;
131
+ }
132
+ const trimmed = schema.trimEnd();
133
+ return `${trimmed}\n\nexport type FlowData = Record<string, any>;\n`;
134
+ }
135
+
136
+ function buildCompactContextSchema(rawSchema: string): string {
137
+ const lines = [
138
+ "// Auto-generated by buildx-cli (compact mode).",
139
+ "// Use --full-context to keep the full remote schema.",
140
+ "",
141
+ "export type FlowData = Record<string, any>;",
142
+ "export type FlowContext = Record<string, any>;",
143
+ "",
144
+ "export interface CodeContext {",
145
+ "\tcontext: FlowContext;",
146
+ "\tdata: FlowData;",
147
+ "\t[key: string]: any;",
148
+ "}",
149
+ ""
150
+ ];
151
+
152
+ // Keep high-signal service aliases if they exist in upstream schema.
153
+ if (rawSchema.includes("type LODASH_TYPE")) {
154
+ lines.splice(3, 0, "type LODASH_TYPE = any;");
155
+ }
156
+ if (rawSchema.includes("type URL_SEARCH_PARAMS_TYPE")) {
157
+ lines.splice(3, 0, "type URL_SEARCH_PARAMS_TYPE = any;");
158
+ }
159
+
160
+ return lines.join("\n");
161
+ }
162
+
163
+ function toImportPathWithoutExtension(fromFilePath: string, toFilePath: string): string {
164
+ const relativePath = path.relative(path.dirname(fromFilePath), toFilePath);
165
+ const normalized = relativePath.split(path.sep).join("/");
166
+ const withoutExt = normalized.replace(/\.[^.]+$/, "");
167
+ if (withoutExt.startsWith(".")) return withoutExt;
168
+ return `./${withoutExt}`;
169
+ }
170
+
171
+ function wrapArrowFunctionSource(code: string, contextImportPath: string): string {
172
+ return [
173
+ `import type { FlowContext, FlowData } from "${contextImportPath}";`,
174
+ "",
175
+ `const handler = ${code.trim()};`,
176
+ "",
177
+ "export default handler;",
178
+ ""
179
+ ].join("\n");
180
+ }
181
+
182
+ function normalizeFunctionSource(code: string, contextImportPath: string): string {
183
+ const trimmed = code.trim();
184
+ if (!trimmed) {
185
+ return [
186
+ `import type { FlowContext, FlowData } from "${contextImportPath}";`,
187
+ "",
188
+ "const handler = async (data: FlowData, context: FlowContext) => {",
189
+ "\treturn { data, context };",
190
+ "};",
191
+ "",
192
+ "export default handler;",
193
+ ""
194
+ ].join("\n");
195
+ }
196
+
197
+ const hasModuleShape = /\bexport\s+default\b/.test(trimmed) || /\bmodule\.exports\b/.test(trimmed);
198
+ if (hasModuleShape) {
199
+ return `${trimmed}\n`;
200
+ }
201
+
202
+ const looksLikeArrowExpression = /^(async\s*)?\([\s\S]*\)\s*=>\s*[\s\S]+$/.test(trimmed);
203
+ if (looksLikeArrowExpression) {
204
+ return wrapArrowFunctionSource(trimmed, contextImportPath);
205
+ }
206
+
207
+ return `${trimmed}\n`;
208
+ }
209
+
210
+ function preserveFunctionSource(code: string): string {
211
+ const trimmed = code.trim();
212
+ return trimmed ? `${trimmed}\n` : "";
213
+ }
214
+
215
+ function compactFunctionMetadata(detail: any): FunctionManifestEntry["metadata"] {
216
+ const metadata: FunctionManifestEntry["metadata"] = {};
217
+ if (detail.group_path) metadata.group_path = detail.group_path;
218
+ if (Array.isArray(detail.compatible) && detail.compatible.length > 0) metadata.compatible = detail.compatible;
219
+ if (detail.collection_id) metadata.collection_id = detail.collection_id;
220
+ if (typeof detail.allow_anonymous === "boolean") metadata.allow_anonymous = detail.allow_anonymous;
221
+ if (Array.isArray(detail.allow_method) && detail.allow_method.length > 0) metadata.allow_method = detail.allow_method;
222
+ if (detail.input_schema && detail.input_schema !== "{}") metadata.input_schema = detail.input_schema;
223
+ if (detail.output_schema && detail.output_schema !== "{}") metadata.output_schema = detail.output_schema;
224
+ if (detail.output_format && typeof detail.output_format === "object") {
225
+ metadata.output_format = detail.output_format;
226
+ }
227
+ return metadata;
228
+ }
229
+
230
+ function validateFunctionRuntimeCode(code: string): FunctionCodeValidationResult {
231
+ const trimmed = String(code || "").trim();
232
+ if (!trimmed) {
233
+ return { valid: false, reason: "code is empty" };
234
+ }
235
+
236
+ if (FUNCTION_DECLARATION_REGEX.test(trimmed)) {
237
+ return { valid: true };
238
+ }
239
+
240
+ const arrowMatch = trimmed.match(ASYNC_ARROW_FUNCTION_REGEX);
241
+ if (arrowMatch) {
242
+ const body = (arrowMatch[1] || "").trim();
243
+ if (ASYNC_ARROW_FUNCTION_REGEX.test(body)) {
244
+ return { valid: false, reason: "nested async wrapper detected (double-wrapped function body)" };
245
+ }
246
+ return { valid: true };
247
+ }
248
+
249
+ // TODO(buildx-export-default): add optional export default runtime mode in datastore/runtime and accept ESM handler shape here.
250
+ return {
251
+ valid: false,
252
+ reason: "expected runtime wrapper function, e.g. async (data, context) => { ... }"
253
+ };
254
+ }
255
+
256
+ function stripTypeFromParam(param: string): string {
257
+ const withoutDefault = param.trim();
258
+ const colonIndex = withoutDefault.indexOf(":");
259
+ const left = colonIndex >= 0 ? withoutDefault.slice(0, colonIndex).trim() : withoutDefault;
260
+ return left.replace(/\?/g, "");
261
+ }
262
+
263
+ function toLintableRuntimeCode(code: string): string {
264
+ const trimmed = String(code || "").trim();
265
+ if (!trimmed) return trimmed;
266
+
267
+ const arrowSignature = trimmed.match(/^(\s*async\s*)\(([\s\S]*?)\)(\s*=>\s*\{[\s\S]*\}\s*;?\s*)$/);
268
+ if (arrowSignature) {
269
+ const params = arrowSignature[2]
270
+ .split(",")
271
+ .map((p) => stripTypeFromParam(p))
272
+ .join(", ");
273
+ return `${arrowSignature[1]}(${params})${arrowSignature[3]}`;
274
+ }
275
+
276
+ const fnSignature = trimmed.match(/^(\s*(?:async\s+)?function(?:\s+[A-Za-z_$][\w$]*)?\s*)\(([\s\S]*?)\)(\s*\{[\s\S]*\}\s*;?\s*)$/);
277
+ if (fnSignature) {
278
+ const params = fnSignature[2]
279
+ .split(",")
280
+ .map((p) => stripTypeFromParam(p))
281
+ .join(", ");
282
+ return `${fnSignature[1]}(${params})${fnSignature[3]}`;
283
+ }
284
+
285
+ return trimmed;
286
+ }
287
+
288
+ function lintFunctionRuntimeCode(code: string): FunctionCodeLintResult {
289
+ const trimmed = String(code || "").trim();
290
+ if (!trimmed) {
291
+ return { valid: false, reason: "code is empty" };
292
+ }
293
+
294
+ try {
295
+ const jsLikeCode = toLintableRuntimeCode(trimmed);
296
+ if (jsLikeCode.startsWith("async")) {
297
+ // Arrow function expression
298
+ void new Function(`return (${jsLikeCode});`)();
299
+ } else {
300
+ // Function declaration shape
301
+ void new Function(`${jsLikeCode}; return true;`)();
302
+ }
303
+ return { valid: true };
304
+ } catch (error) {
305
+ return {
306
+ valid: false,
307
+ reason: `syntax check failed (${error instanceof Error ? error.message : "unknown error"})`
308
+ };
309
+ }
310
+ }
311
+
312
+ async function runFunctionPull(options: any): Promise<void> {
313
+ ensureConfigured();
314
+ const projectId = resolveProjectId(options.projectId);
315
+ if (options.apiUrl) {
316
+ apiService.setBaseUrl(options.apiUrl);
317
+ }
318
+
319
+ const targetDirPath = options.targetDir ? toAbsolutePath(options.targetDir) : null;
320
+ const contextOutputPath = targetDirPath
321
+ ? path.join(targetDirPath, "function-context.d.ts")
322
+ : toAbsolutePath(options.contextOutput);
323
+ const functionsDirPath = targetDirPath
324
+ ? path.join(targetDirPath, "functions")
325
+ : toAbsolutePath(options.functionsDir);
326
+ const manifestPath = getFunctionManifestPath(functionsDirPath);
327
+ const previousManifest = await readJsonIfExists<FunctionManifest>(manifestPath);
328
+
329
+ const contextSpinner = ora("Fetching function context schema...").start();
330
+ const contextInfo = await apiService.getFunctionContextSchemaInfo(projectId);
331
+ const contextSchema = options.fullContext
332
+ ? ensureFlowDataType(contextInfo.schema)
333
+ : buildCompactContextSchema(contextInfo.schema);
334
+ contextSpinner.succeed("Function context schema fetched");
335
+
336
+ await fs.ensureDir(path.dirname(contextOutputPath));
337
+ if ((await fs.pathExists(contextOutputPath)) && !options.force) {
338
+ const existingContext = await fs.readFile(contextOutputPath, "utf8");
339
+ if (sha256(existingContext) !== sha256(contextSchema)) {
340
+ throw new Error(`Context file has local edits: ${contextOutputPath}. Use --force to overwrite.`);
341
+ }
342
+ }
343
+ if (options.dryRun) {
344
+ console.log(chalk.cyan(`~ would write function context schema: ${contextOutputPath}`));
345
+ printDryRunFilePreview(contextOutputPath, contextSchema);
346
+ } else {
347
+ await fs.writeFile(contextOutputPath, contextSchema, "utf8");
348
+ }
349
+
350
+ await fs.ensureDir(functionsDirPath);
351
+ const listSpinner = ora("Fetching function list...").start();
352
+ const filters = parseFilters(options.filter);
353
+ const functions = filterFunctions(await apiService.listFunctions(projectId), filters);
354
+ listSpinner.succeed(`Fetched ${functions.length} functions`);
355
+
356
+ const entries: FunctionManifestEntry[] = [];
357
+ for (const fn of functions) {
358
+ const name = String(fn.name || "");
359
+ if (!name) continue;
360
+
361
+ const detail = await apiService.getFunction(projectId, name);
362
+ const filename = toFunctionFilename(name);
363
+ const filePath = path.join(functionsDirPath, filename);
364
+ const contextImportPath = toImportPathWithoutExtension(filePath, contextOutputPath);
365
+ const sourceCode = String(detail.code || "");
366
+ if (options.validateCode) {
367
+ const validation = validateFunctionRuntimeCode(sourceCode);
368
+ if (!validation.valid) {
369
+ console.log(chalk.yellow(`⚠ ${name}: runtime validation warning (${validation.reason})`));
370
+ }
371
+ }
372
+ if (options.lint) {
373
+ const lintResult = lintFunctionRuntimeCode(sourceCode);
374
+ if (!lintResult.valid) {
375
+ console.log(chalk.yellow(`⚠ ${name}: lint warning (${lintResult.reason})`));
376
+ }
377
+ }
378
+ const code = options.moduleWrap
379
+ ? normalizeFunctionSource(sourceCode, contextImportPath)
380
+ : preserveFunctionSource(sourceCode);
381
+ const localChecksum = sha256(code);
382
+
383
+ if ((await fs.pathExists(filePath)) && !options.force) {
384
+ const previousEntry = previousManifest?.functions?.find((entry) => entry.name === name);
385
+ const existingCode = await fs.readFile(filePath, "utf8");
386
+ const existingChecksum = sha256(existingCode);
387
+ if (previousEntry && existingChecksum !== previousEntry.localChecksum) {
388
+ console.log(chalk.yellow(`⚠ Skipped ${name}: local file changed (use --force to overwrite)`));
389
+ continue;
390
+ }
391
+ }
392
+
393
+ if (options.dryRun) {
394
+ console.log(chalk.cyan(`~ would write function source: ${filePath}`));
395
+ printDryRunFilePreview(filePath, code);
396
+ } else {
397
+ await fs.writeFile(filePath, code, "utf8");
398
+ }
399
+ entries.push({
400
+ name,
401
+ file: filename,
402
+ remoteChecksum: detail.checksum,
403
+ localChecksum,
404
+ metadata: compactFunctionMetadata(detail)
405
+ });
406
+ }
407
+
408
+ const manifest: FunctionManifest = {
409
+ version: 1,
410
+ projectId,
411
+ generatedAt: new Date().toISOString(),
412
+ context: {
413
+ file: path.relative(path.dirname(manifestPath), contextOutputPath),
414
+ checksum: contextInfo.checksum,
415
+ version: contextInfo.version
416
+ },
417
+ functions: entries.sort((a, b) => a.name.localeCompare(b.name))
418
+ };
419
+
420
+ if (options.dryRun) {
421
+ console.log(chalk.cyan(`~ would write function manifest: ${manifestPath}`));
422
+ console.log(chalk.blue("Functions count:"), entries.length);
423
+ printDryRunFilePreview(manifestPath, JSON.stringify(manifest, null, 2));
424
+ } else {
425
+ await fs.writeJson(manifestPath, manifest, { spaces: 2 });
426
+ console.log(chalk.green("✓ Function context schema:"), contextOutputPath);
427
+ console.log(chalk.green("✓ Function sources:"), functionsDirPath);
428
+ console.log(chalk.green("✓ Function manifest:"), manifestPath);
429
+ }
430
+ }
431
+
432
+ export const functionSyncCommand = applyFunctionPullOptions(
433
+ new Command("function:sync")
434
+ .description("Deprecated alias of function:pull (kept for compatibility)")
435
+ ).action(async (options) => {
436
+ try {
437
+ console.log(chalk.yellow("function:sync is deprecated. Use function:pull."));
438
+ await runFunctionPull(options);
439
+ } catch (error) {
440
+ console.error(chalk.red("Error:"), error instanceof Error ? error.message : "Unknown error");
441
+ process.exit(1);
442
+ }
443
+ });
444
+
445
+ export const functionPullCommand = applyFunctionPullOptions(
446
+ new Command("function:pull")
447
+ .description("Pull function context type + function source files into local project")
448
+ ).action(async (options) => {
449
+ try {
450
+ await runFunctionPull(options);
451
+ } catch (error) {
452
+ console.error(chalk.red("Error:"), error instanceof Error ? error.message : "Unknown error");
453
+ process.exit(1);
454
+ }
455
+ });
456
+
457
+ export const functionPushCommand = new Command("function:push")
458
+ .description("Push local function source files back to project")
459
+ .option("-p, --project-id <project-id>", "Project ID to push functions to (uses default if not specified)")
460
+ .option("-t, --target-dir <path>", "Base target directory for function artifacts")
461
+ .option("--functions-dir <path>", "Directory containing function source files", "./buildx/functions")
462
+ .option("-n, --name <function-name>", "Push only one function by name")
463
+ .option("-u, --api-url <url>", "Custom API base URL")
464
+ .option("--filter <pattern...>", "Only push functions matching wildcard pattern(s)")
465
+ .option("--no-validate-code", "Skip runtime compatibility validation before push")
466
+ .option("--no-lint", "Skip basic syntax lint checks before push")
467
+ .option("--dry-run", "Show what would be pushed")
468
+ .option("-f, --force", "Force push even when remote drift is detected")
469
+ .action(async (options) => {
470
+ try {
471
+ ensureConfigured();
472
+ const projectId = resolveProjectId(options.projectId);
473
+ if (options.apiUrl) {
474
+ apiService.setBaseUrl(options.apiUrl);
475
+ }
476
+
477
+ const targetDirPath = options.targetDir ? toAbsolutePath(options.targetDir) : null;
478
+ const functionsDirPath = targetDirPath
479
+ ? path.join(targetDirPath, "functions")
480
+ : toAbsolutePath(options.functionsDir);
481
+ const manifestPath = getFunctionManifestPath(functionsDirPath);
482
+ const manifest = await readJsonIfExists<FunctionManifest>(manifestPath);
483
+ if (!manifest) {
484
+ throw new Error(`Function manifest not found: ${manifestPath}. Run function:sync first.`);
485
+ }
486
+
487
+ const filters = parseFilters(options.filter);
488
+ const filteredFromManifest = filterFunctions(manifest.functions, filters);
489
+ const targets = options.name
490
+ ? filteredFromManifest.filter((entry) => entry.name === options.name)
491
+ : filteredFromManifest;
492
+
493
+ if (targets.length === 0) {
494
+ throw new Error(options.name ? `Function "${options.name}" not found in manifest` : "No functions in manifest");
495
+ }
496
+
497
+ if (options.validateCode) {
498
+ const invalidEntries: string[] = [];
499
+ for (const entry of targets) {
500
+ const filePath = path.join(functionsDirPath, entry.file);
501
+ if (!(await fs.pathExists(filePath))) continue;
502
+ const code = await fs.readFile(filePath, "utf8");
503
+ const validation = validateFunctionRuntimeCode(code);
504
+ if (!validation.valid) {
505
+ invalidEntries.push(`${entry.name}: ${validation.reason}`);
506
+ }
507
+ }
508
+ if (invalidEntries.length > 0) {
509
+ throw new Error(
510
+ `Function code validation failed:\n- ${invalidEntries.join("\n- ")}\nUse --no-validate-code to bypass.`
511
+ );
512
+ }
513
+ }
514
+ if (options.lint) {
515
+ const lintErrors: string[] = [];
516
+ for (const entry of targets) {
517
+ const filePath = path.join(functionsDirPath, entry.file);
518
+ if (!(await fs.pathExists(filePath))) continue;
519
+ const code = await fs.readFile(filePath, "utf8");
520
+ const lintResult = lintFunctionRuntimeCode(code);
521
+ if (!lintResult.valid) {
522
+ lintErrors.push(`${entry.name}: ${lintResult.reason}`);
523
+ }
524
+ }
525
+ if (lintErrors.length > 0) {
526
+ throw new Error(
527
+ `Function lint failed:\n- ${lintErrors.join("\n- ")}\nUse --no-lint to bypass.`
528
+ );
529
+ }
530
+ }
531
+
532
+ let pushed = 0;
533
+ let skipped = 0;
534
+ const updatedEntries: FunctionManifestEntry[] = [];
535
+
536
+ for (const entry of manifest.functions) {
537
+ const target = targets.find((item) => item.name === entry.name);
538
+ if (!target) {
539
+ updatedEntries.push(entry);
540
+ continue;
541
+ }
542
+
543
+ const filePath = path.join(functionsDirPath, entry.file);
544
+ if (!(await fs.pathExists(filePath))) {
545
+ console.log(chalk.yellow(`⚠ Skipped ${entry.name}: source file missing (${filePath})`));
546
+ skipped++;
547
+ updatedEntries.push(entry);
548
+ continue;
549
+ }
550
+
551
+ const code = await fs.readFile(filePath, "utf8");
552
+ const localChecksum = sha256(code);
553
+
554
+ const remote = await apiService.getFunction(projectId, entry.name).catch(() => null);
555
+ const remoteChecksum = remote?.checksum;
556
+ const remoteDrifted = !!(entry.remoteChecksum && remoteChecksum && remoteChecksum !== entry.remoteChecksum);
557
+ if (remoteDrifted && !options.force) {
558
+ console.log(chalk.yellow(`⚠ Skipped ${entry.name}: remote changed since last sync (use --force to override)`));
559
+ skipped++;
560
+ updatedEntries.push(entry);
561
+ continue;
562
+ }
563
+
564
+ if (options.dryRun) {
565
+ console.log(chalk.cyan(`~ would push ${entry.name}`));
566
+ pushed++;
567
+ updatedEntries.push({
568
+ ...entry,
569
+ localChecksum
570
+ });
571
+ continue;
572
+ }
573
+
574
+ const payload = {
575
+ ...entry.metadata,
576
+ code
577
+ };
578
+ const saved = await apiService.upsertFunction(projectId, entry.name, payload);
579
+ const nextEntry: FunctionManifestEntry = {
580
+ ...entry,
581
+ remoteChecksum: saved?.checksum || remoteChecksum,
582
+ localChecksum
583
+ };
584
+ updatedEntries.push(nextEntry);
585
+ console.log(chalk.green(`✓ pushed ${entry.name}`));
586
+ pushed++;
587
+ }
588
+
589
+ manifest.generatedAt = new Date().toISOString();
590
+ manifest.functions = updatedEntries.sort((a, b) => a.name.localeCompare(b.name));
591
+ await fs.writeJson(manifestPath, manifest, { spaces: 2 });
592
+
593
+ console.log(chalk.blue(`Done. pushed=${pushed}, skipped=${skipped}`));
594
+ } catch (error) {
595
+ console.error(chalk.red("Error:"), error instanceof Error ? error.message : "Unknown error");
596
+ process.exit(1);
597
+ }
598
+ });
599
+
600
+ export const functionListCommand = new Command("function:list")
601
+ .description("List remote functions in project")
602
+ .option("-p, --project-id <project-id>", "Project ID (uses default if not specified)")
603
+ .option("-u, --api-url <url>", "Custom API base URL")
604
+ .option("--filter <pattern...>", "Filter function names by wildcard pattern(s)")
605
+ .action(async (options) => {
606
+ try {
607
+ ensureConfigured();
608
+ const projectId = resolveProjectId(options.projectId);
609
+ if (options.apiUrl) {
610
+ apiService.setBaseUrl(options.apiUrl);
611
+ }
612
+ const filters = parseFilters(options.filter);
613
+ const functions = filterFunctions(await apiService.listFunctions(projectId), filters)
614
+ .sort((a, b) => String(a.name || "").localeCompare(String(b.name || "")));
615
+ for (const fn of functions) {
616
+ console.log(fn.name);
617
+ }
618
+ console.log(chalk.blue(`Total: ${functions.length}`));
619
+ } catch (error) {
620
+ console.error(chalk.red("Error:"), error instanceof Error ? error.message : "Unknown error");
621
+ process.exit(1);
622
+ }
623
+ });
624
+
625
+ export const functionDiffCommand = new Command("function:diff")
626
+ .description("Diff local function sources against remote project")
627
+ .option("-p, --project-id <project-id>", "Project ID (uses default if not specified)")
628
+ .option("-t, --target-dir <path>", "Base target directory for function artifacts")
629
+ .option("--functions-dir <path>", "Directory containing function source files", "./buildx/functions")
630
+ .option("-u, --api-url <url>", "Custom API base URL")
631
+ .option("-n, --name <function-name>", "Diff only one function by name")
632
+ .option("--filter <pattern...>", "Filter function names by wildcard pattern(s)")
633
+ .action(async (options) => {
634
+ try {
635
+ ensureConfigured();
636
+ const projectId = resolveProjectId(options.projectId);
637
+ if (options.apiUrl) {
638
+ apiService.setBaseUrl(options.apiUrl);
639
+ }
640
+
641
+ const targetDirPath = options.targetDir ? toAbsolutePath(options.targetDir) : null;
642
+ const functionsDirPath = targetDirPath
643
+ ? path.join(targetDirPath, "functions")
644
+ : toAbsolutePath(options.functionsDir);
645
+ const manifestPath = getFunctionManifestPath(functionsDirPath);
646
+ const manifest = await readJsonIfExists<FunctionManifest>(manifestPath);
647
+ if (!manifest) {
648
+ throw new Error(`Function manifest not found: ${manifestPath}. Run function:sync first.`);
649
+ }
650
+
651
+ const filters = parseFilters(options.filter);
652
+ let targets = filterFunctions(manifest.functions, filters);
653
+ if (options.name) {
654
+ targets = targets.filter((entry) => entry.name === options.name);
655
+ }
656
+ if (!targets.length) {
657
+ throw new Error("No functions matched filters/name");
658
+ }
659
+
660
+ let same = 0;
661
+ let changed = 0;
662
+ let missingLocal = 0;
663
+ let missingRemote = 0;
664
+ let remoteDrift = 0;
665
+
666
+ for (const entry of targets) {
667
+ const filePath = path.join(functionsDirPath, entry.file);
668
+ if (!(await fs.pathExists(filePath))) {
669
+ console.log(chalk.yellow(`+ local-missing ${entry.name}`));
670
+ missingLocal++;
671
+ continue;
672
+ }
673
+ const localCode = await fs.readFile(filePath, "utf8");
674
+ const localChecksum = sha256(localCode);
675
+
676
+ const remote = await apiService.getFunction(projectId, entry.name).catch(() => null);
677
+ if (!remote) {
678
+ console.log(chalk.yellow(`- remote-missing ${entry.name}`));
679
+ missingRemote++;
680
+ continue;
681
+ }
682
+
683
+ if (entry.remoteChecksum && remote.checksum && entry.remoteChecksum !== remote.checksum) {
684
+ console.log(chalk.yellow(`! remote-drift ${entry.name}`));
685
+ remoteDrift++;
686
+ }
687
+
688
+ const remoteChecksum = remote.checksum || sha256(String(remote.code || ""));
689
+ if (localChecksum === remoteChecksum) {
690
+ console.log(chalk.gray(`= same ${entry.name}`));
691
+ same++;
692
+ } else {
693
+ console.log(chalk.cyan(`~ changed ${entry.name}`));
694
+ changed++;
695
+ }
696
+ }
697
+
698
+ console.log(chalk.blue(`Summary: changed=${changed}, same=${same}, local-missing=${missingLocal}, remote-missing=${missingRemote}, remote-drift=${remoteDrift}`));
699
+ } catch (error) {
700
+ console.error(chalk.red("Error:"), error instanceof Error ? error.message : "Unknown error");
701
+ process.exit(1);
702
+ }
703
+ });