@lark-apaas/fullstack-cli 1.1.28 → 1.1.30

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/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/index.ts
2
- import fs25 from "fs";
3
- import path21 from "path";
2
+ import fs26 from "fs";
3
+ import path22 from "path";
4
4
  import { fileURLToPath as fileURLToPath5 } from "url";
5
5
  import { config as dotenvConfig } from "dotenv";
6
6
 
@@ -2427,6 +2427,13 @@ var syncConfig = {
2427
2427
  type: "add-line",
2428
2428
  to: ".gitignore",
2429
2429
  line: ".agent/"
2430
+ },
2431
+ // 8. 同步 .spark_project 配置文件(总是覆盖)
2432
+ {
2433
+ from: "templates/.spark_project",
2434
+ to: ".spark_project",
2435
+ type: "file",
2436
+ overwrite: true
2430
2437
  }
2431
2438
  ],
2432
2439
  // 文件权限设置
@@ -4285,7 +4292,7 @@ var PROMPT_PATTERNS = [
4285
4292
  { pattern: /proceed\?/i, answer: "y\n" }
4286
4293
  ];
4287
4294
  async function executeShadcnAdd(registryItemPath) {
4288
- return new Promise((resolve) => {
4295
+ return new Promise((resolve2) => {
4289
4296
  let output = "";
4290
4297
  const args = ["--yes", "shadcn@3.8.2", "add", registryItemPath];
4291
4298
  const ptyProcess = pty.spawn("npx", args, {
@@ -4311,7 +4318,7 @@ async function executeShadcnAdd(registryItemPath) {
4311
4318
  });
4312
4319
  const timeoutId = setTimeout(() => {
4313
4320
  ptyProcess.kill();
4314
- resolve({
4321
+ resolve2({
4315
4322
  success: false,
4316
4323
  files: [],
4317
4324
  error: "\u6267\u884C\u8D85\u65F6"
@@ -4322,7 +4329,7 @@ async function executeShadcnAdd(registryItemPath) {
4322
4329
  const success = exitCode === 0;
4323
4330
  const filePaths = parseOutput(output);
4324
4331
  const files = filePaths.map(toFileInfo);
4325
- resolve({
4332
+ resolve2({
4326
4333
  success,
4327
4334
  files,
4328
4335
  error: success ? void 0 : output || `Process exited with code ${exitCode}`
@@ -4333,12 +4340,12 @@ async function executeShadcnAdd(registryItemPath) {
4333
4340
 
4334
4341
  // src/commands/component/add.handler.ts
4335
4342
  function runActionPluginInit() {
4336
- return new Promise((resolve) => {
4343
+ return new Promise((resolve2) => {
4337
4344
  execFile("fullstack-cli", ["action-plugin", "init"], { cwd: process.cwd(), stdio: "ignore" }, (error) => {
4338
4345
  if (error) {
4339
4346
  debug("action-plugin init \u5931\u8D25: %s", error.message);
4340
4347
  }
4341
- resolve();
4348
+ resolve2();
4342
4349
  });
4343
4350
  });
4344
4351
  }
@@ -7174,7 +7181,45 @@ async function genArtifactUploadCredential(appId, body) {
7174
7181
  const response = await client.post(url, body);
7175
7182
  if (!response.ok || response.status !== 200) {
7176
7183
  throw new Error(
7177
- `API request failed: ${response.status} ${response.statusText}`
7184
+ `gen_artifact_upload_credential \u8BF7\u6C42\u5931\u8D25: ${response.status} ${response.statusText}`
7185
+ );
7186
+ }
7187
+ return response.json();
7188
+ }
7189
+ async function getDefaultBucketId(appId) {
7190
+ const client = getHttpClient();
7191
+ const url = `/b/${appId}/get_published_v2`;
7192
+ const response = await client.get(url);
7193
+ if (!response.ok || response.status !== 200) {
7194
+ throw new Error(
7195
+ `get_published_v2 \u8BF7\u6C42\u5931\u8D25: ${response.status} ${response.statusText}`
7196
+ );
7197
+ }
7198
+ const data = await response.json();
7199
+ const bucketId = data?.data?.app_runtime_extra?.bucket?.default_bucket_id;
7200
+ if (!bucketId) {
7201
+ throw new Error(`\u672A\u627E\u5230\u5E94\u7528 ${appId} \u7684\u9ED8\u8BA4\u5B58\u50A8\u6876`);
7202
+ }
7203
+ return bucketId;
7204
+ }
7205
+ async function preUploadStaticAttachment(appId, bucketId) {
7206
+ const client = getHttpClient();
7207
+ const url = `/v1/app/${appId}/storage/bucket/${bucketId}/preUploadStatic`;
7208
+ const response = await client.post(url, {});
7209
+ if (!response.ok || response.status !== 200) {
7210
+ throw new Error(
7211
+ `preUploadStatic \u8BF7\u6C42\u5931\u8D25: ${response.status} ${response.statusText}`
7212
+ );
7213
+ }
7214
+ return response.json();
7215
+ }
7216
+ async function uploadStaticAttachmentCallback(appId, bucketId, body) {
7217
+ const client = getHttpClient();
7218
+ const url = `/v1/app/${appId}/storage/bucket/${bucketId}/object/callbackStatic`;
7219
+ const response = await client.post(url, body);
7220
+ if (!response.ok || response.status !== 200) {
7221
+ throw new Error(
7222
+ `callbackStatic \u8BF7\u6C42\u5931\u8D25: ${response.status} ${response.statusText}`
7178
7223
  );
7179
7224
  }
7180
7225
  return response.json();
@@ -7212,6 +7257,170 @@ function camelToKebab(str) {
7212
7257
  return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
7213
7258
  }
7214
7259
 
7260
+ // src/commands/build/upload-static.handler.ts
7261
+ import * as fs25 from "fs";
7262
+ import * as path21 from "path";
7263
+ import { execFileSync } from "child_process";
7264
+ function readCredentialsFromEnv() {
7265
+ const uploadPrefix = process.env.STATIC_UPLOAD_PREFIX;
7266
+ const uploadID = process.env.STATIC_UPLOAD_ID;
7267
+ const bucketId = process.env.STATIC_UPLOAD_BUCKET_ID;
7268
+ const accessKeyID = process.env.STATIC_UPLOAD_AK;
7269
+ const secretAccessKey = process.env.STATIC_UPLOAD_SK;
7270
+ const sessionToken = process.env.STATIC_UPLOAD_TOKEN;
7271
+ if (!uploadPrefix || !uploadID || !bucketId || !accessKeyID || !secretAccessKey || !sessionToken) {
7272
+ return null;
7273
+ }
7274
+ return { uploadPrefix, uploadID, bucketId, accessKeyID, secretAccessKey, sessionToken };
7275
+ }
7276
+ var LOG_PREFIX = "[upload-static]";
7277
+ async function uploadStatic(options) {
7278
+ try {
7279
+ const {
7280
+ appId,
7281
+ staticDir = "shared/static",
7282
+ tosutilPath = "/workspace/tosutil",
7283
+ endpoint = "tos-cn-beijing.volces.com",
7284
+ region = "cn-beijing"
7285
+ } = options;
7286
+ const resolvedStaticDir = path21.resolve(staticDir);
7287
+ if (!fs25.existsSync(resolvedStaticDir)) {
7288
+ console.error(`${LOG_PREFIX} \u76EE\u5F55\u4E0D\u5B58\u5728: ${resolvedStaticDir}\uFF0C\u8DF3\u8FC7\u4E0A\u4F20`);
7289
+ return;
7290
+ }
7291
+ if (isDirEmpty(resolvedStaticDir)) {
7292
+ console.error(`${LOG_PREFIX} \u76EE\u5F55\u4E3A\u7A7A: ${resolvedStaticDir}\uFF0C\u8DF3\u8FC7\u4E0A\u4F20`);
7293
+ return;
7294
+ }
7295
+ if (!fs25.existsSync(tosutilPath)) {
7296
+ throw new Error(
7297
+ `tosutil \u4E0D\u5B58\u5728: ${tosutilPath}\u3002\u8BF7\u786E\u4FDD\u6D41\u6C34\u7EBF\u5DF2\u5728"\u4EA7\u7269\u6253\u5305\u4E0A\u4F20"\u6B65\u9AA4\u4E2D\u4E0B\u8F7D tosutil\u3002`
7298
+ );
7299
+ }
7300
+ let uploadPrefix;
7301
+ let uploadID;
7302
+ let bucketId;
7303
+ let accessKeyID;
7304
+ let secretAccessKey;
7305
+ let sessionToken;
7306
+ const envCredentials = readCredentialsFromEnv();
7307
+ if (envCredentials) {
7308
+ console.error(`${LOG_PREFIX} \u4F7F\u7528\u73AF\u5883\u53D8\u91CF\u4E2D\u7684\u4E0A\u4F20\u51ED\u8BC1`);
7309
+ ({ uploadPrefix, uploadID, bucketId, accessKeyID, secretAccessKey, sessionToken } = envCredentials);
7310
+ } else {
7311
+ console.error(`${LOG_PREFIX} \u73AF\u5883\u53D8\u91CF\u672A\u8BBE\u7F6E\uFF0C\u8C03\u7528 preUploadStatic...`);
7312
+ bucketId = await resolveBucketId(appId);
7313
+ const preUploadResp = await fetchPreUpload(appId, bucketId);
7314
+ const { uploadCredential } = preUploadResp.data;
7315
+ ({ uploadPrefix, uploadID } = preUploadResp.data);
7316
+ ({ AccessKeyID: accessKeyID, SecretAccessKey: secretAccessKey, SessionToken: sessionToken } = uploadCredential);
7317
+ }
7318
+ console.error(`${LOG_PREFIX} \u4E0A\u4F20\u76EE\u6807: ${uploadPrefix}`);
7319
+ console.error(`${LOG_PREFIX} \u914D\u7F6E tosutil...`);
7320
+ configureTosutil(tosutilPath, {
7321
+ endpoint,
7322
+ region,
7323
+ accessKeyID,
7324
+ secretAccessKey,
7325
+ sessionToken
7326
+ });
7327
+ console.error(`${LOG_PREFIX} \u4E0A\u4F20 ${resolvedStaticDir} -> ${uploadPrefix}`);
7328
+ uploadToTos(tosutilPath, resolvedStaticDir, uploadPrefix);
7329
+ console.error(`${LOG_PREFIX} tosutil \u4E0A\u4F20\u5B8C\u6210`);
7330
+ console.error(`${LOG_PREFIX} \u8C03\u7528 callbackStatic (uploadID: ${uploadID})...`);
7331
+ const callbackResp = await uploadStaticAttachmentCallback(appId, bucketId, { uploadID });
7332
+ if (callbackResp.status_code !== "0") {
7333
+ throw new Error(`callbackStatic \u8FD4\u56DE\u5F02\u5E38, status_code: ${callbackResp.status_code}`);
7334
+ }
7335
+ const attachments = callbackResp.data?.attachments || [];
7336
+ console.error(`${LOG_PREFIX} \u4E0A\u4F20\u5B8C\u6210\uFF0C\u5171 ${attachments.length} \u4E2A\u6587\u4EF6`);
7337
+ console.log(JSON.stringify(callbackResp));
7338
+ } catch (error) {
7339
+ const message = error instanceof Error ? error.message : String(error);
7340
+ console.error(`${LOG_PREFIX} Error: ${message}`);
7341
+ process.exit(1);
7342
+ }
7343
+ }
7344
+ async function fetchPreUpload(appId, bucketId) {
7345
+ const response = await preUploadStaticAttachment(appId, bucketId);
7346
+ if (response.status_code !== "0") {
7347
+ throw new Error(`preUploadStatic \u8FD4\u56DE\u5F02\u5E38, status_code: ${response.status_code}`);
7348
+ }
7349
+ const { uploadPrefix, uploadID, uploadCredential } = response.data || {};
7350
+ if (!uploadPrefix || !uploadID) {
7351
+ throw new Error("preUploadStatic \u8FD4\u56DE\u6570\u636E\u4E0D\u5B8C\u6574\uFF0C\u7F3A\u5C11 uploadPrefix \u6216 uploadID");
7352
+ }
7353
+ if (!uploadCredential?.AccessKeyID || !uploadCredential?.SecretAccessKey || !uploadCredential?.SessionToken) {
7354
+ throw new Error("preUploadStatic \u8FD4\u56DE\u7684\u51ED\u8BC1\u5B57\u6BB5\u4E0D\u5B8C\u6574");
7355
+ }
7356
+ return response;
7357
+ }
7358
+ function configureTosutil(tosutilPath, config) {
7359
+ const { endpoint, region, accessKeyID, secretAccessKey, sessionToken } = config;
7360
+ execFileSync(
7361
+ tosutilPath,
7362
+ ["config", "-e", endpoint, "-i", accessKeyID, "-k", secretAccessKey, "-t", sessionToken, "-re", region],
7363
+ { stdio: "pipe" }
7364
+ );
7365
+ }
7366
+ function uploadToTos(tosutilPath, sourceDir, destUrl) {
7367
+ execFileSync(
7368
+ tosutilPath,
7369
+ ["cp", sourceDir, destUrl, "-r", "-flat", "-j", "5", "-p", "3", "-ps", "10485760", "-f"],
7370
+ { stdio: "inherit" }
7371
+ );
7372
+ }
7373
+ async function resolveBucketId(appId) {
7374
+ console.error(`${LOG_PREFIX} \u83B7\u53D6\u9ED8\u8BA4\u5B58\u50A8\u6876...`);
7375
+ const bucketId = await getDefaultBucketId(appId);
7376
+ console.error(`${LOG_PREFIX} \u9ED8\u8BA4\u5B58\u50A8\u6876: ${bucketId}`);
7377
+ return bucketId;
7378
+ }
7379
+ function isDirEmpty(dirPath) {
7380
+ const entries = fs25.readdirSync(dirPath);
7381
+ return entries.length === 0;
7382
+ }
7383
+
7384
+ // src/commands/build/pre-upload-static.handler.ts
7385
+ var LOG_PREFIX2 = "[pre-upload-static]";
7386
+ function shellEscape(value) {
7387
+ return value.replace(/["\\`$]/g, "\\$&");
7388
+ }
7389
+ async function preUploadStatic(options) {
7390
+ try {
7391
+ const { appId } = options;
7392
+ console.error(`${LOG_PREFIX2} \u83B7\u53D6\u9ED8\u8BA4\u5B58\u50A8\u6876...`);
7393
+ const bucketId = await getDefaultBucketId(appId);
7394
+ console.error(`${LOG_PREFIX2} \u9ED8\u8BA4\u5B58\u50A8\u6876: ${bucketId}`);
7395
+ console.error(`${LOG_PREFIX2} \u8C03\u7528 preUploadStatic...`);
7396
+ const response = await preUploadStaticAttachment(appId, bucketId);
7397
+ if (response.status_code !== "0") {
7398
+ console.error(`${LOG_PREFIX2} preUploadStatic \u8FD4\u56DE\u5F02\u5E38, status_code: ${response.status_code}`);
7399
+ return;
7400
+ }
7401
+ const { downloadURLPrefix, uploadPrefix, uploadID, uploadCredential } = response.data || {};
7402
+ if (!downloadURLPrefix || !uploadPrefix || !uploadID) {
7403
+ console.error(`${LOG_PREFIX2} preUploadStatic \u8FD4\u56DE\u6570\u636E\u4E0D\u5B8C\u6574`);
7404
+ return;
7405
+ }
7406
+ if (!uploadCredential?.AccessKeyID || !uploadCredential?.SecretAccessKey || !uploadCredential?.SessionToken) {
7407
+ console.error(`${LOG_PREFIX2} preUploadStatic \u8FD4\u56DE\u7684\u51ED\u8BC1\u5B57\u6BB5\u4E0D\u5B8C\u6574`);
7408
+ return;
7409
+ }
7410
+ console.log(`export STATIC_ASSETS_BASE_URL="${shellEscape(downloadURLPrefix)}"`);
7411
+ console.log(`export STATIC_UPLOAD_PREFIX="${shellEscape(uploadPrefix)}"`);
7412
+ console.log(`export STATIC_UPLOAD_ID="${shellEscape(uploadID)}"`);
7413
+ console.log(`export STATIC_UPLOAD_AK="${shellEscape(uploadCredential.AccessKeyID)}"`);
7414
+ console.log(`export STATIC_UPLOAD_SK="${shellEscape(uploadCredential.SecretAccessKey)}"`);
7415
+ console.log(`export STATIC_UPLOAD_TOKEN="${shellEscape(uploadCredential.SessionToken)}"`);
7416
+ console.log(`export STATIC_UPLOAD_BUCKET_ID="${shellEscape(bucketId)}"`);
7417
+ console.error(`${LOG_PREFIX2} \u73AF\u5883\u53D8\u91CF\u5DF2\u8F93\u51FA`);
7418
+ } catch (error) {
7419
+ const message = error instanceof Error ? error.message : String(error);
7420
+ console.error(`${LOG_PREFIX2} Warning: ${message}`);
7421
+ }
7422
+ }
7423
+
7215
7424
  // src/commands/build/index.ts
7216
7425
  var getTokenCommand = {
7217
7426
  name: "get-token",
@@ -7222,10 +7431,28 @@ var getTokenCommand = {
7222
7431
  });
7223
7432
  }
7224
7433
  };
7434
+ var uploadStaticCommand = {
7435
+ name: "upload-static",
7436
+ description: "Upload shared/static files to TOS",
7437
+ register(program) {
7438
+ program.command(this.name).description(this.description).requiredOption("--app-id <id>", "Application ID").option("--static-dir <dir>", "Static files directory", "shared/static").option("--tosutil-path <path>", "Path to tosutil binary", "/workspace/tosutil").option("--endpoint <endpoint>", "TOS endpoint", "tos-cn-beijing.volces.com").option("--region <region>", "TOS region", "cn-beijing").action(async (options) => {
7439
+ await uploadStatic(options);
7440
+ });
7441
+ }
7442
+ };
7443
+ var preUploadStaticCommand = {
7444
+ name: "pre-upload-static",
7445
+ description: "Get TOS upload info and output as env vars for build.sh eval",
7446
+ register(program) {
7447
+ program.command(this.name).description(this.description).requiredOption("--app-id <id>", "Application ID").action(async (options) => {
7448
+ await preUploadStatic(options);
7449
+ });
7450
+ }
7451
+ };
7225
7452
  var buildCommandGroup = {
7226
7453
  name: "build",
7227
7454
  description: "Build related commands",
7228
- commands: [getTokenCommand]
7455
+ commands: [getTokenCommand, uploadStaticCommand, preUploadStaticCommand]
7229
7456
  };
7230
7457
 
7231
7458
  // src/commands/index.ts
@@ -7242,12 +7469,12 @@ var commands = [
7242
7469
  ];
7243
7470
 
7244
7471
  // src/index.ts
7245
- var envPath = path21.join(process.cwd(), ".env");
7246
- if (fs25.existsSync(envPath)) {
7472
+ var envPath = path22.join(process.cwd(), ".env");
7473
+ if (fs26.existsSync(envPath)) {
7247
7474
  dotenvConfig({ path: envPath });
7248
7475
  }
7249
- var __dirname = path21.dirname(fileURLToPath5(import.meta.url));
7250
- var pkg = JSON.parse(fs25.readFileSync(path21.join(__dirname, "../package.json"), "utf-8"));
7476
+ var __dirname = path22.dirname(fileURLToPath5(import.meta.url));
7477
+ var pkg = JSON.parse(fs26.readFileSync(path22.join(__dirname, "../package.json"), "utf-8"));
7251
7478
  var cli = new FullstackCLI(pkg.version);
7252
7479
  cli.useAll(commands);
7253
7480
  cli.run();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/fullstack-cli",
3
- "version": "1.1.28",
3
+ "version": "1.1.30",
4
4
  "description": "CLI tool for fullstack template management",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,16 @@
1
+ run = ["npm", "run", "dev"] # 默认 spark-cli dev
2
+ hidden = [".config", ".git", "scripts", "node_modules", "dist", ".spark", ".agent", ".agents", "tmp", ".spark_project", ".playwright-cli"]
3
+ lint = ["npm", "run", "lint"]
4
+ test = ["npm", "run", "test"]
5
+ genDbSchema = ["npm", "run", "gen:db-schema"]
6
+ genOpenApiClient = ["npm", "run", "gen:openapi"]
7
+
8
+ [deployment]
9
+ build = ["npm", "run", "build"]
10
+ run = ["npm", "run", "start"]
11
+
12
+ [files]
13
+ [files.restrict]
14
+ pathPatterns = ["client/src/api/gen", "package.json", ".spark_project", ".gitignore"]
15
+ [files.hidden]
16
+ pathPatterns = [".config", ".git", "scripts", "node_modules", "dist", ".spark", ".agent", ".agents", "tmp", ".spark_project", ".playwright-cli"]
@@ -0,0 +1,275 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { spawn, execSync } = require('child_process');
7
+ const readline = require('readline');
8
+
9
+ // ── Project root ──────────────────────────────────────────────────────────────
10
+ const PROJECT_ROOT = path.resolve(__dirname, '..');
11
+ process.chdir(PROJECT_ROOT);
12
+
13
+ // ── Load .env ─────────────────────────────────────────────────────────────────
14
+ function loadEnv() {
15
+ const envPath = path.join(PROJECT_ROOT, '.env');
16
+ if (!fs.existsSync(envPath)) return;
17
+ const lines = fs.readFileSync(envPath, 'utf8').split('\n');
18
+ for (const line of lines) {
19
+ const trimmed = line.trim();
20
+ if (!trimmed || trimmed.startsWith('#')) continue;
21
+ const eqIdx = trimmed.indexOf('=');
22
+ if (eqIdx === -1) continue;
23
+ const key = trimmed.slice(0, eqIdx).trim();
24
+ const value = trimmed.slice(eqIdx + 1).trim();
25
+ if (!(key in process.env)) {
26
+ process.env[key] = value;
27
+ }
28
+ }
29
+ }
30
+ loadEnv();
31
+
32
+ // ── Configuration ─────────────────────────────────────────────────────────────
33
+ const LOG_DIR = process.env.LOG_DIR || 'logs';
34
+ const MAX_RESTART_COUNT = parseInt(process.env.MAX_RESTART_COUNT, 10) || 10;
35
+ const RESTART_DELAY = parseInt(process.env.RESTART_DELAY, 10) || 2;
36
+ const MAX_DELAY = 60;
37
+ const SERVER_PORT = process.env.SERVER_PORT || '3000';
38
+ const CLIENT_DEV_PORT = process.env.CLIENT_DEV_PORT || '8080';
39
+
40
+ fs.mkdirSync(LOG_DIR, { recursive: true });
41
+
42
+ // ── Logging infrastructure ────────────────────────────────────────────────────
43
+ const devStdLogPath = path.join(LOG_DIR, 'dev.std.log');
44
+ const devLogPath = path.join(LOG_DIR, 'dev.log');
45
+ const devStdLogFd = fs.openSync(devStdLogPath, 'a');
46
+ const devLogFd = fs.openSync(devLogPath, 'a');
47
+
48
+ function timestamp() {
49
+ const now = new Date();
50
+ return (
51
+ now.getFullYear() + '-' +
52
+ String(now.getMonth() + 1).padStart(2, '0') + '-' +
53
+ String(now.getDate()).padStart(2, '0') + ' ' +
54
+ String(now.getHours()).padStart(2, '0') + ':' +
55
+ String(now.getMinutes()).padStart(2, '0') + ':' +
56
+ String(now.getSeconds()).padStart(2, '0')
57
+ );
58
+ }
59
+
60
+ /** Write to terminal + dev.std.log */
61
+ function writeOutput(msg) {
62
+ try { process.stdout.write(msg); } catch {}
63
+ try { fs.writeSync(devStdLogFd, msg); } catch {}
64
+ }
65
+
66
+ /** Structured event log → terminal + dev.std.log + dev.log */
67
+ function logEvent(level, name, message) {
68
+ const msg = `[${timestamp()}] [${level}] [${name}] ${message}\n`;
69
+ writeOutput(msg);
70
+ try { fs.writeSync(devLogFd, msg); } catch {}
71
+ }
72
+
73
+ // ── Process group management ──────────────────────────────────────────────────
74
+ function killProcessGroup(pid, signal) {
75
+ try {
76
+ process.kill(-pid, signal);
77
+ } catch {}
78
+ }
79
+
80
+ function killOrphansByPort(port) {
81
+ try {
82
+ const pids = execSync(`lsof -ti :${port}`, { encoding: 'utf8', timeout: 5000 }).trim();
83
+ if (pids) {
84
+ const pidList = pids.split('\n').filter(Boolean);
85
+ for (const p of pidList) {
86
+ try { process.kill(parseInt(p, 10), 'SIGKILL'); } catch {}
87
+ }
88
+ return pidList;
89
+ }
90
+ } catch {}
91
+ return [];
92
+ }
93
+
94
+ // ── Process supervision ───────────────────────────────────────────────────────
95
+ let stopping = false;
96
+ const managedProcesses = []; // { name, pid, child }
97
+
98
+ function sleep(ms) {
99
+ return new Promise((resolve) => setTimeout(resolve, ms));
100
+ }
101
+
102
+ /**
103
+ * Start and supervise a process with auto-restart and log piping.
104
+ * Returns a promise that resolves when the process loop ends.
105
+ */
106
+ function startProcess({ name, command, args, cleanupPort }) {
107
+ const logFilePath = path.join(LOG_DIR, `${name}.std.log`);
108
+ const logFd = fs.openSync(logFilePath, 'a');
109
+
110
+ const entry = { name, pid: null, child: null };
111
+ managedProcesses.push(entry);
112
+
113
+ const run = async () => {
114
+ let restartCount = 0;
115
+
116
+ while (!stopping) {
117
+ const child = spawn(command, args, {
118
+ detached: true,
119
+ stdio: ['ignore', 'pipe', 'pipe'],
120
+ shell: true,
121
+ cwd: PROJECT_ROOT,
122
+ env: { ...process.env },
123
+ });
124
+
125
+ entry.pid = child.pid;
126
+ entry.child = child;
127
+
128
+ logEvent('INFO', name, `Process started (PGID: ${child.pid}): ${command} ${args.join(' ')}`);
129
+
130
+ // Pipe stdout and stderr through readline for timestamped logging
131
+ const pipeLines = (stream) => {
132
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
133
+ rl.on('line', (line) => {
134
+ const msg = `[${timestamp()}] [${name}] ${line}\n`;
135
+ try { fs.writeSync(logFd, msg); } catch {}
136
+ writeOutput(msg);
137
+ });
138
+ };
139
+ if (child.stdout) pipeLines(child.stdout);
140
+ if (child.stderr) pipeLines(child.stderr);
141
+
142
+ // Wait for the direct child to exit.
143
+ // NOTE: must use 'exit', not 'close'. With shell:true, grandchild processes
144
+ // (e.g. nest's server) inherit stdout pipes. 'close' won't fire until ALL
145
+ // pipe holders exit, causing dev.js to hang when npm/nest dies but server survives.
146
+ const exitCode = await new Promise((resolve) => {
147
+ child.on('exit', (code) => resolve(code ?? 1));
148
+ child.on('error', () => resolve(1));
149
+ });
150
+
151
+ // Kill the entire process group
152
+ if (entry.pid) {
153
+ killProcessGroup(entry.pid, 'SIGTERM');
154
+ await sleep(2000);
155
+ killProcessGroup(entry.pid, 'SIGKILL');
156
+ }
157
+ entry.pid = null;
158
+ entry.child = null;
159
+
160
+ // Port cleanup fallback
161
+ if (cleanupPort) {
162
+ const orphans = killOrphansByPort(cleanupPort);
163
+ if (orphans.length > 0) {
164
+ logEvent('WARN', name, `Killed orphan processes on port ${cleanupPort}: ${orphans.join(' ')}`);
165
+ await sleep(500);
166
+ }
167
+ }
168
+
169
+ if (stopping) break;
170
+
171
+ restartCount++;
172
+ if (restartCount >= MAX_RESTART_COUNT) {
173
+ logEvent('ERROR', name, `Max restart count (${MAX_RESTART_COUNT}) reached, giving up`);
174
+ break;
175
+ }
176
+
177
+ const delay = Math.min(RESTART_DELAY * (1 << (restartCount - 1)), MAX_DELAY);
178
+ logEvent('WARN', name, `Process exited with code ${exitCode}, restarting (${restartCount}/${MAX_RESTART_COUNT}) in ${delay}s...`);
179
+ await sleep(delay * 1000);
180
+ }
181
+
182
+ try { fs.closeSync(logFd); } catch {}
183
+ };
184
+
185
+ return run();
186
+ }
187
+
188
+ // ── Cleanup ───────────────────────────────────────────────────────────────────
189
+ let cleanupDone = false;
190
+
191
+ async function cleanup() {
192
+ if (cleanupDone) return;
193
+ cleanupDone = true;
194
+ stopping = true;
195
+
196
+ logEvent('INFO', 'main', 'Shutting down all processes...');
197
+
198
+ // Kill all managed process groups
199
+ for (const entry of managedProcesses) {
200
+ if (entry.pid) {
201
+ logEvent('INFO', 'main', `Stopping process group (PGID: ${entry.pid})`);
202
+ killProcessGroup(entry.pid, 'SIGTERM');
203
+ }
204
+ }
205
+
206
+ // Wait for graceful shutdown
207
+ await sleep(2000);
208
+
209
+ // Force kill any remaining
210
+ for (const entry of managedProcesses) {
211
+ if (entry.pid) {
212
+ logEvent('WARN', 'main', `Force killing process group (PGID: ${entry.pid})`);
213
+ killProcessGroup(entry.pid, 'SIGKILL');
214
+ }
215
+ }
216
+
217
+ // Port cleanup fallback
218
+ killOrphansByPort(SERVER_PORT);
219
+ killOrphansByPort(CLIENT_DEV_PORT);
220
+
221
+ logEvent('INFO', 'main', 'All processes stopped');
222
+
223
+ try { fs.closeSync(devStdLogFd); } catch {}
224
+ try { fs.closeSync(devLogFd); } catch {}
225
+
226
+ process.exit(0);
227
+ }
228
+
229
+ process.on('SIGTERM', cleanup);
230
+ process.on('SIGINT', cleanup);
231
+ process.on('SIGHUP', cleanup);
232
+
233
+ // ── Main ──────────────────────────────────────────────────────────────────────
234
+ async function main() {
235
+ logEvent('INFO', 'main', '========== Dev session started ==========');
236
+
237
+ // Initialize action plugins
238
+ writeOutput('\n🔌 Initializing action plugins...\n');
239
+ try {
240
+ execSync('fullstack-cli action-plugin init', { cwd: PROJECT_ROOT, stdio: 'inherit' });
241
+ writeOutput('✅ Action plugins initialized\n\n');
242
+ } catch {
243
+ writeOutput('⚠️ Action plugin initialization failed, continuing anyway...\n\n');
244
+ }
245
+
246
+ // Start server and client
247
+ const serverPromise = startProcess({
248
+ name: 'server',
249
+ command: 'npm',
250
+ args: ['run', 'dev:server'],
251
+ cleanupPort: SERVER_PORT,
252
+ });
253
+
254
+ const clientPromise = startProcess({
255
+ name: 'client',
256
+ command: 'npm',
257
+ args: ['run', 'dev:client'],
258
+ cleanupPort: CLIENT_DEV_PORT,
259
+ });
260
+
261
+ writeOutput(`📋 Dev processes running. Press Ctrl+C to stop.\n`);
262
+ writeOutput(`📄 Logs: ${devStdLogPath}\n\n`);
263
+
264
+ // Wait for both (they loop until stopping or max restarts)
265
+ await Promise.all([serverPromise, clientPromise]);
266
+
267
+ if (!cleanupDone) {
268
+ await cleanup();
269
+ }
270
+ }
271
+
272
+ main().catch((err) => {
273
+ console.error('Fatal error:', err);
274
+ process.exit(1);
275
+ });
@@ -1,245 +1,2 @@
1
1
  #!/usr/bin/env bash
2
- # This file is auto-generated by @lark-apaas/fullstack-cli
3
-
4
- set -uo pipefail
5
-
6
- # Ensure the script always runs from the project root
7
- cd "$(dirname "${BASH_SOURCE[0]}")/.."
8
-
9
- # Configuration
10
- LOG_DIR=${LOG_DIR:-logs}
11
- DEV_LOG="${LOG_DIR}/dev.log"
12
- MAX_RESTART_COUNT=${MAX_RESTART_COUNT:-10}
13
- RESTART_DELAY=${RESTART_DELAY:-2}
14
-
15
- # Process tracking
16
- SERVER_PID=""
17
- CLIENT_PID=""
18
- PARENT_PID=$$
19
- STOP_FLAG_FILE="/tmp/dev_sh_stop_$$"
20
- CLEANUP_DONE=false
21
-
22
- mkdir -p "${LOG_DIR}"
23
-
24
- # Redirect all stdout/stderr to both terminal and log file
25
- DEV_STD_LOG="${LOG_DIR}/dev.std.log"
26
- exec > >(tee -a "$DEV_STD_LOG") 2>&1
27
-
28
- # Log event to dev.log with timestamp
29
- log_event() {
30
- local level=$1
31
- local process_name=$2
32
- local message=$3
33
- local msg="[$(date '+%Y-%m-%d %H:%M:%S')] [${level}] [${process_name}] ${message}"
34
- echo "$msg"
35
- echo "$msg" >> "${DEV_LOG}"
36
- }
37
-
38
- # Check if PID is valid (positive integer and process exists)
39
- is_valid_pid() {
40
- local pid=$1
41
- [[ -n "$pid" ]] && [[ "$pid" =~ ^[0-9]+$ ]] && [[ "$pid" -gt 0 ]] && kill -0 "$pid" 2>/dev/null
42
- }
43
-
44
- # Check if parent process is still alive
45
- is_parent_alive() {
46
- kill -0 "$PARENT_PID" 2>/dev/null
47
- }
48
-
49
- # Check if should stop (parent exited or stop flag exists)
50
- should_stop() {
51
- [[ -f "$STOP_FLAG_FILE" ]] || ! is_parent_alive
52
- }
53
-
54
- # Kill entire process tree (process and all descendants)
55
- kill_tree() {
56
- local pid=$1
57
- local signal=${2:-TERM}
58
-
59
- # Get all descendant PIDs
60
- local children
61
- children=$(pgrep -P "$pid" 2>/dev/null) || true
62
-
63
- # Recursively kill children first
64
- for child in $children; do
65
- kill_tree "$child" "$signal"
66
- done
67
-
68
- # Kill the process itself
69
- if kill -0 "$pid" 2>/dev/null; then
70
- kill -"$signal" "$pid" 2>/dev/null || true
71
- fi
72
- }
73
-
74
- # Start a process with supervision
75
- # $1: name
76
- # $2: command
77
- # $3: cleanup port for orphan processes (optional)
78
- start_supervised_process() {
79
- local name=$1
80
- local cmd=$2
81
- local cleanup_port=${3:-""}
82
- local log_file="${LOG_DIR}/${name}.std.log"
83
-
84
- (
85
- local restart_count=0
86
- local child_pid=""
87
- local max_delay=60 # Maximum delay in seconds
88
-
89
- # Handle signals to kill child process tree
90
- trap 'if [[ -n "$child_pid" ]]; then kill_tree "$child_pid" TERM; fi' TERM INT
91
-
92
- while true; do
93
- # Check if we should stop (parent exited or stop flag)
94
- if should_stop; then
95
- log_event "INFO" "$name" "Process stopped (parent exited or user requested)"
96
- break
97
- fi
98
-
99
- # Start command in background and capture output with timestamps
100
- eval "$cmd" > >(
101
- while IFS= read -r line; do
102
- local msg="[$(date '+%Y-%m-%d %H:%M:%S')] [${name}] ${line}"
103
- echo "$msg"
104
- echo "$msg" >> "$log_file"
105
- done
106
- ) 2>&1 &
107
- child_pid=$!
108
-
109
- log_event "INFO" "$name" "Process started (PID: ${child_pid}): ${cmd}"
110
-
111
- # Wait for child to exit
112
- set +e
113
- wait "$child_pid"
114
- exit_code=$?
115
- set -e
116
-
117
- # Kill entire process tree to avoid orphans
118
- if [[ -n "$child_pid" ]]; then
119
- kill_tree "$child_pid" TERM
120
- sleep 0.5
121
- kill_tree "$child_pid" KILL
122
- fi
123
- child_pid=""
124
-
125
- # Cleanup orphan processes by port (for processes that escaped kill_tree)
126
- if [[ -n "$cleanup_port" ]]; then
127
- local orphan_pids
128
- orphan_pids=$(lsof -ti ":${cleanup_port}" 2>/dev/null) || true
129
- if [[ -n "$orphan_pids" ]]; then
130
- log_event "WARN" "$name" "Killing orphan processes on port ${cleanup_port}: $(echo $orphan_pids | tr '\n' ' ')"
131
- echo "$orphan_pids" | xargs kill -9 2>/dev/null || true
132
- sleep 0.5
133
- fi
134
- fi
135
-
136
- # Check if we should stop (parent exited or stop flag)
137
- if should_stop; then
138
- log_event "INFO" "$name" "Process stopped (parent exited or user requested)"
139
- break
140
- fi
141
-
142
- # Process exited unexpectedly, restart
143
- restart_count=$((restart_count + 1))
144
-
145
- if [[ $restart_count -ge $MAX_RESTART_COUNT ]]; then
146
- log_event "ERROR" "$name" "Max restart count (${MAX_RESTART_COUNT}) reached, giving up"
147
- break
148
- fi
149
-
150
- # Exponential backoff: delay = RESTART_DELAY * 2^(restart_count-1), capped at max_delay
151
- local delay=$((RESTART_DELAY * (1 << (restart_count - 1))))
152
- if [[ $delay -gt $max_delay ]]; then
153
- delay=$max_delay
154
- fi
155
-
156
- log_event "WARN" "$name" "Process exited with code ${exit_code}, restarting (${restart_count}/${MAX_RESTART_COUNT}) in ${delay}s..."
157
- sleep "$delay"
158
- done
159
- ) &
160
- }
161
-
162
- # Cleanup function
163
- cleanup() {
164
- # Prevent multiple cleanup calls
165
- if [[ "$CLEANUP_DONE" == "true" ]]; then
166
- return
167
- fi
168
- CLEANUP_DONE=true
169
-
170
- log_event "INFO" "main" "Shutting down all processes..."
171
-
172
- # Create stop flag to signal child processes
173
- touch "$STOP_FLAG_FILE"
174
-
175
- # Kill entire process trees (TERM first)
176
- for pid in $SERVER_PID $CLIENT_PID; do
177
- if is_valid_pid "$pid"; then
178
- log_event "INFO" "main" "Stopping process tree (PID: ${pid})"
179
- kill_tree "$pid" TERM
180
- fi
181
- done
182
-
183
- # Kill any remaining background jobs
184
- local bg_pids
185
- bg_pids=$(jobs -p 2>/dev/null) || true
186
- if [[ -n "$bg_pids" ]]; then
187
- for pid in $bg_pids; do
188
- kill_tree "$pid" TERM
189
- done
190
- fi
191
-
192
- # Wait for processes to terminate
193
- sleep 1
194
-
195
- # Force kill if still running
196
- for pid in $SERVER_PID $CLIENT_PID; do
197
- if is_valid_pid "$pid"; then
198
- log_event "WARN" "main" "Force killing process tree (PID: ${pid})"
199
- kill_tree "$pid" KILL
200
- fi
201
- done
202
-
203
- # Cleanup stop flag
204
- rm -f "$STOP_FLAG_FILE"
205
-
206
- log_event "INFO" "main" "All processes stopped"
207
- }
208
-
209
- # Set up signal handlers
210
- trap cleanup EXIT INT TERM HUP
211
-
212
- # Remove any stale stop flag
213
- rm -f "$STOP_FLAG_FILE"
214
-
215
- # Initialize dev.log
216
- echo "" >> "${DEV_LOG}"
217
- log_event "INFO" "main" "========== Dev session started =========="
218
-
219
- # Initialize action plugins before starting dev servers
220
- echo "🔌 Initializing action plugins..."
221
- if fullstack-cli action-plugin init; then
222
- echo "✅ Action plugins initialized"
223
- else
224
- echo "⚠️ Action plugin initialization failed, continuing anyway..."
225
- fi
226
- echo ""
227
-
228
- # Start server (cleanup orphan processes on SERVER_PORT)
229
- start_supervised_process "server" "npm run dev:server" "${SERVER_PORT:-3000}"
230
- SERVER_PID=$!
231
- log_event "INFO" "server" "Supervisor started with PID ${SERVER_PID}"
232
-
233
- # Start client (cleanup orphan processes on CLIENT_DEV_PORT)
234
- start_supervised_process "client" "npm run dev:client" "${CLIENT_DEV_PORT:-8080}"
235
- CLIENT_PID=$!
236
- log_event "INFO" "client" "Supervisor started with PID ${CLIENT_PID}"
237
-
238
- log_event "INFO" "main" "All processes started, monitoring..."
239
- echo ""
240
- echo "📋 Dev processes running. Press Ctrl+C to stop."
241
- echo "📄 Logs: ${DEV_STD_LOG}"
242
- echo ""
243
-
244
- # Wait for all background processes
245
- wait
2
+ exec node "$(dirname "$0")/dev.js" "$@"