u
This commit is contained in:
88
src/tools/applet.js
Normal file
88
src/tools/applet.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
/**
|
||||
* applet.js — the default `.applet.js` example files.
|
||||
*
|
||||
* Default applets are the executable counterpart to default SKILL files: small, real examples
|
||||
* seeded into every new company's VFS so an operator can open one, read it, and adapt it instead
|
||||
* of starting from a blank editor. They follow the same two rules as skills:
|
||||
*
|
||||
* 1. **Flat only.** The name is a bare file name at the VFS root — no path separators — so the
|
||||
* whole set stays enumerable and addressable by name.
|
||||
* 2. **Version-gated, never overwritten.** A seeded applet is written only when the target file
|
||||
* does not exist yet. Once an operator edits one, their version is never clobbered by a
|
||||
* later run — bump {@link DEFAULT_APPLETS_VERSION} and the file name to ship a new example.
|
||||
*/
|
||||
|
||||
/** Resolved directory holding the shipped example applets. */
|
||||
export const DEFAULT_APPLETS_DIR = fs.existsSync(path.resolve(__dirname, '../defaultApplets'))
|
||||
? path.resolve(__dirname, '../defaultApplets')
|
||||
: path.resolve(process.cwd(), 'defaultApplets');
|
||||
|
||||
/** Canonical extension for applet files, including the leading dot. */
|
||||
export const APPLET_EXT = '.applet.js';
|
||||
|
||||
/**
|
||||
* Bumped whenever the shipped examples change materially. Embedded in the seeded file name so a
|
||||
* rewritten example arrives as a NEW file rather than overwriting one an operator may have
|
||||
* customised. Keep `defaultApplets/` file names in sync with this prefix.
|
||||
*/
|
||||
export const DEFAULT_APPLETS_VERSION = 'v1';
|
||||
|
||||
/**
|
||||
* Checks whether a bare file name is an applet file name.
|
||||
* Rejects anything containing a path separator — applets are flat by contract, like skills.
|
||||
* @param {string} name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAppletFileName(name) {
|
||||
if (typeof name !== 'string') return false;
|
||||
const clean = name.trim();
|
||||
if (!clean) return false;
|
||||
if (clean.includes('/') || clean.includes('\\')) return false;
|
||||
if (!clean.toLowerCase().endsWith(APPLET_EXT)) return false;
|
||||
return clean.slice(0, -APPLET_EXT.length).trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a VFS path points at an applet file, tolerating leading `./` or `/`.
|
||||
* @param {string} p
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAppletPath(p) {
|
||||
if (typeof p !== 'string') return false;
|
||||
return isAppletFileName(p.trim().replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads every shipped example applet from {@link DEFAULT_APPLETS_DIR}.
|
||||
* @returns {Array<{ name: string, content: string }>}
|
||||
*/
|
||||
export function getDefaultApplets() {
|
||||
if (!fs.existsSync(DEFAULT_APPLETS_DIR)) return [];
|
||||
|
||||
let dirents;
|
||||
try {
|
||||
dirents = fs.readdirSync(DEFAULT_APPLETS_DIR, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const out = [];
|
||||
for (const dirent of dirents) {
|
||||
if (!dirent.isFile() || !isAppletFileName(dirent.name)) continue;
|
||||
const absPath = path.join(DEFAULT_APPLETS_DIR, dirent.name);
|
||||
try {
|
||||
out.push({ name: dirent.name, content: fs.readFileSync(absPath, 'utf8') });
|
||||
} catch (err) {
|
||||
console.warn(`[applet] failed to read default applet "${dirent.name}":`, err.message);
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return out;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import path from 'path';
|
||||
import { getStore } from '../db/store.js';
|
||||
import env from '../config/env.js';
|
||||
import { getDefaultSkills } from './skill.js';
|
||||
import { getDefaultApplets, DEFAULT_APPLETS_VERSION, APPLET_EXT } from './applet.js';
|
||||
|
||||
const FULL_HASH_REGEX = /^[a-zA-Z0-9]{8}$/;
|
||||
const VIRTUAL_IMAGE_FOLDERS = new Set(['image', 'images']);
|
||||
@@ -164,7 +165,39 @@ export async function loadDefaultSkills({ googleAuthId, store: passedStore, vfsR
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the VFS for the selected company with default SKILL files.
|
||||
* Loads the shipped example `.applet.js` files into the selected company's VFS.
|
||||
*
|
||||
* Version-gated: each example is written under a `v1-` prefixed name and only when that exact
|
||||
* file is absent, so an operator's edits survive and a new example revision ships as a new file.
|
||||
* Must stay in step with {@link loadDefaultSkills}, which runs alongside it.
|
||||
*
|
||||
* @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx
|
||||
* @returns {Promise<Array<string>>} List of seeded applet file names
|
||||
*/
|
||||
export async function loadDefaultApplets({ googleAuthId, store: passedStore, vfsRoot: passedVfsRoot } = {}) {
|
||||
const store = passedStore || await getStore();
|
||||
const vfsRoot = passedVfsRoot || await vfsRootFor(store, googleAuthId);
|
||||
fs.mkdirSync(vfsRoot, { recursive: true });
|
||||
|
||||
const seeded = [];
|
||||
for (const applet of getDefaultApplets()) {
|
||||
const name = `${DEFAULT_APPLETS_VERSION}-${applet.name}`;
|
||||
try {
|
||||
const diskPath = path.resolve(vfsRoot, name);
|
||||
if (!fs.existsSync(diskPath)) {
|
||||
fs.writeFileSync(diskPath, applet.content, 'utf8');
|
||||
}
|
||||
seeded.push(name);
|
||||
} catch (err) {
|
||||
console.error(`[vfs] failed to write default applet "${name}" to disk:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return seeded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the VFS for the selected company with default SKILL files and example applets.
|
||||
*
|
||||
* @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx
|
||||
* @returns {Promise<string>} vfsRoot directory path
|
||||
@@ -175,11 +208,13 @@ export async function initCompanyVfs({ googleAuthId, store: passedStore, vfsRoot
|
||||
fs.mkdirSync(vfsRoot, { recursive: true });
|
||||
|
||||
await loadDefaultSkills({ googleAuthId, store, vfsRoot });
|
||||
await loadDefaultApplets({ googleAuthId, store, vfsRoot });
|
||||
return vfsRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the local directory for this company's VFS exists and default skills are loaded.
|
||||
* Ensure the local directory for this company's VFS exists and the default skills and example
|
||||
* applets are loaded.
|
||||
*
|
||||
* @param {{ googleAuthId: number }} ctx
|
||||
* @returns {Promise<string>} vfsRoot directory path
|
||||
@@ -195,6 +230,12 @@ export async function prepareVfs({ googleAuthId }) {
|
||||
console.warn('[vfs] prepare warning:', err.message);
|
||||
}
|
||||
|
||||
try {
|
||||
await loadDefaultApplets({ googleAuthId, store, vfsRoot });
|
||||
} catch (err) {
|
||||
console.warn('[vfs] prepare applet warning:', err.message);
|
||||
}
|
||||
|
||||
return vfsRoot;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user