Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/cli/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# test projects created by localdev test scripts
testproject
testproject-empty
userdb-Guest

# artifacts from running cli
userdb-sk*
logs
8 changes: 4 additions & 4 deletions packages/cli/src/commands/studio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -812,18 +812,18 @@ async function buildStudioUIWithQuestions(
const buildPath = await ensureBuildDirectory(coursePath, questionsHash);

try {
// Handle special cases
if (questionsHash === 'no-questions') {
// Handle special cases (now version-aware)
if (questionsHash.startsWith('no-questions-')) {
console.log(chalk.gray(` No local questions detected, using default studio-ui`));
return await buildDefaultStudioUI(buildPath);
}

if (questionsHash === 'empty-questions') {
if (questionsHash.startsWith('empty-questions-')) {
console.log(chalk.gray(` Empty questions directory, using default studio-ui`));
return await buildDefaultStudioUI(buildPath);
}

if (questionsHash === 'hash-error') {
if (questionsHash.startsWith('hash-error-')) {
const hashError = createStudioBuildError(
StudioBuildErrorType.QUESTIONS_HASH_ERROR,
'Questions directory could not be processed',
Expand Down
71 changes: 52 additions & 19 deletions packages/cli/src/utils/questions-hash.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,71 @@
import { createHash } from 'crypto';
import { promises as fs, existsSync } from 'fs';
import path from 'path';
import { VERSION } from '../cli.js';

/**
* Calculate hash of src/questions/ directory contents
* Returns consistent hash based on file names, content, and modification times
* Calculate composite hash of src/questions/ directory contents + CLI version
*
* Returns consistent hash based on:
* - File names, content, and modification times in src/questions/
* - CLI version (ensures cache invalidation when CLI updates with new studio-ui code)
*
* Cache Invalidation Strategy:
* - Questions change: Different file content/mtime → new hash → cache miss → rebuild
* - CLI updates: Different VERSION → new hash → cache miss → rebuild with new studio-ui
* - This solves the problem where CLI updates with new studio-ui features weren't
* being picked up by existing cached builds
*
* Special Cases:
* - No questions directory: Returns "no-questions-{version-hash}"
* - Empty questions directory: Returns "empty-questions-{version-hash}"
* - Hash error: Returns "hash-error-{version-hash}"
*/
export async function hashQuestionsDirectory(coursePath: string): Promise<string> {
const questionsPath = path.join(coursePath, 'src', 'questions');
// If questions directory doesn't exist, return special "no-questions" hash

// If questions directory doesn't exist, return special "no-questions" hash with CLI version
if (!existsSync(questionsPath)) {
return 'no-questions';
const hash = createHash('sha256');
hash.update(`no-questions:${VERSION}`);
return `no-questions-${hash.digest('hex').substring(0, 8)}`;
}

const hash = createHash('sha256');

try {
// Get all files recursively, sort for consistent ordering
const files = await getAllFiles(questionsPath);
files.sort();
// If no files, return "empty-questions" hash

// If no files, return "empty-questions" hash with CLI version
if (files.length === 0) {
return 'empty-questions';
const emptyHash = createHash('sha256');
emptyHash.update(`empty-questions:${VERSION}`);
return `empty-questions-${emptyHash.digest('hex').substring(0, 8)}`;
}

// Hash each file's relative path, content, and mtime
for (const file of files) {
const relativePath = path.relative(questionsPath, file);
const stat = await fs.stat(file);
const content = await fs.readFile(file);

// Include relative path, modification time, and content in hash
hash.update(relativePath);
hash.update(stat.mtime.toISOString());
hash.update(content);
}


// Include CLI version in hash for cache invalidation on CLI updates
hash.update(`cli-version:${VERSION}`);

return hash.digest('hex').substring(0, 12); // First 12 chars for readability
} catch (error) {
console.warn(`Warning: Failed to hash questions directory: ${error}`);
return 'hash-error';
const errorHash = createHash('sha256');
errorHash.update(`hash-error:${VERSION}`);
return `hash-error-${errorHash.digest('hex').substring(0, 8)}`;
}
}

Expand All @@ -50,13 +74,13 @@ export async function hashQuestionsDirectory(coursePath: string): Promise<string
*/
async function getAllFiles(dirPath: string): Promise<string[]> {
const files: string[] = [];

try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });

for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);

if (entry.isDirectory()) {
// Recursively get files from subdirectories
const subFiles = await getAllFiles(fullPath);
Expand All @@ -72,12 +96,18 @@ async function getAllFiles(dirPath: string): Promise<string[]> {
// If directory can't be read, return empty array
console.warn(`Warning: Could not read directory ${dirPath}: ${error}`);
}

return files;
}

/**
* Get the studio build directory path for a given questions hash
*
* The questionsHash parameter already includes CLI version, creating version-aware cache directories:
* - .skuilder/studio-builds/a1b2c3d4e5f6/ (normal questions + CLI v0.1.15)
* - .skuilder/studio-builds/f6e5d4c3b2a1/ (same questions + CLI v0.1.16)
*
* This ensures that CLI updates automatically get fresh cache entries with updated studio-ui code.
*/
export function getStudioBuildPath(coursePath: string, questionsHash: string): string {
return path.join(coursePath, '.skuilder', 'studio-builds', questionsHash);
Expand All @@ -102,8 +132,11 @@ export async function ensureCacheDirectory(coursePath: string): Promise<void> {
/**
* Ensure a specific build directory exists
*/
export async function ensureBuildDirectory(coursePath: string, questionsHash: string): Promise<string> {
export async function ensureBuildDirectory(
coursePath: string,
questionsHash: string
): Promise<string> {
const buildPath = getStudioBuildPath(coursePath, questionsHash);
await fs.mkdir(buildPath, { recursive: true });
return buildPath;
}
}
3 changes: 2 additions & 1 deletion packages/edit-ui/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export default defineConfig({
build: {
target: 'es2020',
minify: 'terser',
sourcemap: true,
terserOptions: {
keep_classnames: true,
},
Expand Down Expand Up @@ -38,12 +39,12 @@ export default defineConfig({
'@vue-skuilder/db': 'VueSkuilderDb',
'@vue-skuilder/common': 'VueSkuilderCommon',
'@vue-skuilder/common-ui': 'VueSkuilderCommonUI',
sourcemap: true,
},
// Preserve CSS in the output bundles
assetFileNames: (assetInfo) => {
return `assets/[name][extname]`;
},
sourcemap: true,
},
},
// This is crucial for component libraries - allow CSS to be in chunks
Expand Down
11 changes: 8 additions & 3 deletions packages/studio-ui/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,19 @@
<v-spacer />

<!-- Navigation buttons -->
<v-btn-group v-if="courseId" class="mr-4">
<v-btn :color="$route.name === 'browse' ? 'primary' : 'secondary'" @click="$router.push('/')">
<div v-if="courseId" class="d-flex mr-4">
<v-btn
:color="$route.name === 'browse' ? 'primary' : 'secondary'"
@click="$router.push('/')"
class="mr-2"

Check warning on line 15 in packages/studio-ui/src/App.vue

View workflow job for this annotation

GitHub Actions / lint

Attribute "class" should go before "@click"
>
<v-icon start>mdi-magnify</v-icon>
Browse Course
</v-btn>
<v-btn
:color="$route.name === 'create-card' ? 'primary' : 'secondary'"
@click="$router.push('/create-card')"
class="mr-2"

Check warning on line 23 in packages/studio-ui/src/App.vue

View workflow job for this annotation

GitHub Actions / lint

Attribute "class" should go before "@click"
>
<v-icon start>mdi-card-plus</v-icon>
Create Card
Expand All @@ -27,7 +32,7 @@
<v-icon start>mdi-file-import</v-icon>
Bulk Import
</v-btn>
</v-btn-group>
</div>

<studio-flush v-if="courseId" :course-id="courseId" />
</v-app-bar>
Expand Down
1 change: 1 addition & 0 deletions packages/studio-ui/src/views/CreateCardView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
<!-- Data Input Form from edit-ui package -->
<data-input-form
v-if="selectedDataShape"
:key="selectedDataShapeIndex"
:course-id="courseId"
:course-cfg="courseConfig"
:data-shape="selectedDataShape"
Expand Down
Loading