@cedarjs/cli 7.0.1-next.314 → 7.0.1-next.466

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 (59) hide show
  1. package/dist/commands/deploy/baremetal/baremetalHandler.js +375 -76
  2. package/dist/commands/deploy/baremetal/monitors.js +66 -0
  3. package/dist/commands/deploy/baremetal.js +6 -0
  4. package/dist/commands/destroy/scaffold/scaffoldHandler.js +63 -25
  5. package/dist/commands/dev/devHandler.js +13 -1
  6. package/dist/commands/generate/dbAuth/dbAuth.js +6 -2
  7. package/dist/commands/generate/dbAuth/dbAuthHandler.js +57 -14
  8. package/dist/commands/generate/dbAuth/templates/changePassword.tsx.template +131 -0
  9. package/dist/commands/generate/dbAuth/templates/login.webAuthn.tsx.template +20 -12
  10. package/dist/commands/generate/directive/directive.js +15 -2
  11. package/dist/commands/generate/helpers.js +26 -0
  12. package/dist/commands/generate/ogImage/templates/ogImage.og.tsx.template +1 -1
  13. package/dist/commands/generate/package/templates/tsconfig.json.template +1 -0
  14. package/dist/commands/generate/realtime/templates/liveQueries/blank/blank.service.ts.template +12 -4
  15. package/dist/commands/generate/realtime/templates/subscriptions/blank/blank.service.ts.template +2 -2
  16. package/dist/commands/generate/realtime/templates/subscriptions/blank/blank.ts.template +3 -3
  17. package/dist/commands/generate/scaffold/scaffold.js +1 -1
  18. package/dist/commands/generate/scaffold/scaffoldHandler.js +83 -44
  19. package/dist/commands/generate/scaffold/templates/assets/scaffold.tailwind.css.template +12 -7
  20. package/dist/commands/generate/scaffold/templates/components/EditNameCell.tsx.template +7 -7
  21. package/dist/commands/generate/scaffold/templates/components/NameForm.tsx.template +7 -6
  22. package/dist/commands/generate/scaffold/templates/layouts/NamesScaffoldLayout.tsx.template +18 -0
  23. package/dist/commands/generate/scaffold/templates/lib/formatters.tsx.template +3 -3
  24. package/dist/commands/generate/script/templates/tsconfig.json.template +1 -0
  25. package/dist/commands/generate/sdl/sdlHandler.js +8 -3
  26. package/dist/commands/generate/service/serviceHandler.js +32 -11
  27. package/dist/commands/generate/service/templates/service.ts.template +21 -8
  28. package/dist/commands/generate/service/templates/test.ts.template +7 -5
  29. package/dist/commands/jobsHandler.js +5 -3
  30. package/dist/commands/setup/cache/templates/memcached.ts.template +14 -17
  31. package/dist/commands/setup/cache/templates/redis.ts.template +12 -20
  32. package/dist/commands/setup/deploy/helpers/index.js +3 -1
  33. package/dist/commands/setup/deploy/providers/baremetal.js +7 -0
  34. package/dist/commands/setup/deploy/providers/baremetalHandler.js +128 -20
  35. package/dist/commands/setup/deploy/providers/netlifyHandler.js +25 -3
  36. package/dist/commands/setup/deploy/templates/baremetal.js +119 -9
  37. package/dist/commands/setup/deploy/templates/netlifyUD.js +3 -0
  38. package/dist/commands/setup/jobs/jobsHandler.js +2 -0
  39. package/dist/commands/setup/monitoring/sentry/sentryHandler.js +5 -2
  40. package/dist/commands/setup/monitoring/sentry/templates/sentryApi.ts.template +1 -4
  41. package/dist/commands/setup/monitoring/sentry/templates/sentryWeb.ts.template +7 -4
  42. package/dist/commands/setup/realtime/templates/defer/fastAndSlowFields/fastAndSlowFields.ts.template +4 -1
  43. package/dist/commands/setup/realtime/templates/liveQueries/auctions/auctions.ts.template +12 -4
  44. package/dist/commands/setup/realtime/templates/subscriptions/newMessage/newMessage.ts.template +1 -1
  45. package/dist/commands/setup/tenancy/signupCodemod.js +2 -1
  46. package/dist/commands/setup/tenancy/templates/InvitePage.tsx.template +10 -1
  47. package/dist/commands/setup/tenancy/templates/organizations.scenarios.ts.template +0 -2
  48. package/dist/commands/setup/tenancy/templates/organizations.test.ts.template +0 -8
  49. package/dist/commands/setup/tenancy/templates/organizations.ts.template +1 -1
  50. package/dist/commands/setup/tenancy/templates/requireMembership.test.ts.template +1 -1
  51. package/dist/commands/setup/tenancy/tenancyHandler.js +1 -1
  52. package/dist/commands/setup/ui/libraries/tailwindcssHandler.js +61 -81
  53. package/dist/commands/setup/uploads/gitignore.js +17 -0
  54. package/dist/commands/setup/uploads/serverFile.js +93 -27
  55. package/dist/commands/setup/uploads/templates/uploads.ts.template +14 -5
  56. package/dist/commands/setup/uploads/uploadsHandler.js +22 -1
  57. package/dist/lib/index.js +10 -6
  58. package/package.json +24 -24
  59. package/dist/commands/setup/ui/templates/postcss.config.cjs.template +0 -9
@@ -9,21 +9,34 @@ import { titleCase } from "title-case";
9
9
  import { colors as c } from "@cedarjs/cli-helpers/colors";
10
10
  import { formatCedarCommand } from "@cedarjs/cli-helpers/packageManager/display";
11
11
  import { getPackageManager } from "@cedarjs/project-config/packageManager";
12
- import { getPaths } from "../../../lib/index.js";
12
+ import { getConfig, getPaths } from "../../../lib/index.js";
13
+ import { MONITOR_ADAPTERS, MONITORS, isMonitor } from "./monitors.js";
13
14
  const CONFIG_FILENAME = "deploy.toml";
15
+ const DOCS_URL = "https://cedarjs.com/docs/deploy/baremetal";
14
16
  const SYMLINK_FLAGS = "-nsf";
15
17
  const CURRENT_RELEASE_SYMLINK_NAME = "current";
16
18
  const LIFECYCLE_HOOKS = ["before", "after"];
19
+ const DEFAULT_HEALTH_CHECK_PATH = "/graphql/health";
20
+ const HEALTH_CHECK_INTERVAL_MS = 2e3;
21
+ const HEALTH_CHECK_REQUEST_TIMEOUT_SECONDS = 5;
22
+ const WEB_DIST_DIRS = ["web/dist", "web/dist/browser"];
23
+ const BUILD_MANIFEST_FILENAME = "client-build-manifest.json";
24
+ const LINK_BATCH_SIZE = 100;
25
+ const RELEASE_DIR_PATTERN = "^[0-9]{14}$";
26
+ const releaseDirRegExp = new RegExp(RELEASE_DIR_PATTERN);
27
+ const DEFAULT_MONITOR = "pm2";
17
28
  const DEFAULT_SERVER_CONFIG = {
18
29
  port: 22,
19
30
  branch: "main",
20
31
  packageManagerCommand: getPackageManager(),
21
- monitorCommand: "pm2",
32
+ monitor: DEFAULT_MONITOR,
22
33
  sides: ["api", "web"],
23
34
  keepReleases: 5,
24
- freeSpaceRequired: 2048
35
+ freeSpaceRequired: 2048,
36
+ healthCheckTimeout: 30
25
37
  };
26
38
  const pathJoin = path.posix.join;
39
+ const shellQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`;
27
40
  const throwMissingConfig = (name) => {
28
41
  throw new Error(
29
42
  `"${name}" config option not set. See https://cedarjs.com/docs/deployment/baremetal#deploytoml`
@@ -40,6 +53,17 @@ const verifyConfig = (config, yargs) => {
40
53
  }
41
54
  return true;
42
55
  };
56
+ const isHttpUrl = (value) => {
57
+ if (typeof value !== "string") {
58
+ return false;
59
+ }
60
+ try {
61
+ const { protocol } = new URL(value);
62
+ return protocol === "http:" || protocol === "https:";
63
+ } catch {
64
+ return false;
65
+ }
66
+ };
43
67
  const verifyServerConfig = (config) => {
44
68
  if (!config.host) {
45
69
  throwMissingConfig("host");
@@ -53,6 +77,17 @@ const verifyServerConfig = (config) => {
53
77
  if (!/^\d+$/.test(String(config.freeSpaceRequired))) {
54
78
  throw new Error('"freeSpaceRequired" must be an integer >= 0');
55
79
  }
80
+ if (config.healthCheckUrl !== void 0 && config.healthCheckUrl !== false && !isHttpUrl(config.healthCheckUrl)) {
81
+ throw new Error('"healthCheckUrl" must be an http(s) URL or `false`');
82
+ }
83
+ if (!/^\d+$/.test(String(config.healthCheckTimeout))) {
84
+ throw new Error('"healthCheckTimeout" must be an integer >= 0');
85
+ }
86
+ if (!isMonitor(config.monitor)) {
87
+ throw new Error(
88
+ `"monitor" must be one of ${MONITORS.map((monitor) => `"${monitor}"`).join(", ")}`
89
+ );
90
+ }
56
91
  return true;
57
92
  };
58
93
  const symlinkCurrentCommand = async (dir, ssh, deployPath) => {
@@ -62,17 +97,110 @@ const symlinkCurrentCommand = async (dir, ssh, deployPath) => {
62
97
  CURRENT_RELEASE_SYMLINK_NAME
63
98
  ]);
64
99
  };
65
- const restartProcessCommand = async (processName, ssh, serverConfig, deployPath) => {
66
- return await ssh.exec(deployPath, serverConfig.monitorCommand, [
67
- "restart",
68
- processName
69
- ]);
100
+ const monitorAdapter = (serverConfig) => MONITOR_ADAPTERS[serverConfig.monitor];
101
+ const runMonitorCommand = async (command, ssh, serverConfig) => {
102
+ return await ssh.exec(
103
+ serverConfig.path,
104
+ serverConfig.monitorCommand,
105
+ command.args
106
+ );
107
+ };
108
+ const monitorTask = (command, ssh, serverConfig, skip) => {
109
+ return {
110
+ title: command.title,
111
+ task: async () => {
112
+ await runMonitorCommand(command, ssh, serverConfig);
113
+ },
114
+ ...skip ? { skip } : {}
115
+ };
116
+ };
117
+ const healthCheckUrl = (serverConfig) => {
118
+ if (serverConfig.healthCheckUrl === false) {
119
+ return void 0;
120
+ }
121
+ if (serverConfig.healthCheckUrl) {
122
+ return serverConfig.healthCheckUrl;
123
+ }
124
+ if (!serverConfig.sides.includes("api")) {
125
+ return void 0;
126
+ }
127
+ return `http://localhost:${getConfig().api.port}${DEFAULT_HEALTH_CHECK_PATH}`;
128
+ };
129
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
130
+ const waitForHealthCheck = async ({
131
+ url,
132
+ timeoutSeconds,
133
+ ssh,
134
+ serverConfig,
135
+ nextSteps,
136
+ onFailedAttempt
137
+ }) => {
138
+ const deadline = Date.now() + timeoutSeconds * 1e3;
139
+ let lastFailure = "";
140
+ let keepTrying = true;
141
+ while (keepTrying) {
142
+ try {
143
+ await ssh.exec(serverConfig.path, "curl", [
144
+ "--fail",
145
+ "--location",
146
+ "--silent",
147
+ "--show-error",
148
+ "--output",
149
+ "/dev/null",
150
+ "--max-time",
151
+ String(HEALTH_CHECK_REQUEST_TIMEOUT_SECONDS),
152
+ shellQuote(url)
153
+ ]);
154
+ return;
155
+ } catch (e) {
156
+ lastFailure = e instanceof Error ? e.message : String(e);
157
+ onFailedAttempt?.(lastFailure);
158
+ }
159
+ keepTrying = Date.now() + HEALTH_CHECK_INTERVAL_MS <= deadline;
160
+ if (keepTrying) {
161
+ await sleep(HEALTH_CHECK_INTERVAL_MS);
162
+ }
163
+ }
164
+ throw new Error(
165
+ [
166
+ `Health check failed: ${url} did not respond successfully within ${timeoutSeconds} seconds of restarting the ${serverConfig.processNames?.join(", ")} process(es).`,
167
+ `Last attempt: ${lastFailure}`,
168
+ "",
169
+ ...nextSteps,
170
+ `If your app serves its health check somewhere else, set \`healthCheckUrl\` in deploy.toml. Set it to \`false\` to skip the check. See ${DOCS_URL}#health-check`
171
+ ].join("\n")
172
+ );
173
+ };
174
+ const healthCheckTask = (url, ssh, serverConfig, nextSteps) => {
175
+ return {
176
+ title: url ? `Checking ${url}...` : "Checking health...",
177
+ task: async (_ctx, task) => {
178
+ if (!url) {
179
+ return;
180
+ }
181
+ await waitForHealthCheck({
182
+ url,
183
+ timeoutSeconds: parseInt(String(serverConfig.healthCheckTimeout), 10),
184
+ ssh,
185
+ serverConfig,
186
+ nextSteps,
187
+ onFailedAttempt: (reason) => {
188
+ task.output = reason;
189
+ }
190
+ });
191
+ },
192
+ skip: () => !url
193
+ };
70
194
  };
71
195
  const serverConfigWithDefaults = (serverConfig, yargs) => {
196
+ const monitor = serverConfig.monitor ?? DEFAULT_SERVER_CONFIG.monitor;
197
+ const monitorCommand = serverConfig.monitorCommand ?? (isMonitor(monitor) ? MONITOR_ADAPTERS[monitor].defaultCommand : "");
72
198
  return {
73
199
  ...DEFAULT_SERVER_CONFIG,
74
200
  ...serverConfig,
75
- branch: yargs.branch || serverConfig.branch || DEFAULT_SERVER_CONFIG.branch
201
+ branch: yargs.branch || serverConfig.branch || DEFAULT_SERVER_CONFIG.branch,
202
+ monitor,
203
+ monitorCommand
76
204
  };
77
205
  };
78
206
  const maintenanceTasks = (status, ssh, serverConfig) => {
@@ -94,26 +222,24 @@ const maintenanceTasks = (status, ssh, serverConfig) => {
94
222
  }
95
223
  });
96
224
  if (serverConfig.processNames) {
97
- tasks.push({
98
- title: `Stopping ${serverConfig.processNames.join(", ")} processes...`,
99
- task: async () => {
100
- await ssh.exec(serverConfig.path, serverConfig.monitorCommand, [
101
- "stop",
102
- serverConfig.processNames.join(" ")
103
- ]);
104
- }
105
- });
225
+ tasks.push(
226
+ monitorTask(
227
+ monitorAdapter(serverConfig).stop(serverConfig.processNames),
228
+ ssh,
229
+ serverConfig
230
+ )
231
+ );
106
232
  }
107
233
  } else if (status === "down") {
108
- tasks.push({
109
- title: `Starting ${serverConfig.processNames?.join(", ")} processes...`,
110
- task: async () => {
111
- await ssh.exec(serverConfig.path, serverConfig.monitorCommand, [
112
- "start",
113
- serverConfig.processNames.join(" ")
114
- ]);
115
- }
116
- });
234
+ if (serverConfig.processNames) {
235
+ tasks.push(
236
+ monitorTask(
237
+ monitorAdapter(serverConfig).start(serverConfig.processNames),
238
+ ssh,
239
+ serverConfig
240
+ )
241
+ );
242
+ }
117
243
  if (serverConfig.processNames) {
118
244
  tasks.push({
119
245
  title: `Disabling maintenance page...`,
@@ -141,8 +267,13 @@ const rollbackTasks = (count, ssh, serverConfig) => {
141
267
  title: `Rolling back ${rollbackCount} release(s)...`,
142
268
  task: async () => {
143
269
  const currentLink = (await ssh.exec(serverConfig.path, "readlink", ["-f", "current"])).stdout.split("/").pop();
144
- const dirs = (await ssh.exec(serverConfig.path, "ls", ["-t"])).stdout.split("\n").filter((dirs2) => !dirs2.match(/current/));
270
+ const dirs = (await ssh.exec(serverConfig.path, "ls", ["-t"])).stdout.split("\n").filter((dir) => releaseDirRegExp.test(dir));
145
271
  const deployedIndex = dirs.indexOf(currentLink ?? "");
272
+ if (deployedIndex === -1) {
273
+ throw new Error(
274
+ `Cannot rollback: \`current\` points to "${currentLink}", which is not a timestamp-named release directory`
275
+ );
276
+ }
146
277
  const rollbackIndex = deployedIndex + rollbackCount;
147
278
  if (dirs[rollbackIndex]) {
148
279
  console.info("Setting symlink");
@@ -153,26 +284,22 @@ const rollbackTasks = (count, ssh, serverConfig) => {
153
284
  );
154
285
  } else {
155
286
  throw new Error(
156
- `Cannot rollback ${rollbackCount} release(s): ${dirs.length - dirs.indexOf(currentLink ?? "") - 1} previous release(s) available`
287
+ `Cannot rollback ${rollbackCount} release(s): ${dirs.length - deployedIndex - 1} previous release(s) available`
157
288
  );
158
289
  }
159
290
  }
160
291
  }
161
292
  ];
162
293
  if (serverConfig.processNames) {
294
+ const adapter = monitorAdapter(serverConfig);
163
295
  for (const processName of serverConfig.processNames) {
164
- tasks.push({
165
- title: `Restarting ${processName} process...`,
166
- task: async () => {
167
- await restartProcessCommand(
168
- processName,
169
- ssh,
170
- serverConfig,
171
- serverConfig.path
172
- );
173
- }
174
- });
296
+ tasks.push(monitorTask(adapter.restart(processName), ssh, serverConfig));
175
297
  }
298
+ tasks.push(
299
+ healthCheckTask(healthCheckUrl(serverConfig), ssh, serverConfig, [
300
+ "The release you rolled back to is live as `current`. Check the process logs on the server, or run the rollback again with `--rollback` to move one more release back."
301
+ ])
302
+ );
176
303
  }
177
304
  return tasks;
178
305
  };
@@ -203,6 +330,134 @@ const commandWithLifecycleEvents = ({
203
330
  tasks.push(lifecycleTask("after", name, skip, config));
204
331
  return tasks.flat().filter((t) => Boolean(t));
205
332
  };
333
+ const manifestAssetFiles = (manifestJson) => {
334
+ const manifest = JSON.parse(manifestJson);
335
+ const files = /* @__PURE__ */ new Set();
336
+ if (!manifest || typeof manifest !== "object") {
337
+ return [];
338
+ }
339
+ for (const entry of Object.values(manifest)) {
340
+ if (!entry || typeof entry !== "object") {
341
+ continue;
342
+ }
343
+ if ("file" in entry && typeof entry.file === "string") {
344
+ files.add(entry.file);
345
+ }
346
+ for (const key of ["css", "assets"]) {
347
+ if (!(key in entry) || !Array.isArray(entry[key])) {
348
+ continue;
349
+ }
350
+ for (const item of entry[key]) {
351
+ if (typeof item === "string") {
352
+ files.add(item);
353
+ }
354
+ }
355
+ }
356
+ }
357
+ return [...files];
358
+ };
359
+ const readBuildManifest = async (ssh, releasePath) => {
360
+ for (const distDir of WEB_DIST_DIRS) {
361
+ let stdout;
362
+ try {
363
+ ;
364
+ ({ stdout } = await ssh.exec(pathJoin(releasePath, distDir), "cat", [
365
+ BUILD_MANIFEST_FILENAME
366
+ ]));
367
+ } catch {
368
+ continue;
369
+ }
370
+ const jsonStart = stdout.indexOf("{");
371
+ const json = jsonStart === -1 ? stdout : stdout.slice(jsonStart);
372
+ try {
373
+ return { distDir, files: manifestAssetFiles(json) };
374
+ } catch (e) {
375
+ const message = e instanceof Error ? e.message : String(e);
376
+ throw new Error(
377
+ `Could not parse ${pathJoin(releasePath, distDir, BUILD_MANIFEST_FILENAME)}: ${message}`
378
+ );
379
+ }
380
+ }
381
+ return void 0;
382
+ };
383
+ const keepPreviousAssets = async (yargs, ssh, serverConfig) => {
384
+ const newReleasePath = pathJoin(serverConfig.path, yargs.releaseDir);
385
+ let newRelease;
386
+ try {
387
+ newRelease = await readBuildManifest(ssh, newReleasePath);
388
+ } catch (e) {
389
+ const reason = e instanceof Error ? e.message : String(e);
390
+ return { status: "skipped", reason };
391
+ }
392
+ if (!newRelease) {
393
+ return {
394
+ status: "skipped",
395
+ reason: `No ${BUILD_MANIFEST_FILENAME} in ` + WEB_DIST_DIRS.map((dir) => pathJoin(yargs.releaseDir, dir)).join(
396
+ " or "
397
+ )
398
+ };
399
+ }
400
+ const { stdout } = await ssh.exec(serverConfig.path, "ls");
401
+ const previousReleases = stdout.split("\n").map((line) => line.trim()).filter((dir) => releaseDirRegExp.test(dir) && dir !== yargs.releaseDir).sort().reverse().slice(0, Math.max(serverConfig.keepReleases - 1, 0));
402
+ const presentFiles = new Set(newRelease.files);
403
+ const warnings = [];
404
+ let linkedCount = 0;
405
+ for (const release of previousReleases) {
406
+ const releasePath = pathJoin(serverConfig.path, release);
407
+ let previousRelease;
408
+ try {
409
+ previousRelease = await readBuildManifest(ssh, releasePath);
410
+ } catch (e) {
411
+ const message = e instanceof Error ? e.message : String(e);
412
+ warnings.push(`Skipping the web assets of release ${release}: ${message}`);
413
+ continue;
414
+ }
415
+ if (!previousRelease) {
416
+ warnings.push(
417
+ `Release ${release} has no web build manifest, skipping its assets`
418
+ );
419
+ continue;
420
+ }
421
+ const missingFiles = previousRelease.files.filter(
422
+ (file) => !presentFiles.has(file)
423
+ );
424
+ if (missingFiles.length === 0) {
425
+ continue;
426
+ }
427
+ const filesByDir = /* @__PURE__ */ new Map();
428
+ for (const file of missingFiles) {
429
+ const dir = path.posix.dirname(file);
430
+ filesByDir.set(dir, [...filesByDir.get(dir) ?? [], file]);
431
+ }
432
+ try {
433
+ for (const [dir, files] of filesByDir) {
434
+ const targetDir = pathJoin(newReleasePath, newRelease.distDir, dir);
435
+ await ssh.exec(newReleasePath, "mkdir", ["-p", shellQuote(targetDir)]);
436
+ for (let i = 0; i < files.length; i += LINK_BATCH_SIZE) {
437
+ const sources = files.slice(i, i + LINK_BATCH_SIZE).map(
438
+ (file) => shellQuote(pathJoin(releasePath, previousRelease.distDir, file))
439
+ );
440
+ await ssh.exec(newReleasePath, "ln", [
441
+ "-f",
442
+ ...sources,
443
+ shellQuote(targetDir)
444
+ ]);
445
+ }
446
+ }
447
+ } catch (e) {
448
+ const message = e instanceof Error ? e.message : String(e);
449
+ warnings.push(
450
+ `Could not keep the web assets of release ${release}: ${message}`
451
+ );
452
+ continue;
453
+ }
454
+ for (const file of missingFiles) {
455
+ presentFiles.add(file);
456
+ }
457
+ linkedCount += missingFiles.length;
458
+ }
459
+ return { status: "done", linkedCount, warnings };
460
+ };
206
461
  const deployTasks = (yargs, ssh, serverConfig, serverLifecycle) => {
207
462
  const cmdPath = pathJoin(serverConfig.path, yargs.releaseDir);
208
463
  const config = {
@@ -351,6 +606,46 @@ const deployTasks = (yargs, ssh, serverConfig, serverLifecycle) => {
351
606
  })
352
607
  );
353
608
  }
609
+ tasks.push(
610
+ ...commandWithLifecycleEvents({
611
+ name: "keepAssets",
612
+ config,
613
+ skip: !yargs.keepAssets || !yargs.build || !serverConfig.sides.includes("web"),
614
+ command: {
615
+ title: `Keeping web assets of previous releases...`,
616
+ task: async (_ctx, task) => {
617
+ const result = await keepPreviousAssets(yargs, ssh, serverConfig);
618
+ if (result.status === "skipped") {
619
+ return task.skip(
620
+ c.warning(
621
+ `Warning: ${result.reason}, not keeping the web assets of previous releases`
622
+ )
623
+ );
624
+ }
625
+ task.output = [
626
+ `Linked ${result.linkedCount} file(s) from previous releases`,
627
+ ...result.warnings.map((warning) => c.warning(warning))
628
+ ].join("\n");
629
+ }
630
+ }
631
+ })
632
+ );
633
+ if (serverConfig.processNames && yargs.firstRun) {
634
+ const configFile = monitorAdapter(serverConfig).firstRunConfigFile;
635
+ if (configFile) {
636
+ tasks.push({
637
+ title: `Checking for ${configFile}...`,
638
+ task: () => {
639
+ if (!fs.existsSync(path.join(getPaths().base, configFile))) {
640
+ throw new Error(
641
+ `${configFile} is missing. The ${serverConfig.monitor} monitor reads it when starting processes for the first time. Run \`${formatCedarCommand(["setup", "deploy", "baremetal"])}\` to generate it, or deploy without \`--first-run\` if the processes are already running.`
642
+ );
643
+ }
644
+ },
645
+ skip: () => !yargs.restart
646
+ });
647
+ }
648
+ }
354
649
  tasks.push(
355
650
  ...commandWithLifecycleEvents({
356
651
  name: "symlinkCurrent",
@@ -366,56 +661,56 @@ const deployTasks = (yargs, ssh, serverConfig, serverLifecycle) => {
366
661
  })
367
662
  );
368
663
  if (serverConfig.processNames) {
664
+ const adapter = monitorAdapter(serverConfig);
665
+ const skipRestart = () => !yargs.restart;
666
+ if (yargs.firstRun) {
667
+ for (const command of adapter.firstRunSetup) {
668
+ tasks.push(monitorTask(command, ssh, serverConfig, skipRestart));
669
+ }
670
+ }
369
671
  for (const processName of serverConfig.processNames) {
370
672
  if (yargs.firstRun) {
673
+ const [firstCommand, ...followUpCommands] = adapter.firstRun(processName);
674
+ if (!firstCommand) {
675
+ continue;
676
+ }
371
677
  tasks.push(
372
678
  ...commandWithLifecycleEvents({
373
679
  name: "restart",
374
680
  config,
375
681
  skip: !yargs.restart,
376
- command: {
377
- title: `Starting ${processName} process for the first time...`,
378
- task: async () => {
379
- await ssh.exec(serverConfig.path, serverConfig.monitorCommand, [
380
- "start",
381
- pathJoin(CURRENT_RELEASE_SYMLINK_NAME, "ecosystem.config.js"),
382
- "--only",
383
- processName
384
- ]);
385
- }
386
- }
682
+ command: monitorTask(firstCommand, ssh, serverConfig)
387
683
  })
388
684
  );
389
- tasks.push({
390
- title: `Saving ${processName} state for future startup...`,
391
- task: async () => {
392
- await ssh.exec(serverConfig.path, serverConfig.monitorCommand, [
393
- "save"
394
- ]);
395
- },
396
- skip: () => !yargs.restart
397
- });
685
+ for (const command of followUpCommands) {
686
+ tasks.push(monitorTask(command, ssh, serverConfig, skipRestart));
687
+ }
398
688
  } else {
399
689
  tasks.push(
400
690
  ...commandWithLifecycleEvents({
401
691
  name: "restart",
402
692
  config,
403
693
  skip: !yargs.restart,
404
- command: {
405
- title: `Restarting ${processName} process...`,
406
- task: async () => {
407
- await restartProcessCommand(
408
- processName,
409
- ssh,
410
- serverConfig,
411
- serverConfig.path
412
- );
413
- }
414
- }
694
+ command: monitorTask(
695
+ adapter.restart(processName),
696
+ ssh,
697
+ serverConfig
698
+ )
415
699
  })
416
700
  );
417
701
  }
418
702
  }
703
+ const url = healthCheckUrl(serverConfig);
704
+ tasks.push(
705
+ ...commandWithLifecycleEvents({
706
+ name: "healthCheck",
707
+ config: { ...config, cmdPath: serverConfig.path },
708
+ skip: !yargs.restart || !url,
709
+ command: healthCheckTask(url, ssh, serverConfig, [
710
+ `The new release is live as \`current\`. Check the process logs on the server, or roll back to the previous release with \`${formatCedarCommand(["deploy", "baremetal", yargs.environment, "--rollback"])}\`.`
711
+ ])
712
+ })
713
+ );
419
714
  }
420
715
  tasks.push(
421
716
  ...commandWithLifecycleEvents({
@@ -425,10 +720,10 @@ const deployTasks = (yargs, ssh, serverConfig, serverLifecycle) => {
425
720
  command: {
426
721
  title: `Cleaning up old deploys...`,
427
722
  task: async () => {
428
- const fileStartIndex = serverConfig.keepReleases + 2;
723
+ const fileStartIndex = serverConfig.keepReleases + 1;
429
724
  await ssh.exec(
430
725
  serverConfig.path,
431
- `ls -t | tail -n +${fileStartIndex} | xargs rm -rf`
726
+ `ls -t | grep -E '${RELEASE_DIR_PATTERN}' | tail -n +${fileStartIndex} | xargs -r rm -rf`
432
727
  );
433
728
  }
434
729
  }
@@ -552,9 +847,8 @@ const warnIfUnpushedCommits = async () => {
552
847
  };
553
848
  const handler = async (yargs) => {
554
849
  const { SshExecutor } = await import("./SshExecutor.js");
555
- const tomlPath = path.join(getPaths().base, "deploy.toml");
556
- const ecosystemPath = path.join(getPaths().base, "ecosystem.config.js");
557
- if (!fs.existsSync(tomlPath) || !fs.existsSync(ecosystemPath)) {
850
+ const tomlPath = path.join(getPaths().base, CONFIG_FILENAME);
851
+ if (!fs.existsSync(tomlPath)) {
558
852
  console.error(
559
853
  c.error("\nError: Baremetal deploy has not been properly setup.\n") + `Please run \`${formatCedarCommand(["setup", "deploy", "baremetal"])}\` before deploying`
560
854
  );
@@ -587,17 +881,22 @@ const handler = async (yargs) => {
587
881
  };
588
882
  export {
589
883
  DEFAULT_SERVER_CONFIG,
884
+ RELEASE_DIR_PATTERN,
590
885
  commandWithLifecycleEvents,
591
886
  commands,
592
887
  deployTasks,
593
888
  handler,
889
+ healthCheckUrl,
890
+ keepPreviousAssets,
594
891
  lifecycleTask,
595
892
  maintenanceTasks,
893
+ manifestAssetFiles,
596
894
  parseConfig,
597
895
  rollbackTasks,
598
896
  serverConfigWithDefaults,
599
897
  throwMissingConfig,
600
898
  verifyConfig,
601
899
  verifyServerConfig,
900
+ waitForHealthCheck,
602
901
  warnIfUnpushedCommits
603
902
  };
@@ -0,0 +1,66 @@
1
+ const MONITORS = ["pm2", "systemd-user", "systemd-system"];
2
+ const pm2 = {
3
+ defaultCommand: "pm2",
4
+ firstRunConfigFile: "ecosystem.config.js",
5
+ firstRunSetup: [],
6
+ firstRun: (processName) => [
7
+ {
8
+ title: `Starting ${processName} process for the first time...`,
9
+ args: ["start", "current/ecosystem.config.js", "--only", processName]
10
+ },
11
+ {
12
+ title: `Saving ${processName} state for future startup...`,
13
+ args: ["save"]
14
+ }
15
+ ],
16
+ restart: (processName) => ({
17
+ title: `Restarting ${processName} process...`,
18
+ args: ["restart", processName]
19
+ }),
20
+ stop: (processNames) => ({
21
+ title: `Stopping ${processNames.join(", ")} processes...`,
22
+ args: ["stop", ...processNames]
23
+ }),
24
+ start: (processNames) => ({
25
+ title: `Starting ${processNames.join(", ")} processes...`,
26
+ args: ["start", ...processNames]
27
+ })
28
+ };
29
+ const systemd = (defaultCommand) => ({
30
+ defaultCommand,
31
+ firstRunSetup: [
32
+ {
33
+ title: "Reloading systemd unit files...",
34
+ args: ["daemon-reload"]
35
+ }
36
+ ],
37
+ firstRun: (processName) => [
38
+ {
39
+ title: `Enabling and starting ${processName} for the first time...`,
40
+ args: ["enable", "--now", processName]
41
+ }
42
+ ],
43
+ restart: (processName) => ({
44
+ title: `Restarting ${processName}...`,
45
+ args: ["restart", processName]
46
+ }),
47
+ stop: (processNames) => ({
48
+ title: `Stopping ${processNames.join(", ")}...`,
49
+ args: ["stop", ...processNames]
50
+ }),
51
+ start: (processNames) => ({
52
+ title: `Starting ${processNames.join(", ")}...`,
53
+ args: ["start", ...processNames]
54
+ })
55
+ });
56
+ const MONITOR_ADAPTERS = {
57
+ pm2,
58
+ "systemd-user": systemd("systemctl --user"),
59
+ "systemd-system": systemd("sudo systemctl")
60
+ };
61
+ const isMonitor = (value) => typeof value === "string" && MONITORS.includes(value);
62
+ export {
63
+ MONITORS,
64
+ MONITOR_ADAPTERS,
65
+ isMonitor
66
+ };
@@ -48,6 +48,11 @@ const builder = (yargs) => {
48
48
  default: true,
49
49
  type: "boolean"
50
50
  });
51
+ yargs.option("keep-assets", {
52
+ describe: "Hardlink previous releases' hashed web assets into the new release so open browser tabs keep working after the deploy",
53
+ default: true,
54
+ type: "boolean"
55
+ });
51
56
  yargs.option("releaseDir", {
52
57
  describe: "Directory to create for the latest release, defaults to timestamp",
53
58
  default: (/* @__PURE__ */ new Date()).toISOString().replace(/[:\-TZ]/g, "").replace(/\.\d+$/, ""),
@@ -95,6 +100,7 @@ async function handler(yargs) {
95
100
  build: yargs.build,
96
101
  restart: yargs.restart,
97
102
  cleanup: yargs.cleanup,
103
+ keepAssets: yargs.keepAssets,
98
104
  maintenance: yargs.maintenance,
99
105
  rollback: yargs.rollback,
100
106
  verbose: yargs.verbose,