From 0161b8d371bd5e82aa0925da2abb53ee0a27e381 Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Fri, 17 Jul 2026 17:45:28 -0700 Subject: [PATCH 01/10] feat(scripts): scaffold a new Edge App via edge-apps-scripts create - extend the create command to accept a directory argument, scaffolding a brand new Edge App from a bundled template instead of only replacing placeholders in an already-cloned project - auto-detect npm vs bun from npm_config_user_agent (overridable via --pm) and template package.json scripts/test runner accordingly - add --description, --author, --force, and --skip-install options - pull dependency versions (edge-apps, typescript, prettier, vitest, jsdom, @types/node) straight from this library's own package.json so there is nothing to keep in sync manually - add scripts/create-template/ with a minimal manifest, index.html, and src/main.ts wired up to @screenly/edge-apps - document npx/bunx @screenly/edge-apps create usage in the README --- README.md | 40 +++++ scripts/cli.js | 2 +- scripts/create-template/.prettierrc.json | 6 + scripts/create-template/README.md | 37 ++++ scripts/create-template/_gitignore | 5 + scripts/create-template/index.html | 24 +++ scripts/create-template/package.json | 19 +++ scripts/create-template/screenly.yml | 18 ++ scripts/create-template/screenly_qc.yml | 18 ++ scripts/create-template/src/main.ts | 18 ++ scripts/create-template/src/style.css | 26 +++ scripts/create-template/tsconfig.json | 8 + scripts/create-template/vitest.config.ts | 7 + scripts/create.js | 205 ++++++++++++++++++++++- 14 files changed, 430 insertions(+), 3 deletions(-) create mode 100644 scripts/create-template/.prettierrc.json create mode 100644 scripts/create-template/README.md create mode 100644 scripts/create-template/_gitignore create mode 100644 scripts/create-template/index.html create mode 100644 scripts/create-template/package.json create mode 100644 scripts/create-template/screenly.yml create mode 100644 scripts/create-template/screenly_qc.yml create mode 100644 scripts/create-template/src/main.ts create mode 100644 scripts/create-template/src/style.css create mode 100644 scripts/create-template/tsconfig.json create mode 100644 scripts/create-template/vitest.config.ts diff --git a/README.md b/README.md index 312ae61..6c6e1a4 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,46 @@ Or with Bun: bun add @screenly/edge-apps ``` +## Creating a New Edge App + +Scaffold a new Edge App without installing anything first: + +```bash +npx @screenly/edge-apps create my-edge-app +``` + +Or with Bun: + +```bash +bunx @screenly/edge-apps create my-edge-app +``` + +The package manager used to invoke the command is detected automatically, so +the generated `package.json` scripts are wired up to match (`npm run ...` vs. +`bun run ...`). This generates a minimal Edge App with a `screenly.yml` +manifest, `index.html`, and a `src/main.ts` entry point wired up to this +library. + +Pass options after the directory name, for example: + +```bash +npx @screenly/edge-apps create my-edge-app --description "My Edge App" --author "Jane Doe" +``` + +| Option | Description | +| ---------------------- | ----------------------------------------------------- | +| `--description ` | Description used in `package.json` and `screenly.yml` | +| `--author ` | Author name added to `package.json` | +| `--pm ` | Force a package manager instead of auto-detecting it | +| `--force` | Write into an existing, non-empty directory | +| `--skip-install` | Skip installing dependencies after scaffolding | + +> [!NOTE] +> Running `edge-apps-scripts create` with no directory argument instead +> replaces `{{APP_NAME}}`-style placeholders in the current project — this is +> used by Edge App template repositories after cloning, and is unrelated to +> scaffolding a brand new app. + ### Local Development Setup When developing Edge Apps locally using the library from this repository, you should link the package. diff --git a/scripts/cli.js b/scripts/cli.js index 57571ed..8e4db24 100644 --- a/scripts/cli.js +++ b/scripts/cli.js @@ -42,7 +42,7 @@ const commands = { }, create: { description: - 'Initialize a scaffolded Edge App (replaces template placeholders)', + 'Scaffold a new Edge App (or replace template placeholders in the current project)', handler: createCommand, }, } diff --git a/scripts/create-template/.prettierrc.json b/scripts/create-template/.prettierrc.json new file mode 100644 index 0000000..ea9f4ac --- /dev/null +++ b/scripts/create-template/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/prettierrc", + "semi": false, + "singleQuote": true, + "printWidth": 80 +} diff --git a/scripts/create-template/README.md b/scripts/create-template/README.md new file mode 100644 index 0000000..fffdc4a --- /dev/null +++ b/scripts/create-template/README.md @@ -0,0 +1,37 @@ +# {{APP_TITLE}} + +{{APP_DESCRIPTION}} + +## Getting Started + +Install dependencies: + +```bash +{{PM_INSTALL}} +``` + +## Development + +```bash +{{PM_RUN}} dev +``` + +## Build + +```bash +{{PM_RUN}} build +``` + +## Deployment + +```bash +screenly edge-app create --name {{APP_NAME}} --in-place +{{PM_RUN}} deploy +screenly edge-app instance create +``` + +## Configuration + +| Setting | Description | Type | Default | +| --------- | ------------------------------- | -------- | ------------------ | +| `message` | The message displayed on screen | optional | `Hello, Screenly!` | diff --git a/scripts/create-template/_gitignore b/scripts/create-template/_gitignore new file mode 100644 index 0000000..499d662 --- /dev/null +++ b/scripts/create-template/_gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.log +.DS_Store +mock-data.yml diff --git a/scripts/create-template/index.html b/scripts/create-template/index.html new file mode 100644 index 0000000..32990d9 --- /dev/null +++ b/scripts/create-template/index.html @@ -0,0 +1,24 @@ + + + + + + {{APP_TITLE}} - Screenly Edge App + + + + +
+ +
+

+
+
+
+ + + diff --git a/scripts/create-template/package.json b/scripts/create-template/package.json new file mode 100644 index 0000000..f9c2f99 --- /dev/null +++ b/scripts/create-template/package.json @@ -0,0 +1,19 @@ +{ + "name": "{{APP_NAME}}", + "version": "0.1.0", + "description": "{{APP_DESCRIPTION}}", + "type": "module", + "scripts": { + "prebuild": "{{PM_RUN}} type-check", + "dev": "edge-apps-scripts dev", + "build": "edge-apps-scripts build", + "build:dev": "edge-apps-scripts build:dev", + "lint": "edge-apps-scripts lint", + "type-check": "edge-apps-scripts type-check", + "format": "prettier --write src/ README.md index.html", + "format:check": "prettier --check src/ README.md index.html", + "deploy": "{{PM_RUN}} build && screenly edge-app deploy --path=dist/" + }, + "prettier": "./.prettierrc.json", + "devDependencies": {} +} diff --git a/scripts/create-template/screenly.yml b/scripts/create-template/screenly.yml new file mode 100644 index 0000000..22ea1bb --- /dev/null +++ b/scripts/create-template/screenly.yml @@ -0,0 +1,18 @@ +--- +syntax: manifest_v1 +id: '' +description: '{{APP_DESCRIPTION}}' +ready_signal: true +settings: + message: + type: string + default_value: 'Hello, Screenly!' + title: Message + optional: true + help_text: The message displayed on screen. + sentry_dsn: + type: secret + title: Sentry Client Key + optional: true + help_text: Sentry Client Key from Sentry SDK for error capturing. + is_global: true diff --git a/scripts/create-template/screenly_qc.yml b/scripts/create-template/screenly_qc.yml new file mode 100644 index 0000000..22ea1bb --- /dev/null +++ b/scripts/create-template/screenly_qc.yml @@ -0,0 +1,18 @@ +--- +syntax: manifest_v1 +id: '' +description: '{{APP_DESCRIPTION}}' +ready_signal: true +settings: + message: + type: string + default_value: 'Hello, Screenly!' + title: Message + optional: true + help_text: The message displayed on screen. + sentry_dsn: + type: secret + title: Sentry Client Key + optional: true + help_text: Sentry Client Key from Sentry SDK for error capturing. + is_global: true diff --git a/scripts/create-template/src/main.ts b/scripts/create-template/src/main.ts new file mode 100644 index 0000000..c4b3e83 --- /dev/null +++ b/scripts/create-template/src/main.ts @@ -0,0 +1,18 @@ +import './style.css' +import '@screenly/edge-apps/components' +import { + getSettingWithDefault, + setupErrorHandling, + setupTheme, + signalReady, +} from '@screenly/edge-apps' + +document.addEventListener('DOMContentLoaded', () => { + setupErrorHandling() + setupTheme() + + const message = getSettingWithDefault('message', 'Hello, Screenly!') + document.getElementById('message')!.textContent = message + + signalReady() +}) diff --git a/scripts/create-template/src/style.css b/scripts/create-template/src/style.css new file mode 100644 index 0000000..cec7b9e --- /dev/null +++ b/scripts/create-template/src/style.css @@ -0,0 +1,26 @@ +@import '@screenly/edge-apps/styles'; + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + overflow: hidden; + font-family: 'Inter', system-ui, sans-serif; +} + +#app { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; +} + +.content { + flex: 1; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/scripts/create-template/tsconfig.json b/scripts/create-template/tsconfig.json new file mode 100644 index 0000000..f37e303 --- /dev/null +++ b/scripts/create-template/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@screenly/edge-apps/tsconfig.json", + "compilerOptions": { + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/scripts/create-template/vitest.config.ts b/scripts/create-template/vitest.config.ts new file mode 100644 index 0000000..68e3449 --- /dev/null +++ b/scripts/create-template/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'jsdom', + }, +}) diff --git a/scripts/create.js b/scripts/create.js index 802b6dc..c5f5a3a 100644 --- a/scripts/create.js +++ b/scripts/create.js @@ -1,5 +1,14 @@ import fs from 'fs' import path from 'path' +import { spawnSync } from 'child_process' +import { fileURLToPath } from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const libraryRoot = path.resolve(__dirname, '..') +const templateRoot = path.resolve(__dirname, 'create-template') +const libraryPkg = JSON.parse( + fs.readFileSync(path.join(libraryRoot, 'package.json'), 'utf-8'), +) const TEXT_EXTENSIONS = new Set([ '.ts', @@ -17,6 +26,7 @@ const TEXT_EXTENSIONS = new Set([ '.svg', '.gitignore', '.ignore', + '_gitignore', ]) const SKIP_DIRS = new Set(['node_modules', 'dist', '.git']) @@ -28,6 +38,15 @@ function toTitleCase(kebab) { .join(' ') } +function toKebabCase(name) { + const kebab = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + return kebab || 'my-edge-app' +} + function walkTextFiles(dir) { const results = [] for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { @@ -54,7 +73,177 @@ function replaceInFile(filePath, replacements) { if (updated !== original) fs.writeFileSync(filePath, updated, 'utf-8') } -export async function createCommand(_args) { +function parseCreateArgs(args) { + const options = { + pm: null, + description: null, + author: null, + force: false, + skipInstall: false, + } + const positional = [] + + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg === '--pm') { + options.pm = args[++i] + } else if (arg === '--description') { + options.description = args[++i] + } else if (arg === '--author') { + options.author = args[++i] + } else if (arg === '--force') { + options.force = true + } else if (arg === '--skip-install') { + options.skipInstall = true + } else { + positional.push(arg) + } + } + + return { directory: positional[0], options } +} + +function detectPackageManager(explicit) { + if (explicit) { + if (explicit !== 'npm' && explicit !== 'bun') { + console.error( + `Unsupported package manager: ${explicit}. Use "npm" or "bun".`, + ) + return null + } + return explicit + } + + const userAgent = process.env.npm_config_user_agent || '' + return userAgent.startsWith('bun') ? 'bun' : 'npm' +} + +function finalizePackageJson(destination, pm) { + const pkgPath = path.join(destination, 'package.json') + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) + + pkg.devDependencies = { + ...pkg.devDependencies, + '@screenly/edge-apps': `^${libraryPkg.version}`, + typescript: libraryPkg.dependencies.typescript, + prettier: libraryPkg.devDependencies.prettier, + '@types/node': libraryPkg.devDependencies['@types/node'], + } + + if (pm === 'bun') { + pkg.scripts.test = 'bun test --pass-with-no-tests src/' + } else { + pkg.scripts.test = 'vitest run --passWithNoTests' + pkg.devDependencies.vitest = libraryPkg.devDependencies.vitest + pkg.devDependencies.jsdom = libraryPkg.dependencies.jsdom + } + pkg.scripts['test:unit'] = pkg.scripts.test + + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8') +} + +function installDependencies(destination, pm) { + console.log(`\nInstalling dependencies with ${pm}...`) + const result = spawnSync(pm, ['install'], { + cwd: destination, + stdio: 'inherit', + shell: process.platform === 'win32', + }) + if (result.status !== 0) { + console.warn( + `\n${pm} install failed. Run it manually inside ${destination}.`, + ) + } +} + +function printScaffoldNextSteps(destination, appName, pm, skipInstall) { + const relativePath = path.relative(process.cwd(), destination) || '.' + const runCommand = pm === 'bun' ? 'bun run' : 'npm run' + const installCommand = pm === 'bun' ? 'bun install' : 'npm install' + + const steps = [`cd ${relativePath}`] + if (skipInstall) steps.push(installCommand) + steps.push( + `Add an id to screenly.yml and screenly_qc.yml:\n screenly edge-app create --name ${appName} --in-place`, + `Start the dev server:\n ${runCommand} dev`, + `Deploy when ready:\n ${runCommand} deploy`, + ) + + console.log(` +Done! Your Edge App is ready. + +Next steps: +${steps.map((step, index) => ` ${index + 1}. ${step}`).join('\n')} +`) +} + +function scaffoldNewApp(directory, options) { + const pm = detectPackageManager(options.pm) + if (!pm) { + process.exitCode = 1 + return + } + + const destination = path.resolve(process.cwd(), directory) + + if (fs.existsSync(destination)) { + const isEmpty = fs.readdirSync(destination).length === 0 + if (!isEmpty && !options.force) { + console.error( + `Directory "${directory}" already exists and is not empty. Use --force to write into it anyway.`, + ) + process.exitCode = 1 + return + } + } else { + fs.mkdirSync(destination, { recursive: true }) + } + + const appName = toKebabCase(path.basename(destination)) + const appTitle = toTitleCase(appName) + const appDescription = + options.description || `${appTitle} - Screenly Edge App` + + console.log(`\nScaffolding a new Edge App in ${destination}`) + + fs.cpSync(templateRoot, destination, { recursive: true }) + fs.renameSync( + path.join(destination, '_gitignore'), + path.join(destination, '.gitignore'), + ) + + const replacements = { + '{{APP_NAME}}': appName, + '{{APP_TITLE}}': appTitle, + '{{APP_DESCRIPTION}}': appDescription, + '{{PM_RUN}}': pm === 'bun' ? 'bun run' : 'npm run', + '{{PM_INSTALL}}': pm === 'bun' ? 'bun install' : 'npm install', + } + for (const filePath of walkTextFiles(destination)) { + replaceInFile(filePath, replacements) + } + + finalizePackageJson(destination, pm) + + if (options.author) { + const pkgPath = path.join(destination, 'package.json') + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) + pkg.author = options.author + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8') + } + + if (pm === 'bun') { + fs.rmSync(path.join(destination, 'vitest.config.ts'), { force: true }) + } + + if (!options.skipInstall) { + installDependencies(destination, pm) + } + + printScaffoldNextSteps(destination, appName, pm, options.skipInstall) +} + +function initializeExistingProject() { const projectRoot = process.cwd() const pkgPath = path.join(projectRoot, 'package.json') @@ -64,7 +253,9 @@ export async function createCommand(_args) { } catch (error) { console.error( `Failed to read or parse package.json at ${pkgPath}. ` + - 'Make sure you are running this command from an Edge App project root.', + 'Make sure you are running this command from an Edge App project root, ' + + 'or pass a directory name to scaffold a new Edge App: ' + + 'edge-apps-scripts create ', ) if (error instanceof Error && error.message) { console.error(`Details: ${error.message}`) @@ -113,3 +304,13 @@ Next steps: npm run deploy `) } + +export async function createCommand(args) { + const { directory, options } = parseCreateArgs(args) + + if (directory) { + scaffoldNewApp(directory, options) + } else { + initializeExistingProject() + } +} From f614ef8e05efcd413f7832c479eb7993ad86d3c5 Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Fri, 17 Jul 2026 18:02:25 -0700 Subject: [PATCH 02/10] fix: address Copilot review feedback on create command - validate that --pm/--description/--author are followed by a value, failing fast instead of silently falling back to defaults - error cleanly when the scaffold destination already exists as a file instead of crashing with ENOTDIR - copy (overwrite) _gitignore to .gitignore instead of renaming, so it works when --force targets a directory that already has a .gitignore - quote the printed cd path so it works for directories with spaces - clarify the generated README's settings table: rename the ambiguous Type column (which held optional/required, not the manifest type) to Required with Yes/No values --- scripts/create-template/README.md | 4 ++-- scripts/create.js | 35 +++++++++++++++++++++++-------- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/scripts/create-template/README.md b/scripts/create-template/README.md index fffdc4a..8b47bff 100644 --- a/scripts/create-template/README.md +++ b/scripts/create-template/README.md @@ -32,6 +32,6 @@ screenly edge-app instance create ## Configuration -| Setting | Description | Type | Default | +| Setting | Description | Required | Default | | --------- | ------------------------------- | -------- | ------------------ | -| `message` | The message displayed on screen | optional | `Hello, Screenly!` | +| `message` | The message displayed on screen | No | `Hello, Screenly!` | diff --git a/scripts/create.js b/scripts/create.js index c5f5a3a..b12fdde 100644 --- a/scripts/create.js +++ b/scripts/create.js @@ -73,6 +73,8 @@ function replaceInFile(filePath, replacements) { if (updated !== original) fs.writeFileSync(filePath, updated, 'utf-8') } +const VALUE_FLAGS = new Set(['--pm', '--description', '--author']) + function parseCreateArgs(args) { const options = { pm: null, @@ -85,12 +87,15 @@ function parseCreateArgs(args) { for (let i = 0; i < args.length; i++) { const arg = args[i] - if (arg === '--pm') { - options.pm = args[++i] - } else if (arg === '--description') { - options.description = args[++i] - } else if (arg === '--author') { - options.author = args[++i] + if (VALUE_FLAGS.has(arg)) { + const value = args[i + 1] + if (value === undefined || value.startsWith('--')) { + return { error: `Missing value for ${arg}` } + } + i++ + if (arg === '--pm') options.pm = value + else if (arg === '--description') options.description = value + else options.author = value } else if (arg === '--force') { options.force = true } else if (arg === '--skip-install') { @@ -161,7 +166,7 @@ function printScaffoldNextSteps(destination, appName, pm, skipInstall) { const runCommand = pm === 'bun' ? 'bun run' : 'npm run' const installCommand = pm === 'bun' ? 'bun install' : 'npm install' - const steps = [`cd ${relativePath}`] + const steps = [`cd "${relativePath}"`] if (skipInstall) steps.push(installCommand) steps.push( `Add an id to screenly.yml and screenly_qc.yml:\n screenly edge-app create --name ${appName} --in-place`, @@ -187,6 +192,11 @@ function scaffoldNewApp(directory, options) { const destination = path.resolve(process.cwd(), directory) if (fs.existsSync(destination)) { + if (!fs.statSync(destination).isDirectory()) { + console.error(`"${directory}" already exists and is not a directory.`) + process.exitCode = 1 + return + } const isEmpty = fs.readdirSync(destination).length === 0 if (!isEmpty && !options.force) { console.error( @@ -207,10 +217,11 @@ function scaffoldNewApp(directory, options) { console.log(`\nScaffolding a new Edge App in ${destination}`) fs.cpSync(templateRoot, destination, { recursive: true }) - fs.renameSync( + fs.copyFileSync( path.join(destination, '_gitignore'), path.join(destination, '.gitignore'), ) + fs.rmSync(path.join(destination, '_gitignore')) const replacements = { '{{APP_NAME}}': appName, @@ -306,7 +317,13 @@ Next steps: } export async function createCommand(args) { - const { directory, options } = parseCreateArgs(args) + const { directory, options, error } = parseCreateArgs(args) + + if (error) { + console.error(error) + process.exitCode = 1 + return + } if (directory) { scaffoldNewApp(directory, options) From 7fa1b4f632b9e1f2e8a3c58db09a37987b5db74a Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Fri, 17 Jul 2026 18:15:33 -0700 Subject: [PATCH 03/10] fix: reject unknown/malformed create args and escape --description safely - error on unknown --flags and on more than one positional argument instead of silently scaffolding into a directory named --help or ignoring extra args - escape --description per destination format (JSON for package.json, YAML single-quote escaping for screenly.yml/screenly_qc.yml) instead of substituting the raw string, and reject embedded line breaks up front, so quotes/apostrophes in the description can no longer produce invalid JSON/YAML - split scripts/create.js into template-utils.js (shared file-walking/ substitution helpers), create-scaffold.js (new-app scaffolding), and create.js (legacy in-place placeholder flow + CLI entrypoint) to stay under the repo's max-lines lint rule --- scripts/create-scaffold.js | 222 +++++++++++++++++++++ scripts/create-template/package.json | 2 +- scripts/create-template/screenly.yml | 2 +- scripts/create-template/screenly_qc.yml | 2 +- scripts/create.js | 255 +----------------------- scripts/template-utils.js | 65 ++++++ 6 files changed, 292 insertions(+), 256 deletions(-) create mode 100644 scripts/create-scaffold.js create mode 100644 scripts/template-utils.js diff --git a/scripts/create-scaffold.js b/scripts/create-scaffold.js new file mode 100644 index 0000000..c868b54 --- /dev/null +++ b/scripts/create-scaffold.js @@ -0,0 +1,222 @@ +import fs from 'fs' +import path from 'path' +import { spawnSync } from 'child_process' +import { fileURLToPath } from 'url' +import { + toKebabCase, + toTitleCase, + walkTextFiles, + replaceInFile, +} from './template-utils.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const libraryRoot = path.resolve(__dirname, '..') +const templateRoot = path.resolve(__dirname, 'create-template') +const libraryPkg = JSON.parse( + fs.readFileSync(path.join(libraryRoot, 'package.json'), 'utf-8'), +) + +const VALUE_FLAGS = new Set(['--pm', '--description', '--author']) +const BOOLEAN_FLAGS = new Set(['--force', '--skip-install']) + +export function parseCreateArgs(args) { + const options = { + pm: null, + description: null, + author: null, + force: false, + skipInstall: false, + } + const positional = [] + + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (VALUE_FLAGS.has(arg)) { + const value = args[i + 1] + if (value === undefined || value.startsWith('--')) { + return { error: `Missing value for ${arg}` } + } + i++ + if (arg === '--pm') options.pm = value + else if (arg === '--description') options.description = value + else options.author = value + } else if (BOOLEAN_FLAGS.has(arg)) { + if (arg === '--force') options.force = true + else options.skipInstall = true + } else if (arg.startsWith('--')) { + return { error: `Unknown option: ${arg}` } + } else { + positional.push(arg) + } + } + + if (positional.length > 1) { + return { + error: `Expected a single directory argument, got: ${positional.join(', ')}`, + } + } + + return { directory: positional[0], options } +} + +function validateDescription(description) { + if (description !== null && /[\r\n]/.test(description)) { + return 'Description cannot contain line breaks.' + } + return null +} + +function detectPackageManager(explicit) { + if (explicit) { + if (explicit !== 'npm' && explicit !== 'bun') { + console.error( + `Unsupported package manager: ${explicit}. Use "npm" or "bun".`, + ) + return null + } + return explicit + } + + const userAgent = process.env.npm_config_user_agent || '' + return userAgent.startsWith('bun') ? 'bun' : 'npm' +} + +function finalizePackageJson(destination, pm) { + const pkgPath = path.join(destination, 'package.json') + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) + + pkg.devDependencies = { + ...pkg.devDependencies, + '@screenly/edge-apps': `^${libraryPkg.version}`, + typescript: libraryPkg.dependencies.typescript, + prettier: libraryPkg.devDependencies.prettier, + '@types/node': libraryPkg.devDependencies['@types/node'], + } + + if (pm === 'bun') { + pkg.scripts.test = 'bun test --pass-with-no-tests src/' + } else { + pkg.scripts.test = 'vitest run --passWithNoTests' + pkg.devDependencies.vitest = libraryPkg.devDependencies.vitest + pkg.devDependencies.jsdom = libraryPkg.dependencies.jsdom + } + pkg.scripts['test:unit'] = pkg.scripts.test + + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8') +} + +function installDependencies(destination, pm) { + console.log(`\nInstalling dependencies with ${pm}...`) + const result = spawnSync(pm, ['install'], { + cwd: destination, + stdio: 'inherit', + shell: process.platform === 'win32', + }) + if (result.status !== 0) { + console.warn( + `\n${pm} install failed. Run it manually inside ${destination}.`, + ) + } +} + +function printScaffoldNextSteps(destination, appName, pm, skipInstall) { + const relativePath = path.relative(process.cwd(), destination) || '.' + const runCommand = pm === 'bun' ? 'bun run' : 'npm run' + const installCommand = pm === 'bun' ? 'bun install' : 'npm install' + + const steps = [`cd "${relativePath}"`] + if (skipInstall) steps.push(installCommand) + steps.push( + `Add an id to screenly.yml and screenly_qc.yml:\n screenly edge-app create --name ${appName} --in-place`, + `Start the dev server:\n ${runCommand} dev`, + `Deploy when ready:\n ${runCommand} deploy`, + ) + + console.log(` +Done! Your Edge App is ready. + +Next steps: +${steps.map((step, index) => ` ${index + 1}. ${step}`).join('\n')} +`) +} + +export function scaffoldNewApp(directory, options) { + const pm = detectPackageManager(options.pm) + if (!pm) { + process.exitCode = 1 + return + } + + const descriptionError = validateDescription(options.description) + if (descriptionError) { + console.error(descriptionError) + process.exitCode = 1 + return + } + + const destination = path.resolve(process.cwd(), directory) + + if (fs.existsSync(destination)) { + if (!fs.statSync(destination).isDirectory()) { + console.error(`"${directory}" already exists and is not a directory.`) + process.exitCode = 1 + return + } + const isEmpty = fs.readdirSync(destination).length === 0 + if (!isEmpty && !options.force) { + console.error( + `Directory "${directory}" already exists and is not empty. Use --force to write into it anyway.`, + ) + process.exitCode = 1 + return + } + } else { + fs.mkdirSync(destination, { recursive: true }) + } + + const appName = toKebabCase(path.basename(destination)) + const appTitle = toTitleCase(appName) + const appDescription = + options.description || `${appTitle} - Screenly Edge App` + + console.log(`\nScaffolding a new Edge App in ${destination}`) + + fs.cpSync(templateRoot, destination, { recursive: true }) + fs.copyFileSync( + path.join(destination, '_gitignore'), + path.join(destination, '.gitignore'), + ) + fs.rmSync(path.join(destination, '_gitignore')) + + const replacements = { + '{{APP_NAME}}': appName, + '{{APP_TITLE}}': appTitle, + '{{APP_DESCRIPTION}}': appDescription, + '{{APP_DESCRIPTION_JSON}}': JSON.stringify(appDescription).slice(1, -1), + '{{APP_DESCRIPTION_YAML}}': appDescription.replace(/'/g, "''"), + '{{PM_RUN}}': pm === 'bun' ? 'bun run' : 'npm run', + '{{PM_INSTALL}}': pm === 'bun' ? 'bun install' : 'npm install', + } + for (const filePath of walkTextFiles(destination)) { + replaceInFile(filePath, replacements) + } + + finalizePackageJson(destination, pm) + + if (options.author) { + const pkgPath = path.join(destination, 'package.json') + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) + pkg.author = options.author + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8') + } + + if (pm === 'bun') { + fs.rmSync(path.join(destination, 'vitest.config.ts'), { force: true }) + } + + if (!options.skipInstall) { + installDependencies(destination, pm) + } + + printScaffoldNextSteps(destination, appName, pm, options.skipInstall) +} diff --git a/scripts/create-template/package.json b/scripts/create-template/package.json index f9c2f99..2929d57 100644 --- a/scripts/create-template/package.json +++ b/scripts/create-template/package.json @@ -1,7 +1,7 @@ { "name": "{{APP_NAME}}", "version": "0.1.0", - "description": "{{APP_DESCRIPTION}}", + "description": "{{APP_DESCRIPTION_JSON}}", "type": "module", "scripts": { "prebuild": "{{PM_RUN}} type-check", diff --git a/scripts/create-template/screenly.yml b/scripts/create-template/screenly.yml index 22ea1bb..650c3a8 100644 --- a/scripts/create-template/screenly.yml +++ b/scripts/create-template/screenly.yml @@ -1,7 +1,7 @@ --- syntax: manifest_v1 id: '' -description: '{{APP_DESCRIPTION}}' +description: '{{APP_DESCRIPTION_YAML}}' ready_signal: true settings: message: diff --git a/scripts/create-template/screenly_qc.yml b/scripts/create-template/screenly_qc.yml index 22ea1bb..650c3a8 100644 --- a/scripts/create-template/screenly_qc.yml +++ b/scripts/create-template/screenly_qc.yml @@ -1,7 +1,7 @@ --- syntax: manifest_v1 id: '' -description: '{{APP_DESCRIPTION}}' +description: '{{APP_DESCRIPTION_YAML}}' ready_signal: true settings: message: diff --git a/scripts/create.js b/scripts/create.js index b12fdde..10b6d98 100644 --- a/scripts/create.js +++ b/scripts/create.js @@ -1,258 +1,7 @@ import fs from 'fs' import path from 'path' -import { spawnSync } from 'child_process' -import { fileURLToPath } from 'url' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const libraryRoot = path.resolve(__dirname, '..') -const templateRoot = path.resolve(__dirname, 'create-template') -const libraryPkg = JSON.parse( - fs.readFileSync(path.join(libraryRoot, 'package.json'), 'utf-8'), -) - -const TEXT_EXTENSIONS = new Set([ - '.ts', - '.tsx', - '.js', - '.jsx', - '.html', - '.css', - '.scss', - '.json', - '.yml', - '.yaml', - '.md', - '.txt', - '.svg', - '.gitignore', - '.ignore', - '_gitignore', -]) - -const SKIP_DIRS = new Set(['node_modules', 'dist', '.git']) - -function toTitleCase(kebab) { - return kebab - .split('-') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') -} - -function toKebabCase(name) { - const kebab = name - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - return kebab || 'my-edge-app' -} - -function walkTextFiles(dir) { - const results = [] - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const fullPath = path.join(dir, entry.name) - if (entry.isDirectory()) { - if (!SKIP_DIRS.has(entry.name)) results.push(...walkTextFiles(fullPath)) - } else if ( - entry.isFile() && - (TEXT_EXTENSIONS.has(path.extname(entry.name)) || - TEXT_EXTENSIONS.has(entry.name)) - ) { - results.push(fullPath) - } - } - return results -} - -function replaceInFile(filePath, replacements) { - const original = fs.readFileSync(filePath, 'utf-8') - const updated = Object.entries(replacements).reduce( - (src, [placeholder, value]) => src.replaceAll(placeholder, value), - original, - ) - if (updated !== original) fs.writeFileSync(filePath, updated, 'utf-8') -} - -const VALUE_FLAGS = new Set(['--pm', '--description', '--author']) - -function parseCreateArgs(args) { - const options = { - pm: null, - description: null, - author: null, - force: false, - skipInstall: false, - } - const positional = [] - - for (let i = 0; i < args.length; i++) { - const arg = args[i] - if (VALUE_FLAGS.has(arg)) { - const value = args[i + 1] - if (value === undefined || value.startsWith('--')) { - return { error: `Missing value for ${arg}` } - } - i++ - if (arg === '--pm') options.pm = value - else if (arg === '--description') options.description = value - else options.author = value - } else if (arg === '--force') { - options.force = true - } else if (arg === '--skip-install') { - options.skipInstall = true - } else { - positional.push(arg) - } - } - - return { directory: positional[0], options } -} - -function detectPackageManager(explicit) { - if (explicit) { - if (explicit !== 'npm' && explicit !== 'bun') { - console.error( - `Unsupported package manager: ${explicit}. Use "npm" or "bun".`, - ) - return null - } - return explicit - } - - const userAgent = process.env.npm_config_user_agent || '' - return userAgent.startsWith('bun') ? 'bun' : 'npm' -} - -function finalizePackageJson(destination, pm) { - const pkgPath = path.join(destination, 'package.json') - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) - - pkg.devDependencies = { - ...pkg.devDependencies, - '@screenly/edge-apps': `^${libraryPkg.version}`, - typescript: libraryPkg.dependencies.typescript, - prettier: libraryPkg.devDependencies.prettier, - '@types/node': libraryPkg.devDependencies['@types/node'], - } - - if (pm === 'bun') { - pkg.scripts.test = 'bun test --pass-with-no-tests src/' - } else { - pkg.scripts.test = 'vitest run --passWithNoTests' - pkg.devDependencies.vitest = libraryPkg.devDependencies.vitest - pkg.devDependencies.jsdom = libraryPkg.dependencies.jsdom - } - pkg.scripts['test:unit'] = pkg.scripts.test - - fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8') -} - -function installDependencies(destination, pm) { - console.log(`\nInstalling dependencies with ${pm}...`) - const result = spawnSync(pm, ['install'], { - cwd: destination, - stdio: 'inherit', - shell: process.platform === 'win32', - }) - if (result.status !== 0) { - console.warn( - `\n${pm} install failed. Run it manually inside ${destination}.`, - ) - } -} - -function printScaffoldNextSteps(destination, appName, pm, skipInstall) { - const relativePath = path.relative(process.cwd(), destination) || '.' - const runCommand = pm === 'bun' ? 'bun run' : 'npm run' - const installCommand = pm === 'bun' ? 'bun install' : 'npm install' - - const steps = [`cd "${relativePath}"`] - if (skipInstall) steps.push(installCommand) - steps.push( - `Add an id to screenly.yml and screenly_qc.yml:\n screenly edge-app create --name ${appName} --in-place`, - `Start the dev server:\n ${runCommand} dev`, - `Deploy when ready:\n ${runCommand} deploy`, - ) - - console.log(` -Done! Your Edge App is ready. - -Next steps: -${steps.map((step, index) => ` ${index + 1}. ${step}`).join('\n')} -`) -} - -function scaffoldNewApp(directory, options) { - const pm = detectPackageManager(options.pm) - if (!pm) { - process.exitCode = 1 - return - } - - const destination = path.resolve(process.cwd(), directory) - - if (fs.existsSync(destination)) { - if (!fs.statSync(destination).isDirectory()) { - console.error(`"${directory}" already exists and is not a directory.`) - process.exitCode = 1 - return - } - const isEmpty = fs.readdirSync(destination).length === 0 - if (!isEmpty && !options.force) { - console.error( - `Directory "${directory}" already exists and is not empty. Use --force to write into it anyway.`, - ) - process.exitCode = 1 - return - } - } else { - fs.mkdirSync(destination, { recursive: true }) - } - - const appName = toKebabCase(path.basename(destination)) - const appTitle = toTitleCase(appName) - const appDescription = - options.description || `${appTitle} - Screenly Edge App` - - console.log(`\nScaffolding a new Edge App in ${destination}`) - - fs.cpSync(templateRoot, destination, { recursive: true }) - fs.copyFileSync( - path.join(destination, '_gitignore'), - path.join(destination, '.gitignore'), - ) - fs.rmSync(path.join(destination, '_gitignore')) - - const replacements = { - '{{APP_NAME}}': appName, - '{{APP_TITLE}}': appTitle, - '{{APP_DESCRIPTION}}': appDescription, - '{{PM_RUN}}': pm === 'bun' ? 'bun run' : 'npm run', - '{{PM_INSTALL}}': pm === 'bun' ? 'bun install' : 'npm install', - } - for (const filePath of walkTextFiles(destination)) { - replaceInFile(filePath, replacements) - } - - finalizePackageJson(destination, pm) - - if (options.author) { - const pkgPath = path.join(destination, 'package.json') - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) - pkg.author = options.author - fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8') - } - - if (pm === 'bun') { - fs.rmSync(path.join(destination, 'vitest.config.ts'), { force: true }) - } - - if (!options.skipInstall) { - installDependencies(destination, pm) - } - - printScaffoldNextSteps(destination, appName, pm, options.skipInstall) -} +import { toTitleCase, walkTextFiles, replaceInFile } from './template-utils.js' +import { parseCreateArgs, scaffoldNewApp } from './create-scaffold.js' function initializeExistingProject() { const projectRoot = process.cwd() diff --git a/scripts/template-utils.js b/scripts/template-utils.js new file mode 100644 index 0000000..19feea3 --- /dev/null +++ b/scripts/template-utils.js @@ -0,0 +1,65 @@ +import fs from 'fs' +import path from 'path' + +const TEXT_EXTENSIONS = new Set([ + '.ts', + '.tsx', + '.js', + '.jsx', + '.html', + '.css', + '.scss', + '.json', + '.yml', + '.yaml', + '.md', + '.txt', + '.svg', + '.gitignore', + '.ignore', + '_gitignore', +]) + +const SKIP_DIRS = new Set(['node_modules', 'dist', '.git']) + +export function toTitleCase(kebab) { + return kebab + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') +} + +export function toKebabCase(name) { + const kebab = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + return kebab || 'my-edge-app' +} + +export function walkTextFiles(dir) { + const results = [] + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) results.push(...walkTextFiles(fullPath)) + } else if ( + entry.isFile() && + (TEXT_EXTENSIONS.has(path.extname(entry.name)) || + TEXT_EXTENSIONS.has(entry.name)) + ) { + results.push(fullPath) + } + } + return results +} + +export function replaceInFile(filePath, replacements) { + const original = fs.readFileSync(filePath, 'utf-8') + const updated = Object.entries(replacements).reduce( + (src, [placeholder, value]) => src.replaceAll(placeholder, value), + original, + ) + if (updated !== original) fs.writeFileSync(filePath, updated, 'utf-8') +} From 5719c2cbbc5e5e29f2b4e26879f47af7841ed8eb Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Fri, 17 Jul 2026 18:47:45 -0700 Subject: [PATCH 04/10] fix: reject single-dash flags and preserve explicit empty --description - treat any leading-dash argument (e.g. -h) as an unknown option instead of a positional directory name - use nullish coalescing for the description default so an explicitly passed empty string (--description "") is preserved instead of being overwritten by the generated default --- scripts/create-scaffold.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/create-scaffold.js b/scripts/create-scaffold.js index c868b54..5ec0c2a 100644 --- a/scripts/create-scaffold.js +++ b/scripts/create-scaffold.js @@ -33,7 +33,7 @@ export function parseCreateArgs(args) { const arg = args[i] if (VALUE_FLAGS.has(arg)) { const value = args[i + 1] - if (value === undefined || value.startsWith('--')) { + if (value === undefined || value.startsWith('-')) { return { error: `Missing value for ${arg}` } } i++ @@ -43,7 +43,7 @@ export function parseCreateArgs(args) { } else if (BOOLEAN_FLAGS.has(arg)) { if (arg === '--force') options.force = true else options.skipInstall = true - } else if (arg.startsWith('--')) { + } else if (arg.startsWith('-')) { return { error: `Unknown option: ${arg}` } } else { positional.push(arg) @@ -177,7 +177,7 @@ export function scaffoldNewApp(directory, options) { const appName = toKebabCase(path.basename(destination)) const appTitle = toTitleCase(appName) const appDescription = - options.description || `${appTitle} - Screenly Edge App` + options.description ?? `${appTitle} - Screenly Edge App` console.log(`\nScaffolding a new Edge App in ${destination}`) From 008d1a6ad5aca69050d73c1e509b5096867b336b Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Fri, 17 Jul 2026 18:53:20 -0700 Subject: [PATCH 05/10] fix: show install step in next-steps when install fails, not just skipped Base the install-step hint on whether node_modules/ exists in the scaffolded app rather than solely on --skip-install, so a failed install (not just a skipped one) still surfaces the command to run. --- scripts/create-scaffold.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/create-scaffold.js b/scripts/create-scaffold.js index 5ec0c2a..67f6e59 100644 --- a/scripts/create-scaffold.js +++ b/scripts/create-scaffold.js @@ -119,13 +119,14 @@ function installDependencies(destination, pm) { } } -function printScaffoldNextSteps(destination, appName, pm, skipInstall) { +function printScaffoldNextSteps(destination, appName, pm) { const relativePath = path.relative(process.cwd(), destination) || '.' const runCommand = pm === 'bun' ? 'bun run' : 'npm run' const installCommand = pm === 'bun' ? 'bun install' : 'npm install' + const needsInstall = !fs.existsSync(path.join(destination, 'node_modules')) const steps = [`cd "${relativePath}"`] - if (skipInstall) steps.push(installCommand) + if (needsInstall) steps.push(installCommand) steps.push( `Add an id to screenly.yml and screenly_qc.yml:\n screenly edge-app create --name ${appName} --in-place`, `Start the dev server:\n ${runCommand} dev`, @@ -218,5 +219,5 @@ export function scaffoldNewApp(directory, options) { installDependencies(destination, pm) } - printScaffoldNextSteps(destination, appName, pm, options.skipInstall) + printScaffoldNextSteps(destination, appName, pm) } From a06f59f9099076980d33a16351c570755ee8f2f7 Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Fri, 17 Jul 2026 18:58:05 -0700 Subject: [PATCH 06/10] docs: point the fallback error message at the public npx/bunx entrypoints The error hint referenced the internal edge-apps-scripts create command instead of the documented npx/bunx @screenly/edge-apps create entrypoints users actually run. --- scripts/create.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/create.js b/scripts/create.js index 10b6d98..2e16f0e 100644 --- a/scripts/create.js +++ b/scripts/create.js @@ -15,7 +15,7 @@ function initializeExistingProject() { `Failed to read or parse package.json at ${pkgPath}. ` + 'Make sure you are running this command from an Edge App project root, ' + 'or pass a directory name to scaffold a new Edge App: ' + - 'edge-apps-scripts create ', + 'npx @screenly/edge-apps create (or bunx @screenly/edge-apps create )', ) if (error instanceof Error && error.message) { console.error(`Details: ${error.message}`) From 75a34f7e3c2cade232ee6fa03dbbf9e39dbc4fd7 Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Fri, 17 Jul 2026 19:02:43 -0700 Subject: [PATCH 07/10] fix: reject scaffold-only options when no directory is given Options like --description/--author/--pm/--force/--skip-install were silently ignored when create ran with no directory (the in-place placeholder-replacement path), which could mislead a user into thinking their option applied. Error out instead. --- scripts/create.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/scripts/create.js b/scripts/create.js index 2e16f0e..c939666 100644 --- a/scripts/create.js +++ b/scripts/create.js @@ -65,6 +65,16 @@ Next steps: `) } +function hasScaffoldOnlyOptions(options) { + return ( + options.description !== null || + options.author !== null || + options.pm !== null || + options.force || + options.skipInstall + ) +} + export async function createCommand(args) { const { directory, options, error } = parseCreateArgs(args) @@ -74,6 +84,15 @@ export async function createCommand(args) { return } + if (!directory && hasScaffoldOnlyOptions(options)) { + console.error( + 'Options like --description/--author/--pm/--force/--skip-install only apply ' + + 'when scaffolding a new app: npx @screenly/edge-apps create ', + ) + process.exitCode = 1 + return + } + if (directory) { scaffoldNewApp(directory, options) } else { From 44a49547e6850599647648eba8ba35470d346d9c Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Fri, 17 Jul 2026 19:09:55 -0700 Subject: [PATCH 08/10] fix: re-read package.json after placeholder substitution before final write initializeExistingProject() replaced placeholders in package.json's raw text via replaceInFile(), then wrote back a stale in-memory copy parsed before that substitution happened, silently discarding it. Re-read the file from disk right before stripping bun-create so the placeholder updates survive. --- scripts/create.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/create.js b/scripts/create.js index c939666..8fcf146 100644 --- a/scripts/create.js +++ b/scripts/create.js @@ -44,7 +44,7 @@ function initializeExistingProject() { replaceInFile(filePath, replacements) } - const updatedPkg = { ...pkg } + const updatedPkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) delete updatedPkg['bun-create'] fs.writeFileSync(pkgPath, JSON.stringify(updatedPkg, null, 2) + '\n', 'utf-8') From 2648f0f4b93fa634a838cecce7b20f8a1e59dd0e Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Sat, 18 Jul 2026 22:10:15 -0700 Subject: [PATCH 09/10] fix: scaffold-generator cleanup, screenshot support, and CI regression fix - drop screenly_qc.yml from the generated scaffold; the generator now only produces screenly.yml, since screenly_qc.yml is an internal Screenly convention not applicable to external users of the library - stop writing a blank/auto-generated id into screenly.yml; the field is now omitted entirely and populated by `screenly edge-app create --in-place` on registration - wire the scaffold's message text to the theme's primary accent color (var(--theme-color-primary, ...)) so overriding screenly_color_accent is actually visible - align generated package.json scripts/devDependencies with the production rss-reader-app convention (generate-mock-data, predev, cors-proxy-server, run-p dev, build:prod, npm-run-all2, @playwright/test, bun-specific type packages) - add e2e/screenshots.spec.ts to the scaffold template plus a Screenshots section in the generated README, so the screenshots script actually works out of the box - document why the scaffold ships with no test files - exclude scripts/create-template/** from the library's own vitest config; the new e2e spec was otherwise being picked up by the library's own `vitest run` and failing (no tsconfig for a not-yet-scaffolded file), which would have broken CI --- README.md | 17 ++++++---- scripts/create-scaffold.js | 13 +++++++- scripts/create-template/README.md | 6 ++++ .../create-template/e2e/screenshots.spec.ts | 33 +++++++++++++++++++ scripts/create-template/package.json | 16 ++++++--- scripts/create-template/screenly.yml | 1 - scripts/create-template/screenly_qc.yml | 18 ---------- scripts/create-template/src/style.css | 4 +++ vitest.config.ts | 3 +- 9 files changed, 79 insertions(+), 32 deletions(-) create mode 100644 scripts/create-template/e2e/screenshots.spec.ts delete mode 100644 scripts/create-template/screenly_qc.yml diff --git a/README.md b/README.md index 6c6e1a4..f74bb09 100644 --- a/README.md +++ b/README.md @@ -45,13 +45,13 @@ Pass options after the directory name, for example: npx @screenly/edge-apps create my-edge-app --description "My Edge App" --author "Jane Doe" ``` -| Option | Description | -| ---------------------- | ----------------------------------------------------- | -| `--description ` | Description used in `package.json` and `screenly.yml` | -| `--author ` | Author name added to `package.json` | -| `--pm ` | Force a package manager instead of auto-detecting it | -| `--force` | Write into an existing, non-empty directory | -| `--skip-install` | Skip installing dependencies after scaffolding | +| Option | Description | +| ---------------------- | ------------------------------------------------------------------- | +| `--description ` | Description used in `package.json`, `screenly.yml`, and `README.md` | +| `--author ` | Author name added to `package.json` | +| `--pm ` | Force a package manager instead of auto-detecting it | +| `--force` | Write into an existing, non-empty directory | +| `--skip-install` | Skip installing dependencies after scaffolding | > [!NOTE] > Running `edge-apps-scripts create` with no directory argument instead @@ -59,6 +59,9 @@ npx @screenly/edge-apps create my-edge-app --description "My Edge App" --author > used by Edge App template repositories after cloning, and is unrelated to > scaffolding a brand new app. +No test files are included — `test`/`test:unit` work out of the box on an +empty suite; add your own under `src/` when you have something to test. + ### Local Development Setup When developing Edge Apps locally using the library from this repository, you should link the package. diff --git a/scripts/create-scaffold.js b/scripts/create-scaffold.js index 67f6e59..52fff4e 100644 --- a/scripts/create-scaffold.js +++ b/scripts/create-scaffold.js @@ -16,6 +16,10 @@ const libraryPkg = JSON.parse( fs.readFileSync(path.join(libraryRoot, 'package.json'), 'utf-8'), ) +const NPM_RUN_ALL2_VERSION = '^8.0.4' +const TYPES_BUN_VERSION = '^1.3.13' +const BUN_TYPES_VERSION = '^1.3.13' + const VALUE_FLAGS = new Set(['--pm', '--description', '--author']) const BOOLEAN_FLAGS = new Set(['--force', '--skip-install']) @@ -91,10 +95,17 @@ function finalizePackageJson(destination, pm) { typescript: libraryPkg.dependencies.typescript, prettier: libraryPkg.devDependencies.prettier, '@types/node': libraryPkg.devDependencies['@types/node'], + 'npm-run-all2': NPM_RUN_ALL2_VERSION, + '@playwright/test': libraryPkg.peerDependencies['@playwright/test'], } if (pm === 'bun') { pkg.scripts.test = 'bun test --pass-with-no-tests src/' + pkg.devDependencies['@types/bun'] = TYPES_BUN_VERSION + pkg.devDependencies['bun-types'] = BUN_TYPES_VERSION + pkg.devDependencies.jsdom = libraryPkg.dependencies.jsdom + pkg.devDependencies['@types/jsdom'] = + libraryPkg.devDependencies['@types/jsdom'] } else { pkg.scripts.test = 'vitest run --passWithNoTests' pkg.devDependencies.vitest = libraryPkg.devDependencies.vitest @@ -128,7 +139,7 @@ function printScaffoldNextSteps(destination, appName, pm) { const steps = [`cd "${relativePath}"`] if (needsInstall) steps.push(installCommand) steps.push( - `Add an id to screenly.yml and screenly_qc.yml:\n screenly edge-app create --name ${appName} --in-place`, + `Register the app to get an id for screenly.yml:\n screenly edge-app create --name ${appName} --in-place`, `Start the dev server:\n ${runCommand} dev`, `Deploy when ready:\n ${runCommand} deploy`, ) diff --git a/scripts/create-template/README.md b/scripts/create-template/README.md index 8b47bff..f0812c4 100644 --- a/scripts/create-template/README.md +++ b/scripts/create-template/README.md @@ -35,3 +35,9 @@ screenly edge-app instance create | Setting | Description | Required | Default | | --------- | ------------------------------- | -------- | ------------------ | | `message` | The message displayed on screen | No | `Hello, Screenly!` | + +## Screenshots + +```bash +{{PM_RUN}} screenshots +``` diff --git a/scripts/create-template/e2e/screenshots.spec.ts b/scripts/create-template/e2e/screenshots.spec.ts new file mode 100644 index 0000000..9e556d4 --- /dev/null +++ b/scripts/create-template/e2e/screenshots.spec.ts @@ -0,0 +1,33 @@ +import { test } from '@playwright/test' +import { + createMockScreenlyForScreenshots, + getScreenshotsDir, + RESOLUTIONS, + setupClockMock, + setupScreenlyJsMock, +} from '@screenly/edge-apps/test/screenshots' +import path from 'path' + +const { screenlyJsContent } = createMockScreenlyForScreenshots() + +for (const { width, height } of RESOLUTIONS) { + test(`screenshot ${width}x${height}`, async ({ browser }) => { + const screenshotsDir = getScreenshotsDir() + + const context = await browser.newContext({ viewport: { width, height } }) + const page = await context.newPage() + + await setupClockMock(page) + await setupScreenlyJsMock(page, screenlyJsContent) + + await page.goto('/') + await page.waitForLoadState('networkidle') + + await page.screenshot({ + path: path.join(screenshotsDir, `${width}x${height}.png`), + fullPage: false, + }) + + await context.close() + }) +} diff --git a/scripts/create-template/package.json b/scripts/create-template/package.json index 2929d57..12b09a7 100644 --- a/scripts/create-template/package.json +++ b/scripts/create-template/package.json @@ -5,14 +5,22 @@ "type": "module", "scripts": { "prebuild": "{{PM_RUN}} type-check", - "dev": "edge-apps-scripts dev", + "generate-mock-data": "screenly edge-app run --generate-mock-data", + "predev": "{{PM_RUN}} generate-mock-data", + "cors-proxy-server": "edge-apps-scripts cors-proxy", + "dev": "run-p cors-proxy-server edge-apps-dev", + "edge-apps-dev": "edge-apps-scripts dev", "build": "edge-apps-scripts build", "build:dev": "edge-apps-scripts build:dev", - "lint": "edge-apps-scripts lint", - "type-check": "edge-apps-scripts type-check", + "build:prod": "edge-apps-scripts build", + "test": "", + "test:unit": "", + "lint": "edge-apps-scripts lint --fix", "format": "prettier --write src/ README.md index.html", "format:check": "prettier --check src/ README.md index.html", - "deploy": "{{PM_RUN}} build && screenly edge-app deploy --path=dist/" + "deploy": "{{PM_RUN}} build && screenly edge-app deploy --path=dist/", + "type-check": "edge-apps-scripts type-check", + "screenshots": "edge-apps-scripts screenshots" }, "prettier": "./.prettierrc.json", "devDependencies": {} diff --git a/scripts/create-template/screenly.yml b/scripts/create-template/screenly.yml index 650c3a8..d2d38da 100644 --- a/scripts/create-template/screenly.yml +++ b/scripts/create-template/screenly.yml @@ -1,6 +1,5 @@ --- syntax: manifest_v1 -id: '' description: '{{APP_DESCRIPTION_YAML}}' ready_signal: true settings: diff --git a/scripts/create-template/screenly_qc.yml b/scripts/create-template/screenly_qc.yml deleted file mode 100644 index 650c3a8..0000000 --- a/scripts/create-template/screenly_qc.yml +++ /dev/null @@ -1,18 +0,0 @@ ---- -syntax: manifest_v1 -id: '' -description: '{{APP_DESCRIPTION_YAML}}' -ready_signal: true -settings: - message: - type: string - default_value: 'Hello, Screenly!' - title: Message - optional: true - help_text: The message displayed on screen. - sentry_dsn: - type: secret - title: Sentry Client Key - optional: true - help_text: Sentry Client Key from Sentry SDK for error capturing. - is_global: true diff --git a/scripts/create-template/src/style.css b/scripts/create-template/src/style.css index cec7b9e..035e3ef 100644 --- a/scripts/create-template/src/style.css +++ b/scripts/create-template/src/style.css @@ -24,3 +24,7 @@ body { align-items: center; justify-content: center; } + +#message { + color: var(--theme-color-primary, #972eff); +} diff --git a/vitest.config.ts b/vitest.config.ts index 68e3449..477fdf3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,7 +1,8 @@ -import { defineConfig } from 'vitest/config' +import { defineConfig, configDefaults } from 'vitest/config' export default defineConfig({ test: { environment: 'jsdom', + exclude: [...configDefaults.exclude, 'scripts/create-template/**'], }, }) From fcfb04f658781cc11b72e98e4b12554582b28e6d Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Sat, 18 Jul 2026 22:31:39 -0700 Subject: [PATCH 10/10] chore: bump version to 1.2.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 47ba095..81a4fa5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@screenly/edge-apps", - "version": "1.1.2", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@screenly/edge-apps", - "version": "1.1.2", + "version": "1.2.0", "license": "MIT", "dependencies": { "@eslint/js": "^10.0.1", diff --git a/package.json b/package.json index d1be5e8..a58e336 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@screenly/edge-apps", - "version": "1.1.2", + "version": "1.2.0", "description": "A TypeScript library for interfacing with Screenly Edge Apps API", "type": "module", "sideEffects": [