openvibers/openvibe.examples Commits

v0.3.0: create-openvibe-app (WS-N task 6): start a new app from a tested example (web, server, bot, mod) with its smoke test, a .gitignore, the package renamed and the SDK pinned; no network, refuses a non-empty directory; runs from the tag with npx --package=<tarball> create-openvibe-app; test scaffolds every template and runs its smoke test where it lands

OP OpenVibers committed 26 Sept 2026 · 783cdb64a1 · 6 files, +204 −5


Changes

ddiff --git a/README.md b/README.md
iindex 0752eab..4429196 100644
---- a/README.md
++++ b/README.md
@@@ -3,7 +3,7 @@
> Executable public integration examples for the OpenVibe platform: the SDK, scoped credentials
> and public APIs, nothing else.
--**Status:** alpha, 0.2.0 (roadmap Wave 20). Nine examples, each with a smoke test that CI runs on
++**Status:** alpha, 0.3.0 (roadmap Wave 20; the `create-openvibe-app` scaffolder, WS-N task 6). Nine examples, each with a smoke test that CI runs on
Node 22.22.1 against `openvibe-sdk/testing`'s mock platform (no network). `npm run e2e` runs three
of them (Media, Events pull, a Tools job) against the real platform with your own sandbox app; it
is not part of CI or `npm test`, and it was not run against production as part of this release.
@@@ -34,6 +34,27 @@ Each example folder has a README, a `.env.example`, its own `package.json` and a
`test/smoke.test.js`. Configuration is environment variables only (`OV_CLIENT_ID`,
`OV_CLIENT_SECRET`, `OV_PROJECT_ID`, …); secrets are never logged or sent to a browser.
++## Start a new app
++
++`create-openvibe-app` copies one of these examples into a new folder as your starting point, with
++its smoke test, a `.gitignore`, the package renamed and the SDK pinned to the same tag. It needs no
++network beyond fetching this package.
++
++```bash
++npx --package=https://codeload.github.com/OpenVibers/OpenVibe.Examples/tar.gz/refs/tags/v0.3.0 create-openvibe-app my-app --template web
++cd my-app && npm install && cp .env.example .env && npm test
++```
++
++| Template | Starts from | For |
++|---|---|---|
++| `web` | [browser-app](examples/browser-app) | a plain web page that signs in with OpenVibe (PKCE) and calls a public API |
++| `server` | [node-server-app](examples/node-server-app) | a Node server with a client-credentials app token and registry discovery |
++| `bot` | [chat-bot](examples/chat-bot) | a chat bot that stays in its one room and always identifies as a bot |
++| `mod` | [mod-manifest](examples/mod-manifest) | a mod manifest validated against the schema, capabilities and compatibility ranges |
++
++Without `--template` it asks on a terminal (and picks `web` otherwise). `npm create openvibe-app`
++will work the same way once the package is on the npm registry; until then use the `npx` line.
++
## End-to-end walkthrough
From a new account to the examples running against the platform, with public surfaces only.
ddiff --git a/STATUS.json b/STATUS.json
iindex c363314..d2403b0 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W20",
-- "version": "0.2.0",
++ "version": "0.3.0",
"sdk": "openvibe-sdk v0.4.0",
"contracts": "openvibe-contracts v0.28.0 (mod-manifest example)",
"ci": "every example's smoke test runs on Node 22.22.1 against openvibe-sdk/testing's mock platform (developer-app rules for Events, Media and Tools as in production) plus a local Chat WebSocket mock; none reaches the network; a second job runs the developer path check (scripts/developer-path.js) against the same mock platform",
ddiff --git a/package-lock.json b/package-lock.json
iindex 44fe981..c335bf6 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,16 +1,19 @@
{
"name": "openvibe-examples",
-- "version": "0.2.0",
++ "version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-examples",
-- "version": "0.2.0",
++ "version": "0.3.0",
"license": "MIT",
"workspaces": [
"examples/*"
],
++ "bin": {
++ "create-openvibe-app": "scripts/create-openvibe-app.js"
++ },
"devDependencies": {
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0"
},
ddiff --git a/package.json b/package.json
iindex 3039e92..35b7b63 100644
---- a/package.json
++++ b/package.json
@@@ -1,10 +1,13 @@
{
"name": "openvibe-examples",
-- "version": "0.2.0",
++ "version": "0.3.0",
"private": true,
"description": "Executable public integration examples for the OpenVibe platform: SDK, scoped credentials and public APIs only.",
"license": "MIT",
"repository": "github:OpenVibers/OpenVibe.Examples",
++ "bin": {
++ "create-openvibe-app": "scripts/create-openvibe-app.js"
++ },
"workspaces": [
"examples/*"
],
ddiff --git a/scripts/create-openvibe-app.js b/scripts/create-openvibe-app.js
nnew file mode 100755
iindex 0000000..200b648
---- /dev/null
++++ b/scripts/create-openvibe-app.js
@@@ -0,0 +1,116 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * create-openvibe-app (roadmap WS-N task 6): start a new OpenVibe app from one of the tested examples.
++ *
++ * npx --package=https://codeload.github.com/OpenVibers/OpenVibe.Examples/tar.gz/refs/tags/v0.3.0 \
++ * create-openvibe-app my-app --template web
++ * node scripts/create-openvibe-app.js my-app --template bot (from a checkout)
++ *
++ * Templates are the examples in this repository, copied as they are tested in CI (smoke test
++ * included), with the package renamed, a .gitignore added and the SDK and contracts pinned to the
++ * tags the examples use. Nothing is downloaded and nothing is sent anywhere: the templates travel
++ * inside this package. The target directory must not exist or must be empty.
++ *
++ * --template <web|server|bot|mod> default: asked on a terminal, else web
++ * --list the templates, then exit
++ */
++const fs = require('fs');
++const path = require('path');
++const readline = require('readline');
++
++const ROOT = path.join(__dirname, '..');
++const VERSION = require('../package.json').version;
++const TEMPLATES = {
++ web: { example: 'browser-app', what: 'a plain web page (no build step) that signs in with OpenVibe (PKCE) and calls a public API' },
++ server: { example: 'node-server-app', what: 'a Node server with a client-credentials app token and registry discovery' },
++ bot: { example: 'chat-bot', what: 'a chat bot that stays in its one configured room and always identifies as a bot' },
++ mod: { example: 'mod-manifest', what: 'a mod manifest validated against the schema, the capability catalog and compatibility ranges' },
++};
++const SKIP = new Set(['node_modules', '.env', '.DS_Store']);
++
++function usage(code = 0) {
++ const lines = ['Usage: create-openvibe-app <directory> [--template web|server|bot|mod]', '', 'Templates:'];
++ for (const [k, t] of Object.entries(TEMPLATES)) lines.push(` ${k.padEnd(7)} ${t.what}`);
++ (code ? console.error : console.log)(lines.join('\n'));
++ process.exit(code);
++}
++
++function parse(argv) {
++ const out = { dir: null, template: null };
++ for (let i = 0; i < argv.length; i++) {
++ const a = argv[i];
++ if (a === '--help' || a === '-h') usage(0);
++ else if (a === '--list') usage(0);
++ else if (a === '--template' || a === '-t') out.template = argv[++i];
++ else if (a.startsWith('--template=')) out.template = a.slice(11);
++ else if (a.startsWith('-')) { console.error(`Unknown option ${a}`); usage(2); }
++ else if (!out.dir) out.dir = a;
++ else { console.error(`Unexpected argument ${a}`); usage(2); }
++ }
++ return out;
++}
++
++/** A package name npm accepts, from the directory name. */
++function packageName(dir) {
++ const base = path.basename(path.resolve(dir)).toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[._-]+|[-]+$/g, '');
++ return base || 'openvibe-app';
++}
++
++function copyDir(from, to) {
++ fs.mkdirSync(to, { recursive: true });
++ for (const e of fs.readdirSync(from, { withFileTypes: true })) {
++ if (SKIP.has(e.name)) continue;
++ const s = path.join(from, e.name), d = path.join(to, e.name);
++ if (e.isDirectory()) copyDir(s, d);
++ else if (e.isFile()) fs.copyFileSync(s, d);
++ }
++}
++
++async function ask() {
++ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
++ const keys = Object.keys(TEMPLATES);
++ console.log('Which template?');
++ keys.forEach((k, i) => console.log(` ${i + 1}. ${k.padEnd(7)} ${TEMPLATES[k].what}`));
++ const answer = await new Promise((r) => rl.question('Number or name [1]: ', r));
++ rl.close();
++ const t = answer.trim();
++ if (!t) return keys[0];
++ return TEMPLATES[t] ? t : keys[Number(t) - 1];
++}
++
++function create(dir, template) {
++ const t = TEMPLATES[template];
++ if (!t) throw new Error(`Unknown template "${template}" (${Object.keys(TEMPLATES).join(', ')})`);
++ const target = path.resolve(dir);
++ if (fs.existsSync(target) && fs.readdirSync(target).length) throw new Error(`${dir} exists and is not empty`);
++ const src = path.join(ROOT, 'examples', t.example);
++ copyDir(src, target);
++
++ const name = packageName(dir);
++ const pkgPath = path.join(target, 'package.json');
++ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
++ const next = { name, version: '0.1.0', private: true, description: pkg.description, ...pkg };
++ next.name = name; next.version = '0.1.0';
++ fs.writeFileSync(pkgPath, `${JSON.stringify(next, null, 2)}\n`);
++
++ fs.writeFileSync(path.join(target, '.gitignore'), 'node_modules/\n.env\n');
++ const readme = path.join(target, 'README.md');
++ const body = fs.existsSync(readme) ? fs.readFileSync(readme, 'utf8').replace(/^# .*\n/, '') : '';
++ fs.writeFileSync(readme, `# ${name}\n\nStarted with create-openvibe-app ${VERSION} from the \`${template}\` template (OpenVibe.Examples \`examples/${t.example}\`): ${t.what}.\n${body}`);
++ return { target, name };
++}
++
++async function main() {
++ const opts = parse(process.argv.slice(2));
++ if (!opts.dir) usage(2);
++ let template = opts.template;
++ if (!template) template = process.stdin.isTTY && process.stdout.isTTY ? await ask() : 'web';
++ const { target, name } = create(opts.dir, template);
++ const rel = path.relative(process.cwd(), target) || '.';
++ console.log(`\nCreated ${name} in ${rel} from the ${template} template.\n\nNext:\n cd ${rel}\n npm install\n cp .env.example .env # then fill in your app's values (never commit .env)\n npm test # the template's smoke test, no network needed\n npm start\n\nCreate a project and an app at https://openvibe.codes, and read https://openvibe.codes/docs.`);
++}
++
++if (require.main === module) main().catch((err) => { console.error(err.message); process.exit(1); });
++
++module.exports = { TEMPLATES, create, packageName };
ddiff --git a/test/create-app.test.js b/test/create-app.test.js
nnew file mode 100644
iindex 0000000..8158dcc
---- /dev/null
++++ b/test/create-app.test.js
@@@ -0,0 +1,56 @@
++'use strict';
++/**
++ * create-openvibe-app (WS-N task 6): every template scaffolds a working app. The copy is the tested
++ * example without node_modules or .env, renamed, with a .gitignore, and its own smoke test passes
++ * where it lands (resolving the SDK from this repository's install, as `npm install` would).
++ * A non-empty target and an unknown template are refused; nothing needs the network.
++ */
++const assert = require('node:assert/strict');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { spawnSync } = require('child_process');
++
++const ROOT = path.join(__dirname, '..');
++const BIN = path.join(ROOT, 'scripts', 'create-openvibe-app.js');
++const { TEMPLATES, packageName } = require(BIN);
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'create-ov-app-'));
++const run = (args, opts = {}) => spawnSync(process.execPath, [BIN, ...args], { cwd: tmp, encoding: 'utf8', ...opts });
++
++try {
++ assert.equal(require(path.join(ROOT, 'package.json')).bin['create-openvibe-app'], 'scripts/create-openvibe-app.js', 'the package exposes the bin');
++ assert.equal(packageName('My Cool App!'), 'my-cool-app');
++
++ for (const [template, t] of Object.entries(TEMPLATES)) {
++ const dir = `app-${template}`;
++ const r = run([dir, '--template', template]);
++ assert.equal(r.status, 0, r.stderr);
++ assert.match(r.stdout, new RegExp(`Created app-${template} in ${dir} from the ${template} template`));
++ const target = path.join(tmp, dir);
++ const files = fs.readdirSync(target);
++ assert.ok(!files.includes('node_modules') && !files.includes('.env'), `${template}: no node_modules or .env copied`);
++ assert.ok(files.includes('.gitignore') && files.includes('.env.example') && files.includes('README.md'), `${template}: .gitignore, .env.example, README`);
++ assert.equal(fs.readFileSync(path.join(target, '.gitignore'), 'utf8'), 'node_modules/\n.env\n');
++ const pkg = JSON.parse(fs.readFileSync(path.join(target, 'package.json'), 'utf8'));
++ const orig = require(path.join(ROOT, 'examples', t.example, 'package.json'));
++ assert.equal(pkg.name, `app-${template}`);
++ assert.equal(pkg.version, '0.1.0');
++ assert.deepEqual(pkg.dependencies || {}, orig.dependencies || {}, `${template}: the pinned dependencies are the example's`);
++ assert.match(fs.readFileSync(path.join(target, 'README.md'), 'utf8'), new RegExp(`^# app-${template}\\n\\nStarted with create-openvibe-app`));
++ // The scaffolded app's own smoke test, where it landed.
++ const smoke = spawnSync(process.execPath, ['test/smoke.test.js'], { cwd: target, encoding: 'utf8', env: { ...process.env, NODE_PATH: path.join(ROOT, 'node_modules') }, timeout: 60000 });
++ assert.equal(smoke.status, 0, `${template} smoke:\n${smoke.stdout}\n${smoke.stderr}`);
++ }
++
++ // Refusals: a non-empty directory, an unknown template.
++ let r = run(['app-web', '--template', 'web']);
++ assert.notEqual(r.status, 0); assert.match(r.stderr, /exists and is not empty/);
++ r = run(['other', '--template', 'nope']);
++ assert.notEqual(r.status, 0); assert.match(r.stderr, /Unknown template "nope"/);
++ // Without a terminal and without --template, the web template.
++ r = run(['plain'], { input: '' });
++ assert.equal(r.status, 0, r.stderr); assert.match(r.stdout, /from the web template/);
++ console.log('create-openvibe-app: all checks passed');
++} finally {
++ fs.rmSync(tmp, { recursive: true, force: true });
++}