openpetswithchatandmcp/apps/desktop/tests/plugin-runtime.test.ts
OpenPets Dev 6ab3bb64d8 feat(rebrand): rename OpenPets to FamiliarOS and pets to familiars
- Rename all user-facing and technical identifiers from OpenPets/Pet to FamiliarOS/Familiar.
- Rename packages from @open-pets/* to @familiaros/*; rename install-pet/pet-format packages.
- Rename plugin IDs and directories from openpets.* to familiaros.*.
- Rename IPC namespace from openpets:* to familiaros:* and state filenames from openpets-* to familiaros-* with legacy migration.
- Rename source files (pet-window, built-in-pet, default-pet-controller, etc.) to familiar equivalents.
- Update locales (en, es-419, ja, ko, pt-BR, zh-Hans, zh-Hant) and tray/pet context menu strings.
- Add Familiar naming feature: preference, settings input, tray menu display.
- Update assets and packaging config; all desktop tests pass.
2026-06-17 01:42:08 +00:00

451 lines
24 KiB
TypeScript

import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { FAMILIAROS_PLUGIN_MANIFEST_FILENAME, type FamiliarOSDeclarativePluginManifest, type FamiliarOSJavascriptPluginManifest } from "../src/plugin-manifest.js";
import { type PluginPetApi } from "../src/plugin-familiar-api.js";
import { PluginRuntime, type PluginRuntimeScheduler, type PluginTimerHandle } from "../src/plugin-runtime.js";
import type { PluginJsHost, PluginJsHostInstance, PluginJsHostStartOptions } from "../src/plugin-js-host.js";
import { initializePluginState, type PluginStateStore, type PluginStateRecord } from "../src/plugin-state.js";
let currentRoot = "";
class FakeScheduler implements PluginRuntimeScheduler {
timers: Array<{ delayMs: number; active: boolean; callback: () => void }> = [];
setTimeout(callback: () => void, delayMs: number): PluginTimerHandle {
const timer = { delayMs, active: true, callback };
this.timers.push(timer);
return { cancel: () => { timer.active = false; } };
}
fire(index = 0): void {
const timer = this.timers[index];
if (timer?.active) {
timer.active = false;
timer.callback();
}
}
activeCount(): number {
return this.timers.filter((timer) => timer.active).length;
}
}
class FakePetApi implements PluginPetApi {
events: string[] = [];
fail = false;
deferredFailure: { reject: (error: Error) => void } | undefined;
speak(message: string): void {
if (this.fail) throw new Error("familiar api failed");
this.events.push(`speak:${message}`);
}
react(reaction: string): void | Promise<void> {
if (this.deferredFailure) return new Promise((_resolve, reject) => { this.deferredFailure = { reject }; });
if (this.fail) throw new Error("familiar api failed");
this.events.push(`react:${reaction}`);
}
moveBy(options: { x: number; y: number; durationMs?: number }): void { this.events.push(`moveBy:${options.x},${options.y},${options.durationMs ?? ""}`); }
wander(options: { distance?: number; durationMs?: number }): void { this.events.push(`wander:${options.distance ?? ""},${options.durationMs ?? ""}`); }
moveToHome(): void { this.events.push("moveToHome"); }
}
class FakeJsHost implements PluginJsHost {
starts: PluginJsHostStartOptions[] = [];
instances: Array<{ stopped: boolean; id: string }> = [];
fail = false;
breakBeforeReturn = false;
async startPlugin(options: PluginJsHostStartOptions): Promise<PluginJsHostInstance> {
this.starts.push(options);
if (this.fail) throw new Error("host failed");
const instance = { stopped: false, id: options.record.id };
this.instances.push(instance);
if (options.record.approvedPermissions.includes("commands")) options.sdk?.commands.register({ id: "run", title: "Run" }, () => options.sdk?.status.set("ran"));
if (this.breakBeforeReturn) options.onBroken("early crash");
return { stop: () => { instance.stopped = true; } };
}
}
await scenario("disabled no timers", async ({ store, scheduler }) => {
addPlugin(store, { enabled: false });
await runtime(store, scheduler).start();
assert.equal(scheduler.activeCount(), 0);
});
await scenario("javascript sdk command status storage schedule and permissions", async ({ store, scheduler, petApi }) => {
const jsHost = new FakeJsHost();
addPlugin(store, { manifestVersion: 2, runtime: "javascript", approvedPermissions: ["commands", "status", "storage", "schedule", "familiar:speak"] }, jsManifest({ permissions: ["commands", "status", "storage", "schedule", "familiar:speak"] }));
const rt = runtime(store, scheduler, petApi, undefined, jsHost);
await rt.start();
assert.deepEqual(rt.getPluginState("plug").commands.map((c) => c.id), ["run"]);
await rt.executeCommand("plug", "run");
assert.equal(rt.getPluginState("plug").status?.text, "ran");
jsHost.starts[0].sdk?.storage.set("a", "b");
assert.equal(jsHost.starts[0].sdk?.storage.get("a"), "b");
assert.equal(store.getRecord("plug")?.config["storage:a"], undefined);
jsHost.starts[0].sdk?.schedule.once("s", 1, () => jsHost.starts[0].sdk?.familiar.speak("hi"));
scheduler.fire(0);
await Promise.resolve();
assert.deepEqual(petApi.events, ["speak:hi"]);
});
await scenario("javascript sdk permission rejection", async ({ store }) => {
const jsHost = new FakeJsHost();
addPlugin(store, { manifestVersion: 2, runtime: "javascript", approvedPermissions: ["commands"] }, jsManifest({ permissions: ["commands"] }));
await runtime(store, new FakeScheduler(), new FakePetApi(), undefined, jsHost).start();
assert.throws(() => jsHost.starts[0].sdk?.storage.set("a", "b"), /not approved/);
});
await scenario("javascript sdk familiar movement requires familiar move permission", async ({ store, scheduler, petApi }) => {
const jsHost = new FakeJsHost();
addPlugin(store, { manifestVersion: 2, runtime: "javascript", approvedPermissions: ["familiar:move"] }, jsManifest({ permissions: ["familiar:move"] }));
await runtime(store, scheduler, petApi, undefined, jsHost).start();
await jsHost.starts[0].sdk?.familiar.moveBy({ x: 20, y: -10, durationMs: 500 });
await jsHost.starts[0].sdk?.familiar.wander({ distance: 40, durationMs: 700 });
await jsHost.starts[0].sdk?.familiar.moveToHome();
assert.deepEqual(petApi.events, ["moveBy:20,-10,500", "wander:40,700", "moveToHome"]);
});
await scenario("javascript http fetch allows approved github host", async ({ store }) => {
const originalFetch = globalThis.fetch;
const jsHost = new FakeJsHost();
addPlugin(store, { manifestVersion: 2, runtime: "javascript", approvedPermissions: ["network"], approvedNetworkHosts: ["api.github.com"] }, jsManifest({ permissions: ["network"], network: { hosts: ["api.github.com"] } }));
globalThis.fetch = (async () => new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "content-type": "application/json", etag: "abc" } })) as typeof fetch;
try {
await runtime(store, new FakeScheduler(), new FakePetApi(), undefined, jsHost).start();
const result = await jsHost.starts[0].sdk?.http.fetch("https://api.github.com/repos/familiaros/familiaros/releases");
assert.equal(result?.status, 200);
assert.deepEqual(result?.json, { ok: true });
assert.equal(result?.headers.etag, "abc");
} finally { globalThis.fetch = originalFetch; }
});
await scenario("javascript http fetch denies unapproved host and non-get", async ({ store }) => {
const jsHost = new FakeJsHost();
addPlugin(store, { manifestVersion: 2, runtime: "javascript", approvedPermissions: ["network"], approvedNetworkHosts: ["api.github.com"] }, jsManifest({ permissions: ["network"], network: { hosts: ["api.github.com"] } }));
await runtime(store, new FakeScheduler(), new FakePetApi(), undefined, jsHost).start();
await assert.rejects(() => jsHost.starts[0].sdk!.http.fetch("https://example.com/"), /host is not approved/);
await assert.rejects(() => jsHost.starts[0].sdk!.http.fetch("https://api.github.com/", { method: "POST" }), /only supports GET/);
});
await scenario("javascript http fetch rejects oversized response", async ({ store }) => {
const originalFetch = globalThis.fetch;
const jsHost = new FakeJsHost();
addPlugin(store, { manifestVersion: 2, runtime: "javascript", approvedPermissions: ["network"], approvedNetworkHosts: ["api.github.com"] }, jsManifest({ permissions: ["network"], network: { hosts: ["api.github.com"] } }));
globalThis.fetch = (async () => new Response("x".repeat(4 * 1024 * 1024 + 1), { status: 200 })) as typeof fetch;
try {
await runtime(store, new FakeScheduler(), new FakePetApi(), undefined, jsHost).start();
await assert.rejects(() => jsHost.starts[0].sdk!.http.fetch("https://api.github.com/"), /too large/);
} finally { globalThis.fetch = originalFetch; }
});
await scenario("javascript plugin starts through host", async ({ store }) => {
const jsHost = new FakeJsHost();
addPlugin(store, { manifestVersion: 2, runtime: "javascript", approvedPermissions: [] }, jsManifest());
await runtime(store, new FakeScheduler(), new FakePetApi(), undefined, jsHost).start();
assert.equal(jsHost.starts.length, 1);
assert.equal(store.getRecord("plug")?.brokenReason, undefined);
});
await scenario("javascript stop cancels host", async ({ store }) => {
const jsHost = new FakeJsHost();
addPlugin(store, { manifestVersion: 2, runtime: "javascript", approvedPermissions: [] }, jsManifest());
const rt = runtime(store, new FakeScheduler(), new FakePetApi(), undefined, jsHost);
await rt.start();
rt.stop();
assert.equal(jsHost.instances[0].stopped, true);
});
await scenario("javascript startup failure marks broken", async ({ store }) => {
const jsHost = new FakeJsHost();
jsHost.fail = true;
addPlugin(store, { manifestVersion: 2, runtime: "javascript", approvedPermissions: [] }, jsManifest());
await runtime(store, new FakeScheduler(), new FakePetApi(), undefined, jsHost).start();
assert.match(store.getRecord("plug")?.brokenReason ?? "", /host failed/);
});
await scenario("javascript early crash marks broken", async ({ store }) => {
const jsHost = new FakeJsHost();
jsHost.breakBeforeReturn = true;
addPlugin(store, { manifestVersion: 2, runtime: "javascript", approvedPermissions: [] }, jsManifest());
await runtime(store, new FakeScheduler(), new FakePetApi(), undefined, jsHost).start();
assert.match(store.getRecord("plug")?.brokenReason ?? "", /early crash/);
});
await scenario("disabled and killed javascript plugins do not start", async ({ store }) => {
const jsHost = new FakeJsHost();
addPlugin(store, { manifestVersion: 2, runtime: "javascript", enabled: false, approvedPermissions: [] }, jsManifest());
addPlugin(store, { id: "killed", manifestVersion: 2, runtime: "javascript", catalogDisabled: true, approvedPermissions: [] }, jsManifest({ id: "killed" }));
await runtime(store, new FakeScheduler(), new FakePetApi(), undefined, jsHost).start();
assert.equal(jsHost.starts.length, 0);
});
await scenario("javascript reload replaces host", async ({ store }) => {
const jsHost = new FakeJsHost();
addPlugin(store, { manifestVersion: 2, runtime: "javascript", approvedPermissions: [] }, jsManifest());
const rt = runtime(store, new FakeScheduler(), new FakePetApi(), undefined, jsHost);
await rt.start();
await rt.reloadPlugin("plug");
assert.equal(jsHost.instances[0].stopped, true);
assert.equal(jsHost.instances[1].stopped, false);
});
await scenario("valid timer schedules and fires", async ({ store, scheduler, petApi }) => {
addPlugin(store, {}, manifest({ permissions: ["timer", "familiar:speak", "familiar:reaction"], actions: [{ type: "familiar.speak", message: "Stretch" }, { type: "familiar.react", reaction: "celebrating" }] }));
await runtime(store, scheduler, petApi).start();
assert.equal(scheduler.timers[0].delayMs, 5 * 60_000);
assert.deepEqual(petApi.events, []);
scheduler.fire(0);
await Promise.resolve();
assert.deepEqual(petApi.events, ["speak:Stretch", "react:celebrating"]);
});
await scenario("config speak and reaction refs execute", async ({ store, scheduler, petApi }) => {
addPlugin(store, { config: { message: "Hello", reaction: "celebrating" } }, manifest({ permissions: ["timer", "familiar:speak", "familiar:reaction"], configSchema: { message: { type: "text", default: "Stretch" }, reaction: { type: "select", default: "idle", options: [{ label: "Idle", value: "idle" }, { label: "Celebrate", value: "celebrating" }] } }, actions: [{ type: "familiar.speak", message: { config: "message" } }, { type: "familiar.react", reaction: { config: "reaction" } }] }));
await runtime(store, scheduler, petApi).start();
scheduler.fire(0);
await Promise.resolve();
assert.deepEqual(petApi.events, ["speak:Hello", "react:celebrating"]);
});
await scenario("invalid persisted config refs mark broken without api call", async ({ store, scheduler, petApi }) => {
addPlugin(store, { config: { message: 42 } }, manifest({ configSchema: { message: { type: "text", default: "Stretch" } }, actions: [{ type: "familiar.speak", message: { config: "message" } }] }));
await runtime(store, scheduler, petApi).start();
assert.match(store.getRecord("plug")?.brokenReason ?? "", /invalid/);
assert.equal(scheduler.activeCount(), 0);
assert.deepEqual(petApi.events, []);
});
await scenario("final config message and reaction validation applies", async ({ store }) => {
addPlugin(store, { config: { message: "https://example.test" } }, manifest({ configSchema: { message: { type: "text", default: "Stretch" } }, actions: [{ type: "familiar.speak", message: { config: "message" } }] }));
await runtime(store, new FakeScheduler()).start();
assert.match(store.getRecord("plug")?.brokenReason ?? "", /URL/);
});
await scenario("unapproved permission broken", async ({ store, scheduler }) => {
addPlugin(store, { approvedPermissions: ["timer"] }, manifest({ permissions: ["timer", "familiar:speak"] }));
await runtime(store, scheduler).start();
assert.match(store.getRecord("plug")?.brokenReason ?? "", /not approved/);
assert.equal(scheduler.activeCount(), 0);
assert.equal(store.getRecord("plug")?.enabled, true);
});
await scenario("invalid manifest and id-version mismatch broken", async ({ root, store }) => {
const first = addPlugin(store, {}, { bad: true });
await runtime(store, new FakeScheduler()).start();
assert.match(store.getRecord("plug")?.brokenReason ?? "", /validation failed/);
addPlugin(store, { id: "other" }, manifest({ id: "plug" }));
await runtime(store, new FakeScheduler()).start();
assert.match(store.getRecord("other")?.brokenReason ?? "", /id\/version/);
});
await scenario("invalid reaction and message broken", async ({ store }) => {
addPlugin(store, {}, manifest({ permissions: ["timer", "familiar:reaction"], actions: [{ type: "familiar.react", reaction: "celebrate" }] }));
addPlugin(store, { id: "bad-message" }, manifest({ id: "bad-message", actions: [{ type: "familiar.speak", message: "https://example.test" }] }));
await runtime(store, new FakeScheduler()).start();
assert.match(store.getRecord("plug")?.brokenReason ?? "", /Invalid familiar reaction/);
assert.match(store.getRecord("bad-message")?.brokenReason ?? "", /URL/);
});
await scenario("config interval below five broken", async ({ store }) => {
addPlugin(store, { config: { intervalMinutes: 4 } }, manifest({ everyMinutes: { config: "intervalMinutes" }, configSchema: { intervalMinutes: { type: "number", default: 6 } } }));
await runtime(store, new FakeScheduler()).start();
assert.match(store.getRecord("plug")?.brokenReason ?? "", /at least 5/);
});
await scenario("stop cancels and stale timers do not fire", async ({ store, scheduler, petApi }) => {
addPlugin(store);
const rt = runtime(store, scheduler, petApi);
await rt.start();
const stale = scheduler.timers[0];
rt.stop();
assert.equal(stale.active, false);
stale.callback();
assert.deepEqual(petApi.events, []);
});
await scenario("reload cancels stale timer", async ({ store, scheduler, petApi }) => {
addPlugin(store);
const rt = runtime(store, scheduler, petApi);
await rt.start();
const stale = scheduler.timers[0];
await rt.reloadPlugin("plug");
stale.callback();
assert.deepEqual(petApi.events, []);
scheduler.fire(1);
await Promise.resolve();
assert.deepEqual(petApi.events, ["speak:Stretch"]);
});
await scenario("path outside install/root and oversized rejected", async ({ root, store }) => {
const outsideRoot = tempDir();
addPlugin(store, { installPath: outsideRoot, manifestPath: writeManifest(outsideRoot, manifest()) });
await runtime(store, new FakeScheduler()).start();
assert.match(store.getRecord("plug")?.brokenReason ?? "", /outside allowed/);
const install = join(root, "oversized");
mkdirSync(install, { recursive: true });
const path = join(install, FAMILIAROS_PLUGIN_MANIFEST_FILENAME);
writeFileSync(path, JSON.stringify(manifest()) + " ".repeat(200), "utf8");
store.upsertRecord(record({ id: "oversized", installPath: install, manifestPath: path }));
await new PluginRuntime({ stateStore: store, petApi: new FakePetApi(), scheduler: new FakeScheduler(), allowedPluginRoots: [root], maxManifestBytes: 100 }).start();
assert.match(store.getRecord("oversized")?.brokenReason ?? "", /too large/);
});
await scenario("manifest outside install rejected", async ({ root, store }) => {
const goodInstall = join(root, "good");
const otherInstall = join(root, "other");
mkdirSync(goodInstall, { recursive: true });
const manifestPath = writeManifest(otherInstall, manifest());
store.upsertRecord(record({ installPath: goodInstall, manifestPath }));
await runtime(store, new FakeScheduler()).start();
assert.match(store.getRecord("plug")?.brokenReason ?? "", /outside install/);
});
await scenario("root-level evil manifest filename rejected", async ({ root, store }) => {
const install = join(root, "evil-name");
mkdirSync(install, { recursive: true });
const manifestPath = join(install, `evil-${FAMILIAROS_PLUGIN_MANIFEST_FILENAME}`);
writeFileSync(manifestPath, JSON.stringify(manifest()), "utf8");
store.upsertRecord(record({ installPath: install, manifestPath }));
await runtime(store, new FakeScheduler()).start();
assert.match(store.getRecord("plug")?.brokenReason ?? "", /manifest path is invalid/);
});
await scenario("reloadAll cancels removed plugin timers", async ({ store, scheduler, petApi }) => {
addPlugin(store);
const rt = runtime(store, scheduler, petApi);
await rt.start();
const stale = scheduler.timers[0];
store.removeRecord("plug");
await rt.reloadAll();
assert.equal(stale.active, false);
stale.callback();
assert.deepEqual(petApi.events, []);
assert.equal(scheduler.activeCount(), 0);
});
await scenario("invalid configured timer does not fall back to default", async ({ store }) => {
addPlugin(store, { config: { intervalMinutes: "10" } }, manifest({ everyMinutes: { config: "intervalMinutes" }, configSchema: { intervalMinutes: { type: "number", default: 6 } } }));
await runtime(store, new FakeScheduler()).start();
assert.match(store.getRecord("plug")?.brokenReason ?? "", /invalid|must resolve to an integer/);
});
await scenario("timer handles do not accumulate after repeated fires", async ({ store, scheduler, petApi }) => {
addPlugin(store);
await runtime(store, scheduler, petApi).start();
assert.equal(scheduler.activeCount(), 1);
scheduler.fire(0);
await Promise.resolve();
assert.equal(scheduler.activeCount(), 1);
scheduler.fire(1);
await Promise.resolve();
assert.equal(scheduler.activeCount(), 1);
});
await scenario("brokenReason clears and bad plugin not blocking good", async ({ store, scheduler }) => {
addPlugin(store, { brokenReason: "old" });
addPlugin(store, { id: "bad" }, manifest({ id: "bad", permissions: ["timer", "familiar:reaction"], actions: [{ type: "familiar.react", reaction: "celebrate" }] }));
await runtime(store, scheduler).start();
assert.equal(store.getRecord("plug")?.brokenReason, undefined);
assert.match(store.getRecord("bad")?.brokenReason ?? "", /Invalid familiar reaction/);
assert.equal(scheduler.activeCount(), 1);
});
await scenario("familiar api failure marks broken", async ({ store, scheduler, petApi }) => {
addPlugin(store);
petApi.fail = true;
await runtime(store, scheduler, petApi).start();
scheduler.fire(0);
await Promise.resolve();
assert.match(store.getRecord("plug")?.brokenReason ?? "", /familiar api failed/);
assert.equal(store.getRecord("plug")?.enabled, true);
});
await scenario("stale reload cannot resurrect disabled plugin", async ({ store, scheduler }) => {
addPlugin(store);
const rt = runtime(store, scheduler);
await rt.start();
scheduler.timers = [];
const reload = rt.reloadPlugin("plug");
const current = store.getRecord("plug");
assert.ok(current);
store.upsertRecord({ ...current, enabled: false });
await rt.reloadPlugin("plug");
await reload;
assert.equal(scheduler.activeCount(), 0);
});
await scenario("stale reload cannot resurrect stopped runtime", async ({ store, scheduler }) => {
addPlugin(store);
const rt = runtime(store, scheduler);
await rt.start();
scheduler.timers = [];
const reload = rt.reloadPlugin("plug");
rt.stop();
await reload;
assert.equal(scheduler.activeCount(), 0);
});
await scenario("stale async action failure does not break new generation", async ({ store, scheduler, petApi }) => {
addPlugin(store, {}, manifest({ permissions: ["timer", "familiar:reaction"], actions: [{ type: "familiar.react", reaction: "celebrating" }] }));
petApi.deferredFailure = { reject() {} };
const rt = runtime(store, scheduler, petApi);
await rt.start();
scheduler.fire(0);
const pending = petApi.deferredFailure;
assert.ok(pending);
await rt.reloadPlugin("plug");
pending.reject(new Error("stale failure"));
await Promise.resolve();
assert.equal(store.getRecord("plug")?.brokenReason, undefined);
assert.equal(scheduler.activeCount(), 1);
});
console.error("Plugin runtime validation passed.");
async function scenario(name: string, fn: (ctx: { root: string; store: PluginStateStore; scheduler: FakeScheduler; petApi: FakePetApi }) => Promise<void>): Promise<void> {
const root = tempDir();
currentRoot = root;
const store = initializePluginState({ statePath: join(tempDir(), "state.json") });
const scheduler = new FakeScheduler();
const petApi = new FakePetApi();
try { await fn({ root, store, scheduler, petApi }); } catch (error) { throw new Error(`${name}: ${error instanceof Error ? error.message : String(error)}`); }
}
function runtime(store: PluginStateStore, scheduler: FakeScheduler, petApi = new FakePetApi(), roots?: string[], jsHost?: PluginJsHost): PluginRuntime {
return new PluginRuntime({ stateStore: store, petApi, scheduler, allowedPluginRoots: roots ?? [currentRoot], jsHost });
}
function addPlugin(store: PluginStateStore, patch: Partial<PluginStateRecord> = {}, data: unknown = manifest()): PluginStateRecord {
const root = patch.installPath ? join(patch.installPath, "..") : currentRoot;
const id = patch.id ?? "plug";
const installPath = patch.installPath ?? join(root, id);
const manifestPath = patch.manifestPath ?? writeManifest(installPath, data);
if (isJsManifest(data)) writeFileSync(join(installPath, data.entry), "FamiliarOSPlugin.register();", "utf8");
const rec = record({ ...patch, id, installPath, manifestPath });
store.upsertRecord(rec);
return rec;
}
function record(patch: Partial<PluginStateRecord> = {}): PluginStateRecord {
return { id: patch.id ?? "plug", version: patch.version ?? "1.0.0", manifestPath: patch.manifestPath ?? "", installPath: patch.installPath ?? "", source: patch.source ?? "local", manifestVersion: patch.manifestVersion, runtime: patch.runtime, sdkVersion: patch.sdkVersion, enabled: patch.enabled ?? true, approvedPermissions: patch.approvedPermissions ?? ["timer", "familiar:speak", "familiar:reaction"], approvedNetworkHosts: patch.approvedNetworkHosts, config: patch.config ?? {}, brokenReason: patch.brokenReason, catalogDisabled: patch.catalogDisabled };
}
function manifest(patch: Partial<FamiliarOSDeclarativePluginManifest> & { everyMinutes?: FamiliarOSDeclarativePluginManifest["triggers"][number]["everyMinutes"]; actions?: FamiliarOSDeclarativePluginManifest["triggers"][number]["actions"] } = {}): FamiliarOSDeclarativePluginManifest {
return { manifestVersion: 1, id: patch.id ?? "plug", name: "Plug", version: patch.version ?? "1.0.0", runtime: "declarative", permissions: patch.permissions ?? ["timer", "familiar:speak"], configSchema: patch.configSchema, triggers: [{ on: "timer", everyMinutes: patch.everyMinutes ?? 5, actions: patch.actions ?? [{ type: "familiar.speak", message: "Stretch" }] }] };
}
function jsManifest(patch: Partial<FamiliarOSJavascriptPluginManifest> = {}): FamiliarOSJavascriptPluginManifest {
return { manifestVersion: 2, id: patch.id ?? "plug", name: "Plug", version: patch.version ?? "1.0.0", runtime: "javascript", sdkVersion: patch.sdkVersion ?? "1.0.0", entry: patch.entry ?? "index.js", permissions: patch.permissions ?? [], network: patch.network };
}
function isJsManifest(data: unknown): data is FamiliarOSJavascriptPluginManifest {
return typeof data === "object" && data !== null && "runtime" in data && data.runtime === "javascript" && "entry" in data && typeof data.entry === "string";
}
function writeManifest(dir: string, data: unknown): string {
mkdirSync(dir, { recursive: true });
const path = join(dir, FAMILIAROS_PLUGIN_MANIFEST_FILENAME);
writeFileSync(path, JSON.stringify(data), "utf8");
return path;
}
function tempDir(): string { return mkdtempSync(join(tmpdir(), "familiaros-plugin-runtime-")); }