Skip to content

Commit 79d7864

Browse files
committed
feat(cli): Add check-client command to verify bundle freshness
1 parent fd19f4b commit 79d7864

File tree

5 files changed

+256
-1
lines changed

5 files changed

+256
-1
lines changed

.changeset/lucky-adults-tan.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@builder.io/qwik-city': major
3+
'@builder.io/qwik': major
4+
---
5+
6+
feat(cli): Add check-client command to verify bundle freshness

packages/qwik/src/cli/add/run-add-command.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@ import type { AppCommand } from '../utils/app-command';
22
import { red } from 'kleur/colors';
33
import { runAddInteractive } from './run-add-interactive';
44
import { printAddHelp } from './print-add-help';
5-
5+
import { runQwikClientCommand } from '../check-client/run-qwik-client-command';
66
export async function runAddCommand(app: AppCommand) {
77
try {
88
const id = app.args[1];
99
if (id === 'help') {
1010
await printAddHelp(app);
1111
} else {
1212
await runAddInteractive(app, id);
13+
await runQwikClientCommand(app);
1314
}
1415
} catch (e) {
1516
console.error(`❌ ${red(String(e))}\n`);
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
#!/usr/bin/env node
2+
3+
import fs from 'fs/promises';
4+
import type { Stats } from 'fs';
5+
import path from 'path';
6+
7+
// Import Clack and Kleur for interactive prompts and colors
8+
import { intro, isCancel, log, outro, select, spinner } from '@clack/prompts';
9+
import { bye, getPackageManager } from '../utils/utils'; // Assuming these utils exist
10+
import { bgBlue, bgMagenta, bold, cyan, gray, green, red, yellow } from 'kleur/colors';
11+
import type { AppCommand } from '../utils/app-command'; // Assuming this type exists
12+
import { runInPkg } from '../utils/install-deps';
13+
14+
const DISK_DIR: string = path.resolve('dist');
15+
const SRC_DIR: string = path.resolve('src');
16+
const MANIFEST_PATH: string = path.resolve(DISK_DIR, 'q-manifest.json');
17+
const BUILD_COMMAND: string = 'npm';
18+
const BUILD_ARGS: string[] = ['run', 'build'];
19+
20+
/**
21+
* Recursively finds the latest modification time (mtime) of any file in the given directory.
22+
*
23+
* @param {string} directoryPath - The directory path to search.
24+
* @returns {Promise<number>} Returns the latest mtime (Unix timestamp in milliseconds), or 0 if the
25+
* directory doesn't exist or is empty.
26+
*/
27+
async function getLatestMtime(directoryPath: string): Promise<number> {
28+
let latestTime = 0;
29+
30+
async function traverse(dir: string): Promise<void> {
31+
let items: Array<import('fs').Dirent>;
32+
try {
33+
items = await fs.readdir(dir, { withFileTypes: true });
34+
} catch (err: any) {
35+
if (err.code !== 'ENOENT') {
36+
console.warn(`Cannot read directory ${dir}: ${err.message}`);
37+
}
38+
return;
39+
}
40+
41+
for (const item of items) {
42+
const fullPath = path.join(dir, item.name);
43+
try {
44+
if (item.isDirectory()) {
45+
await traverse(fullPath);
46+
} else if (item.isFile()) {
47+
const stats = await fs.stat(fullPath);
48+
if (stats.mtimeMs > latestTime) {
49+
latestTime = stats.mtimeMs;
50+
}
51+
}
52+
} catch (err: any) {
53+
console.warn(`Cannot access ${fullPath}: ${err.message}`);
54+
}
55+
}
56+
}
57+
58+
await traverse(directoryPath);
59+
return latestTime;
60+
}
61+
62+
/**
63+
* Handles the core logic for the 'check-client' command. Exports this function so other modules can
64+
* import and call it.
65+
*
66+
* @param {AppCommand} app - Application command context (assuming structure).
67+
*/
68+
export async function checkClientCommand(app: AppCommand): Promise<void> {
69+
// Display introductory message
70+
intro(`🚀 ${bgBlue(bold(' Qiwk Client Check '))}`);
71+
const pkgManager = getPackageManager();
72+
73+
let manifestMtime: number = 0;
74+
let manifestExists: boolean = false;
75+
let needsBuild: boolean = false;
76+
const reasonsForBuild: string[] = [];
77+
78+
// Step 1: Check the manifest file
79+
log.step(`Checking manifest file: ${cyan(MANIFEST_PATH)}`);
80+
try {
81+
// Get stats for the manifest file
82+
const stats: Stats = await fs.stat(MANIFEST_PATH); // Use the resolved path
83+
manifestMtime = stats.mtimeMs;
84+
manifestExists = true;
85+
log.info(`Manifest file found, modified: ${gray(new Date(manifestMtime).toLocaleString())}`);
86+
} catch (err: any) {
87+
// Handle errors accessing the manifest file
88+
if (err.code === 'ENOENT') {
89+
log.warn(`Manifest file not found: ${yellow(MANIFEST_PATH)}`);
90+
needsBuild = true;
91+
reasonsForBuild.push('Manifest file not found');
92+
} else {
93+
log.error(`Error accessing manifest file ${MANIFEST_PATH}: ${err.message}`);
94+
needsBuild = true;
95+
reasonsForBuild.push(`Cannot access manifest file (${err.code})`);
96+
}
97+
}
98+
99+
// Step 2: Check the source directory
100+
log.step(`Checking source directory: ${cyan(SRC_DIR)}`);
101+
let latestSrcMtime: number = 0;
102+
try {
103+
// Confirm source directory exists and is accessible
104+
await fs.access(SRC_DIR);
105+
// Find the latest modification time within the source directory
106+
latestSrcMtime = await getLatestMtime(SRC_DIR);
107+
108+
if (latestSrcMtime > 0) {
109+
log.info(
110+
`Latest file modification in source directory: ${gray(new Date(latestSrcMtime).toLocaleString())}`
111+
);
112+
// Compare source modification time with manifest modification time
113+
if (manifestExists && latestSrcMtime > manifestMtime) {
114+
log.warn('Source files are newer than the manifest.');
115+
needsBuild = true;
116+
reasonsForBuild.push('Source files (src) are newer than the manifest');
117+
} else if (manifestExists) {
118+
log.info('Manifest file is up-to-date relative to source files.');
119+
}
120+
} else {
121+
// Handle case where source directory is empty or inaccessible
122+
log.info(`No source files found or directory is empty/inaccessible in '${SRC_DIR}'.`);
123+
// Note: Depending on requirements, you might want to set needsBuild = true here
124+
}
125+
} catch (err: any) {
126+
// Handle errors accessing the source directory
127+
if (err.code === 'ENOENT') {
128+
log.error(`Source directory '${SRC_DIR}' not found! Build might fail.`);
129+
// Decide whether to force build or exit if source directory access fails
130+
// Setting needsBuild = true might be appropriate if the build process creates it.
131+
} else {
132+
log.error(`Error accessing source directory '${SRC_DIR}': ${err.message}`);
133+
// Consider setting needsBuild = true or exiting based on severity
134+
}
135+
}
136+
137+
// Step 3: Perform build if necessary
138+
let buildSuccess: boolean | undefined = undefined; // Initialize build success status
139+
if (needsBuild) {
140+
log.step(yellow('Client build detected as necessary'));
141+
// Log reasons why a build is needed
142+
reasonsForBuild.forEach((reason) => log.info(` - ${reason}`));
143+
144+
// Confirm with the user before proceeding with the build
145+
const proceed: boolean | symbol = await select({
146+
message: `Proceed with client build based on the reasons above (${cyan(BUILD_COMMAND + ' ' + BUILD_ARGS.join(' '))})?`,
147+
options: [
148+
{ value: true, label: 'Yes, proceed with build', hint: 'Will run the build command' },
149+
{ value: false, label: 'No, cancel operation' },
150+
],
151+
initialValue: true,
152+
});
153+
154+
// Check if the user cancelled the operation
155+
if (isCancel(proceed) || proceed === false) {
156+
bye(); // Exit gracefully (assuming bye handles this)
157+
return; // Stop further execution
158+
}
159+
160+
// Show a spinner while the build command runs
161+
const s = spinner();
162+
s.start('Running client build...');
163+
try {
164+
// Execute the build command
165+
// Ensure runCommand returns an object with an 'install' promise or similar structure
166+
const { install } = await runInPkg(pkgManager, BUILD_ARGS, app.rootDir);
167+
buildSuccess = await install; // Await the promise indicating build completion/success
168+
169+
if (buildSuccess) {
170+
s.stop(green('Client build completed successfully.'));
171+
// **Important:** Re-check manifest mtime after successful build
172+
try {
173+
const newStats = await fs.stat(MANIFEST_PATH);
174+
manifestMtime = newStats.mtimeMs;
175+
manifestExists = true; // Mark as existing now
176+
log.info(`Manifest updated: ${gray(new Date(manifestMtime).toLocaleString())}`);
177+
} catch (statErr: any) {
178+
log.error(`Failed to re-stat manifest after build: ${statErr.message}`);
179+
// Handle this case - maybe the build didn't create the manifest?
180+
}
181+
} else {
182+
// Handle build failure reported by runCommand
183+
s.stop(red('Client build failed.'), 1);
184+
throw new Error('Client build command reported failure.');
185+
}
186+
} catch (buildError: any) {
187+
// Catch errors during the build process itself (e.g., command not found, script errors)
188+
s.stop(red('Client build failed.'), 1);
189+
log.error(`Build error: ${buildError.message}`);
190+
// Throw error to indicate failure, let the caller handle exit logic if needed
191+
throw new Error('Client build process encountered an error.');
192+
}
193+
} else {
194+
// If no build was needed
195+
log.info(green('Client is up-to-date, no build needed.'));
196+
}
197+
198+
// Step 4: Check the Disk directory (usually after build)
199+
log.step(`Checking Disk directory: ${cyan(DISK_DIR)}`);
200+
try {
201+
// Check if the disk directory exists and is accessible
202+
await fs.access(DISK_DIR);
203+
log.info(`Disk directory found: ${green(DISK_DIR)}`);
204+
} catch (err: any) {
205+
// Handle errors accessing the disk directory
206+
if (err.code === 'ENOENT') {
207+
log.warn(`Disk directory not found: ${yellow(DISK_DIR)}`);
208+
// Provide context if a build just happened
209+
if (needsBuild && buildSuccess === true) {
210+
// Check if build was attempted and successful
211+
log.warn(
212+
`Note: Build completed, but '${DISK_DIR}' directory was not found. The build process might not create it.`
213+
);
214+
} else if (needsBuild && !buildSuccess) {
215+
log.warn(`Note: Build failed, and '${DISK_DIR}' directory was not found.`);
216+
}
217+
} else {
218+
log.error(`Error accessing disk directory ${DISK_DIR}: ${err.message}`);
219+
}
220+
}
221+
222+
// Display completion message
223+
outro(`✅ ${bgMagenta(bold(' Check complete '))}`);
224+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { AppCommand } from '../utils/app-command';
2+
import { red } from 'kleur/colors';
3+
import { checkClientCommand } from './check-client-command';
4+
5+
export async function runQwikClientCommand(app: AppCommand) {
6+
try {
7+
await checkClientCommand(app);
8+
} catch (e) {
9+
console.error(`❌ ${red(String(e))}\n`);
10+
process.exit(1);
11+
}
12+
}

packages/qwik/src/cli/run.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { note, panic, pmRunCmd, printHeader, bye } from './utils/utils';
88
import { runBuildCommand } from './utils/run-build-command';
99
import { intro, isCancel, select, confirm } from '@clack/prompts';
1010
import { runV2Migration } from './migrate-v2/run-migration';
11+
import { runQwikClientCommand } from './check-client/run-qwik-client-command';
1112

1213
const SPACE_TO_HINT = 18;
1314
const COMMANDS = [
@@ -53,6 +54,13 @@ const COMMANDS = [
5354
run: (app: AppCommand) => runV2Migration(app),
5455
showInHelp: false,
5556
},
57+
{
58+
value: 'check-client',
59+
label: 'check-client',
60+
hint: 'Check if the bundle is latest version',
61+
run: (app: AppCommand) => runQwikClientCommand(app),
62+
showInHelp: true,
63+
},
5664
{
5765
value: 'help',
5866
label: 'help',
@@ -110,6 +118,10 @@ async function runCommand(app: AppCommand) {
110118
await runV2Migration(app);
111119
return;
112120
}
121+
case 'check-client': {
122+
await runQwikClientCommand(app);
123+
return;
124+
}
113125
case 'version': {
114126
printVersion();
115127
return;

0 commit comments

Comments
 (0)