release desktop v2.1.2

This commit is contained in:
Alvin Unreal 2026-05-24 16:32:31 +02:00
parent d19889b378
commit 8220d2259b
23 changed files with 1564 additions and 58 deletions

View file

@ -32,7 +32,10 @@ const validManifest = {
};
assertValid(validManifest);
assertValid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "dist/index.js", permissions: ["pet:speak", "network"], network: { hosts: ["api.example.com"] } });
assertValid({ ...validManifest, icon: "bell" });
assertInvalid({ ...validManifest, icon: "https://example.com/icon.svg" }, "invalid_icon");
assertValid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "javascript", icon: "github", sdkVersion: "1.0.0", entry: "dist/index.js", permissions: ["pet:speak", "network"], network: { hosts: ["api.example.com"] } });
assertInvalid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "javascript", icon: "<svg>", sdkVersion: "1.0.0", entry: "dist/index.js", permissions: ["pet:speak"] }, "invalid_icon");
assertInvalid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "declarative", sdkVersion: "1.0.0", entry: "dist/index.js", permissions: ["pet:speak"] }, "invalid_runtime");
assertInvalid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "../index.js", permissions: ["pet:speak"] }, "invalid_entry");
assertInvalid({ manifestVersion: 2, id: "js-plugin", name: "JS Plugin", version: "1.0.0", runtime: "javascript", sdkVersion: "1.0.0", entry: "index.js", permissions: ["network"], network: { hosts: ["*.example.com"] } }, "invalid_network_host");

View file

@ -1,6 +1,6 @@
{
"name": "@open-pets/desktop",
"version": "2.1.1",
"version": "2.1.2",
"private": true,
"description": "OpenPets tray-first desktop companion app.",
"license": "MIT",
@ -16,7 +16,7 @@
"dev": "pnpm build && electron .",
"dev:electron": "pnpm build:main && electron .",
"dev:control-center": "concurrently -k -n renderer,electron -c cyan,magenta \"pnpm dev:renderer\" \"wait-on http://127.0.0.1:5173 && cross-env OPENPETS_RENDERER_URL=http://127.0.0.1:5173 pnpm dev:electron\"",
"dev:plugins": "OPENPETS_DISABLE_PLUGIN_CATALOG=1 OPENPETS_DEV_PLUGIN_ROOTS=../../web/plugins/official pnpm dev",
"dev:plugins": "cross-env OPENPETS_DISABLE_PLUGIN_CATALOG=1 OPENPETS_DEV_PLUGIN_ROOTS=../../plugins/official pnpm dev",
"dev:debug": "OPENPETS_LOG_LEVEL=debug OPENPETS_LOG_CONSOLE=1 pnpm dev",
"package": "pnpm build && node scripts/clean-package-output.cjs && electron-builder",
"package:dir": "pnpm build && node scripts/clean-package-output.cjs && electron-builder --dir && node dist/check-packaging-contract.js --output",

View file

@ -208,25 +208,36 @@ function run(command, args, options) {
function defaultReleaseNotes() {
return [
`OpenPets ${tag} is a desktop packaging patch for macOS users.`,
`OpenPets ${tag} ships the first official plugin release with polished desktop plugin management.`,
"",
"## Fixed",
"## New: OpenPets Plugins",
"",
"- Fixed macOS release packaging so app bundles are ad-hoc signed when a Developer ID certificate is unavailable.",
"- This addresses the misleading macOS Gatekeeper dialog that can say OpenPets is damaged and can't be opened on Apple Silicon/Sequoia.",
"- Rebuilt desktop artifacts with optional packages included: macOS ZIP, Windows portable, Linux DEB/RPM, and Linux tar.gz.",
"OpenPets now includes a first-party plugin platform for optional desktop companion behaviors.",
"",
"## Still included from v2.1.0",
"## Included plugins",
"",
"- Daily Reminders — recurring local reminders with custom messages, reactions, days, and intervals.",
"- Pomodoro — focus and break sessions with pet feedback and controls.",
"- Pomodoro — focus/break sessions with pet feedback and controls.",
"- GitHub Notifications — public repository release and failed-workflow notifications. No GitHub login, token, or private repository access is used.",
"",
"## Plugin management",
"",
"- New polished Plugins window with install, enable, configure, update, reload, and uninstall actions.",
"- Friendly plugin configuration UI; no JSON editing required.",
"- Plugin permissions, network hosts, and official plugin icons are explicit.",
"- JavaScript plugins run in a sandboxed renderer with a narrow OpenPets SDK.",
"",
"## Developer notes",
"",
"- Official plugin source now lives under the repository-level plugins/official directory.",
"- Local plugin development is available through explicit developer mode and pnpm dev:desktop:plugins.",
"- Legacy sample plugins were removed from public discovery.",
"- This release includes optional desktop artifacts: macOS ZIP, Windows portable, Linux DEB/RPM, and Linux tar.gz.",
"",
"## Known limitations",
"",
"- GitHub Notifications supports public repositories only in this release.",
"- macOS artifacts are ad-hoc signed but not Developer ID notarized yet, so Gatekeeper may still require a first-open confirmation.",
"- Windows artifacts are unsigned, so SmartScreen warnings may appear.",
"- Desktop artifacts are currently unsigned, so OS security warnings may appear.",
].join("\n");
}

View file

@ -1,15 +1,16 @@
import { canonicalizePluginPermissions, type PluginPermission, type KnownPluginRuntime } from "./plugin-manifest.js";
import { canonicalizePluginPermissions, type PluginIcon, type PluginPermission, type KnownPluginRuntime } from "./plugin-manifest.js";
export type PluginCatalogEntry = { readonly id: string; readonly name: string; readonly version: string; readonly description: string; readonly runtime: "declarative"; readonly permissions: readonly PluginPermission[]; readonly downloadUrl: string; readonly sha256: string; readonly minOpenPetsVersion?: string };
export type PluginCatalogEntry = { readonly id: string; readonly name: string; readonly version: string; readonly description: string; readonly runtime: "declarative"; readonly icon?: PluginIcon; readonly permissions: readonly PluginPermission[]; readonly downloadUrl: string; readonly sha256: string; readonly minOpenPetsVersion?: string };
export type PluginCatalogEntryV2 = Omit<PluginCatalogEntry, "runtime"> & { readonly runtime: KnownPluginRuntime; readonly sdkVersion?: string; readonly maxOpenPetsVersion?: string; readonly disabled?: boolean; readonly deprecated?: boolean; readonly statusReason?: string; readonly network?: { readonly hosts: readonly string[] } };
export type PluginCatalog = { readonly version: 1; readonly generatedAt: string; readonly plugins: readonly PluginCatalogEntry[] } | { readonly version: 2; readonly generatedAt: string; readonly plugins: readonly PluginCatalogEntryV2[] };
const catalogFields = new Set(["version", "generatedAt", "plugins"]);
const entryFields = new Set(["id", "name", "version", "description", "runtime", "permissions", "downloadUrl", "sha256", "minOpenPetsVersion"]);
const entryFields = new Set(["id", "name", "version", "description", "runtime", "icon", "permissions", "downloadUrl", "sha256", "minOpenPetsVersion"]);
const entryFieldsV2 = new Set([...entryFields, "sdkVersion", "maxOpenPetsVersion", "disabled", "deprecated", "statusReason", "network"]);
const idPattern = /^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/;
const versionPattern = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
const shaPattern = /^[0-9a-f]{64}$/;
const supportedPluginIcons = new Set(["plugin", "bell", "timer", "github"]);
export function validatePluginCatalog(input: unknown): PluginCatalog {
if (!isRecord(input)) throw new Error("Plugin catalog must be an object.");
@ -26,7 +27,7 @@ export function validatePluginCatalog(input: unknown): PluginCatalog {
if (seen.has(id)) throw new Error(`Duplicate plugin id: ${id}`);
seen.add(id);
const permissions = canonicalizePluginPermissions(entry.permissions);
const base = { id, name: requireString(entry.name, "name", 1, 120), version: requireString(entry.version, "version", 1, 80, versionPattern), description: requireString(entry.description, "description", 0, 1000), runtime: requireRuntime(entry.runtime, input.version), permissions, downloadUrl: requireString(entry.downloadUrl, "downloadUrl", 1, 2048), sha256: requireString(entry.sha256, "sha256", 64, 64, shaPattern), minOpenPetsVersion: entry.minOpenPetsVersion === undefined ? undefined : requireString(entry.minOpenPetsVersion, "minOpenPetsVersion", 1, 80, versionPattern) };
const base = { id, name: requireString(entry.name, "name", 1, 120), version: requireString(entry.version, "version", 1, 80, versionPattern), description: requireString(entry.description, "description", 0, 1000), runtime: requireRuntime(entry.runtime, input.version), icon: normalizeIcon(entry.icon), permissions, downloadUrl: requireString(entry.downloadUrl, "downloadUrl", 1, 2048), sha256: requireString(entry.sha256, "sha256", 64, 64, shaPattern), minOpenPetsVersion: entry.minOpenPetsVersion === undefined ? undefined : requireString(entry.minOpenPetsVersion, "minOpenPetsVersion", 1, 80, versionPattern) };
if (input.version === 1) return base;
const hasNetworkPermission = permissions.includes("network");
if (base.runtime === "javascript" && entry.sdkVersion === undefined) throw new Error("Invalid plugin catalog sdkVersion.");
@ -39,6 +40,7 @@ export function validatePluginCatalog(input: unknown): PluginCatalog {
}
function requireRuntime(value: unknown, catalogVersion: unknown): KnownPluginRuntime { if (value !== "declarative" && !(catalogVersion === 2 && value === "javascript")) throw new Error(catalogVersion === 2 ? 'Plugin runtime must be "declarative" or "javascript".' : 'Plugin runtime must be "declarative".'); return value; }
function normalizeIcon(value: unknown): PluginIcon | undefined { if (value === undefined) return undefined; if (typeof value !== "string" || !supportedPluginIcons.has(value)) throw new Error("Invalid plugin catalog icon."); return value as PluginIcon; }
function requireBoolean(value: unknown, name: string): boolean { if (typeof value !== "boolean") throw new Error(`Invalid plugin catalog ${name}.`); return value; }
function normalizeNetwork(value: unknown): { readonly hosts: readonly string[] } | undefined { if (value === undefined) return undefined; if (!isRecord(value) || !Array.isArray(value.hosts)) throw new Error("Invalid plugin catalog network.hosts."); rejectUnknown(value, new Set(["hosts"]), "network"); return { hosts: value.hosts.map((host) => requireString(host, "network.hosts", 1, 253, /^[a-z0-9.-]+(?::\d{1,5})?$/i)) }; }
function requireString(value: unknown, name: string, min: number, max: number, pattern?: RegExp): string { if (typeof value !== "string" || value.length < min || value.length > max || (min > 0 && value.trim() === "")) throw new Error(`Invalid plugin catalog ${name}.`); if (pattern && !pattern.test(value)) throw new Error(`Invalid plugin catalog ${name}.`); return value; }

View file

@ -7,6 +7,7 @@ export type PluginRuntime = "declarative";
export type KnownPluginRuntime = PluginRuntime | "javascript";
export type PluginPermission = "pet:speak" | "pet:reaction" | "timer" | "schedule" | "storage" | "status" | "commands" | "network";
export type PluginJavascriptPermission = Exclude<PluginPermission, "timer">;
export type PluginIcon = "plugin" | "bell" | "timer" | "github";
export type PluginConfigFieldType = "text" | "textarea" | "number" | "boolean" | "select" | "time" | "multiSelect" | "list";
export type PluginConfigField = {
@ -34,6 +35,7 @@ export type OpenPetsDeclarativePluginManifest = {
name: string;
version: string;
runtime: PluginRuntime;
icon?: PluginIcon;
permissions: PluginPermission[];
configSchema?: Record<string, PluginConfigField>;
triggers: PluginTrigger[];
@ -47,6 +49,7 @@ export type OpenPetsJavascriptPluginManifest = {
runtime: "javascript";
sdkVersion: string;
entry: string;
icon?: PluginIcon;
permissions: PluginJavascriptPermission[];
network?: { hosts: string[] };
configSchema?: Record<string, PluginConfigField>;
@ -64,8 +67,8 @@ export type PluginManifestValidationResult =
| { ok: true; manifest: OpenPetsPluginManifest; errors: [] }
| { ok: false; errors: PluginManifestValidationError[] };
const topLevelFields = new Set(["manifestVersion", "id", "name", "version", "runtime", "permissions", "configSchema", "triggers"]);
const jsTopLevelFields = new Set(["manifestVersion", "id", "name", "version", "runtime", "sdkVersion", "entry", "permissions", "network", "configSchema"]);
const topLevelFields = new Set(["manifestVersion", "id", "name", "version", "runtime", "icon", "permissions", "configSchema", "triggers"]);
const jsTopLevelFields = new Set(["manifestVersion", "id", "name", "version", "runtime", "sdkVersion", "entry", "icon", "permissions", "network", "configSchema"]);
const configFieldFields = new Set(["type", "label", "description", "default", "options", "min", "max", "step", "maxLength", "maxItems", "itemSchema"]);
const configOptionFields = new Set(["label", "value"]);
const triggerFields = new Set(["on", "everyMinutes", "actions"]);
@ -74,6 +77,7 @@ const reactActionFields = new Set(["type", "reaction"]);
const supportedConfigTypes = new Set(["text", "textarea", "number", "boolean", "select", "time", "multiSelect", "list"]);
const deferredConfigTypes = new Set(["multi-select", "date", "schedule", "connection", "secret"]);
const deferredConfigFeatures = new Set(["dynamicOptions"]);
const supportedPluginIcons = new Set(["plugin", "bell", "timer", "github"]);
export const pluginPermissions = ["pet:speak", "pet:reaction", "timer", "schedule", "storage", "status", "commands", "network"] as const satisfies readonly PluginPermission[];
const javascriptPluginPermissions = ["pet:speak", "pet:reaction", "schedule", "storage", "status", "commands", "network"] as const satisfies readonly PluginJavascriptPermission[];
export const pluginPermissionSet: ReadonlySet<string> = new Set(pluginPermissions);
@ -103,6 +107,7 @@ export function validatePluginManifest(input: unknown): PluginManifestValidation
validateString(input.id, "$.id", "id", errors, /^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/);
validateString(input.name, "$.name", "name", errors);
validateString(input.version, "$.version", "version", errors, /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/);
validatePluginIcon(input.icon, errors);
if (input.runtime === "javascript") {
addError(errors, "$.runtime", "unsupported_runtime", 'Runtime "javascript" is recognized but unsupported in manifest v1. Use "declarative".');
@ -124,6 +129,7 @@ function validateJavascriptPluginManifest(input: Record<string, unknown>): Plugi
validateString(input.id, "$.id", "id", errors, /^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/);
validateString(input.name, "$.name", "name", errors);
validateString(input.version, "$.version", "version", errors, /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/);
validatePluginIcon(input.icon, errors);
if (input.runtime !== "javascript") addError(errors, "$.runtime", "invalid_runtime", 'manifestVersion 2 runtime must be "javascript".');
validateString(input.sdkVersion, "$.sdkVersion", "sdkVersion", errors, /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/);
validateEntryPath(input.entry, errors);
@ -141,6 +147,11 @@ function validateJavascriptPermissions(value: unknown, errors: PluginManifestVal
return permissions;
}
function validatePluginIcon(value: unknown, errors: PluginManifestValidationError[]): void {
if (value === undefined) return;
if (typeof value !== "string" || !supportedPluginIcons.has(value)) addError(errors, "$.icon", "invalid_icon", "icon must be one of plugin, bell, timer, or github.");
}
function validateEntryPath(value: unknown, errors: PluginManifestValidationError[]): void {
validateString(value, "$.entry", "entry", errors);
if (typeof value !== "string") return;

View file

@ -7,7 +7,7 @@ import { getEffectivePluginConfig, validatePluginConfigReplacement, type PluginC
import { publishLocalPluginSnapshot, readLocalPluginSourceManifest } from "./plugin-local-loader.js";
import { readSafePluginManifest } from "./plugin-manifest-reader.js";
import type { PluginJsHost } from "./plugin-js-host.js";
import { OPENPETS_PLUGIN_MANIFEST_FILENAME, type OpenPetsPluginManifest, type PluginPermission } from "./plugin-manifest.js";
import { OPENPETS_PLUGIN_MANIFEST_FILENAME, type OpenPetsPluginManifest, type PluginIcon, type PluginPermission } from "./plugin-manifest.js";
import { downloadCatalogPluginZip, installCatalogPluginPackage, readCatalogPluginManifestFromZip, resolveSafePluginInstallDir } from "./plugin-package.js";
import type { PluginPetApi } from "./plugin-pet-api.js";
import { JsonPluginStorageStore, type PluginCommand, type PluginLogLevel, type PluginStatus } from "./plugin-sdk-bridge.js";
@ -18,6 +18,7 @@ export type SafePluginRecord = {
readonly id: string;
readonly name?: string;
readonly version: string;
readonly icon?: PluginIcon;
readonly source: PluginSource;
readonly enabled: boolean;
readonly brokenReason?: string;
@ -35,7 +36,7 @@ export type SafePluginRecord = {
};
export type PluginServiceSnapshot = { readonly plugins: readonly SafePluginRecord[] };
export type SafeCatalogPluginRecord = { readonly id: string; readonly name: string; readonly version: string; readonly description: string; readonly runtime: "declarative" | "javascript"; readonly sdkVersion?: string; readonly permissions: readonly PluginPermission[]; readonly installed: boolean; readonly deprecated?: boolean; readonly statusReason?: string };
export type SafeCatalogPluginRecord = { readonly id: string; readonly name: string; readonly version: string; readonly description: string; readonly runtime: "declarative" | "javascript"; readonly icon?: PluginIcon; readonly sdkVersion?: string; readonly permissions: readonly PluginPermission[]; readonly installed: boolean; readonly deprecated?: boolean; readonly statusReason?: string };
export type PluginCatalogSnapshot = { readonly plugins: readonly SafeCatalogPluginRecord[] };
export type PluginServiceResult = { readonly ok: true; readonly snapshot: PluginServiceSnapshot } | { readonly ok: false; readonly error: string; readonly snapshot: PluginServiceSnapshot };
export type DevPluginLoadResult = { readonly path: string; readonly id?: string; readonly ok: true } | { readonly path: string; readonly ok: false; readonly error: string };
@ -157,7 +158,7 @@ export class PluginService {
try {
const catalog = await getPluginCatalog({ ...this.#catalogOptions, fetchImpl: this.#fetchImpl ?? this.#catalogOptions?.fetchImpl, refresh });
for (const entry of catalog.plugins) await this.#updateCatalogMetadata(entry);
return { plugins: catalog.plugins.filter((entry) => !isEntryDisabled(entry) && isCatalogEntryCompatible(entry.minOpenPetsVersion, getMaxVersion(entry), this.#currentAppVersion)).map((entry) => ({ id: entry.id, name: entry.name, version: entry.version, description: entry.description, runtime: entry.runtime, sdkVersion: getSdkVersion(entry), permissions: entry.permissions, installed: this.stateStore.getRecord(entry.id)?.source === "catalog", deprecated: isEntryDeprecated(entry) || undefined, statusReason: getStatusReason(entry) })) };
return { plugins: catalog.plugins.filter((entry) => !isEntryDisabled(entry) && isCatalogEntryCompatible(entry.minOpenPetsVersion, getMaxVersion(entry), this.#currentAppVersion)).map((entry) => ({ id: entry.id, name: entry.name, version: entry.version, description: entry.description, runtime: entry.runtime, icon: entry.icon, sdkVersion: getSdkVersion(entry), permissions: entry.permissions, installed: this.stateStore.getRecord(entry.id)?.source === "catalog", deprecated: isEntryDeprecated(entry) || undefined, statusReason: getStatusReason(entry) })) };
} catch {
return { plugins: [] };
}
@ -326,7 +327,7 @@ export class PluginService {
const manifest = await this.#readManifest(record);
const config = getEffectivePluginConfig(manifest, record.config);
const runtimeState = typeof (this.runtime as unknown as { getPluginState?: unknown }).getPluginState === "function" ? this.runtime.getPluginState(record.id) : { commands: [] };
return { ...base, brokenReason: sanitizePluginUiMessage(record.brokenReason), name: manifest.name, configSchema: manifest.configSchema, effectiveConfig: config.ok ? config.config : undefined, configErrors: config.ok ? undefined : config.errors, commands: runtimeState.commands, status: runtimeState.status };
return { ...base, brokenReason: sanitizePluginUiMessage(record.brokenReason), name: manifest.name, icon: manifest.icon, configSchema: manifest.configSchema, effectiveConfig: config.ok ? config.config : undefined, configErrors: config.ok ? undefined : config.errors, commands: runtimeState.commands, status: runtimeState.status };
} catch (error) {
return { ...base, brokenReason: sanitizePluginUiMessage(record.brokenReason) ?? safeError(error) };
}

View file

@ -30,14 +30,15 @@ type DashboardSnapshot = { defaultPet: { id: string; displayName: string; previe
type ReactionAnimationSettings = { reactions: { id: string; label: string; description: string; defaultAnimation: UserSelectableAnimationState }[]; animations: { id: UserSelectableAnimationState; label: string; description: string }[]; sprite: { frameWidth: number; frameHeight: number; columns: number; rows: number; states: Record<UserSelectableAnimationState, { row: number; frames: number; durationMs: number; iterations?: number | "infinite" }> }; overrides: ReactionAnimationOverrides; previewSpriteUrl: string };
type PluginFilter = "all" | "installed" | "catalog" | "local" | "broken";
type PluginPermission = "pet:speak" | "pet:reaction" | "timer" | "schedule" | "storage" | "status" | "commands" | "network";
type PluginIconName = "plugin" | "bell" | "timer" | "github";
type PluginConfigField = { type: "text" | "textarea" | "number" | "boolean" | "select" | "time" | "multiSelect" | "list"; label?: string; description?: string; default?: string | number | boolean | string[] | Array<Record<string, unknown>>; options?: Array<{ label: string; value: string }>; min?: number; max?: number; step?: number; maxLength?: number; maxItems?: number; itemSchema?: Record<string, PluginConfigField> };
type PluginConfigSchema = Record<string, PluginConfigField>;
type PluginConfig = Record<string, unknown>;
type PluginCommand = { id: string; title: string; description?: string };
type PluginStatus = { text: string; tone?: "info" | "success" | "warning" | "error" };
type PluginConfigError = { path?: string; code?: string; message?: string };
type SafePluginRecord = { id: string; name?: string; version: string; source: "catalog" | "local"; enabled: boolean; brokenReason?: string; approvedPermissions: PluginPermission[]; runtime?: "declarative" | "javascript"; sdkVersion?: string; catalogDisabled?: boolean; catalogDeprecated?: boolean; catalogStatusReason?: string; configSchema?: PluginConfigSchema; effectiveConfig?: PluginConfig; configErrors?: PluginConfigError[]; commands?: PluginCommand[]; status?: PluginStatus };
type SafeCatalogPluginRecord = { id: string; name: string; version: string; description: string; runtime: "declarative" | "javascript"; sdkVersion?: string; permissions: PluginPermission[]; installed: boolean; deprecated?: boolean; statusReason?: string };
type SafePluginRecord = { id: string; name?: string; version: string; icon?: PluginIconName; source: "catalog" | "local"; enabled: boolean; brokenReason?: string; approvedPermissions: PluginPermission[]; runtime?: "declarative" | "javascript"; sdkVersion?: string; catalogDisabled?: boolean; catalogDeprecated?: boolean; catalogStatusReason?: string; configSchema?: PluginConfigSchema; effectiveConfig?: PluginConfig; configErrors?: PluginConfigError[]; commands?: PluginCommand[]; status?: PluginStatus };
type SafeCatalogPluginRecord = { id: string; name: string; version: string; description: string; runtime: "declarative" | "javascript"; icon?: PluginIconName; sdkVersion?: string; permissions: PluginPermission[]; installed: boolean; deprecated?: boolean; statusReason?: string };
type PluginServiceSnapshot = { plugins: SafePluginRecord[] };
type PluginCatalogSnapshot = { plugins: SafeCatalogPluginRecord[] };
type PluginServiceResult = { ok: true; snapshot: PluginServiceSnapshot } | { ok: false; error: string; snapshot: PluginServiceSnapshot };
@ -1021,6 +1022,25 @@ function PluginGlyph({ className = "plugin-glyph" }: { className?: string }) {
</svg>;
}
function PluginIcon({ icon = "plugin", className = "plugin-glyph" }: { icon?: PluginIconName; className?: string }) {
if (icon === "bell") return <svg className={className} width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3H4a4 4 0 0 0 2-3v-3a7 7 0 0 1 4-6M9 17v1a3 3 0 0 0 6 0v-1" />
</svg>;
if (icon === "timer") return <svg className={className} width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0-18 0" />
<path d="M12 7v5l3 3" />
</svg>;
if (icon === "github") return <svg className={className} width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M9 19c-4.3 1.4-4.3-2.5-6-3" />
<path d="M15 21v-3.5c0-1 .1-1.4-.5-2c2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 0 0-1.3-3.2a4.2 4.2 0 0 0-.1-3.2s-1.1-.3-3.5 1.3a12.3 12.3 0 0 0-6.2 0C6.5 2.8 5.4 3.1 5.4 3.1a4.2 4.2 0 0 0-.1 3.2A4.6 4.6 0 0 0 4 9.5c0 4.6 2.7 5.7 5.5 6c-.6.6-.6 1.2-.5 2V21" />
</svg>;
return <PluginGlyph className={className} />;
}
function pluginIcon(entry: PluginEntry): PluginIconName {
return entry.installed?.icon || entry.catalog?.icon || "plugin";
}
function pluginName(entry: PluginEntry): string {
return entry.installed?.name || entry.catalog?.name || entry.id;
}
@ -1088,7 +1108,7 @@ function materializeConfigDraft(schema: PluginConfigSchema | undefined, config:
return next;
}
function ConfigFieldEditor({ fieldKey, field, value, onChange }: { fieldKey: string; field: PluginConfigField; value: unknown; onChange: (value: unknown) => void }) {
function ConfigFieldEditor({ pluginId, fieldKey, field, value, onChange }: { pluginId?: string; fieldKey: string; field: PluginConfigField; value: unknown; onChange: (value: unknown) => void }) {
const label = field.label || fieldKey;
const description = field.description;
const textValue = typeof value === "string" ? value : typeof field.default === "string" ? field.default : "";
@ -1103,18 +1123,84 @@ function ConfigFieldEditor({ fieldKey, field, value, onChange }: { fieldKey: str
if (field.type === "list" && field.itemSchema) {
const items = Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => item !== null && typeof item === "object" && !Array.isArray(item)) : [];
const maxed = typeof field.maxItems === "number" && items.length >= field.maxItems;
const isReminders = pluginId === "openpets.daily-reminders" && fieldKey === "reminders";
const addLabel = isReminders ? "Add reminder" : "Add item";
return <div className="plugin-config-row">
<span><strong>{label}</strong>{description && <small>{description}</small>}</span>
<div className="plugin-list-editor">
{items.map((item, index) => (
<div className="plugin-list-item" key={index}>
<div className="plugin-list-item-header"><span>Item {index + 1}</span><Button variant="danger" size="compact" onClick={() => onChange(items.filter((_, itemIndex) => itemIndex !== index))}>Remove</Button></div>
{Object.entries(field.itemSchema ?? {}).map(([childKey, childField]) => (
<ConfigFieldEditor key={childKey} fieldKey={childKey} field={childField} value={item[childKey] ?? initialConfigValue(childField)} onChange={(nextValue) => onChange(items.map((existing, itemIndex) => itemIndex === index ? { ...existing, [childKey]: nextValue } : existing))} />
))}
</div>
))}
<Button variant="secondary" size="compact" disabled={maxed} onClick={() => onChange([...items, materializeListItemDefaults(field.itemSchema ?? {})])}>Add Item</Button>
{items.map((item, index) => {
let itemTitle = `Item ${index + 1}`;
let removeLabel = "Remove";
if (isReminders) {
removeLabel = "Remove reminder";
const id = String(item.id || "").trim();
const scheduleType = item.scheduleType;
if (scheduleType === "daily") {
const time = String(item.time || "09:00");
itemTitle = `${id || "Reminder"} · Daily at ${time}`;
} else if (scheduleType === "interval") {
const mins = Number(item.intervalMinutes) || 60;
itemTitle = `${id || "Reminder"} · Every ${mins} min`;
} else if (id) {
itemTitle = id;
}
}
const schemaEntries = Object.entries(field.itemSchema ?? {});
const messageField = schemaEntries.find(([k]) => k === "message");
const scheduleFields = schemaEntries.filter(([k]) => ["scheduleType", "time", "days", "intervalMinutes"].includes(k));
const behaviorFields = schemaEntries.filter(([k]) => ["id", "enabled", "reaction"].includes(k));
const otherFields = schemaEntries.filter(([k]) => !["message", "scheduleType", "time", "days", "intervalMinutes", "id", "enabled", "reaction"].includes(k));
const renderField = ([childKey, childField]: [string, PluginConfigField]) => {
if (isReminders) {
const scheduleType = item.scheduleType;
if (scheduleType === "daily" && childKey === "intervalMinutes") return null;
if (scheduleType === "interval" && (childKey === "time" || childKey === "days")) return null;
}
return <ConfigFieldEditor key={childKey} pluginId={pluginId} fieldKey={childKey} field={childField} value={item[childKey] ?? initialConfigValue(childField)} onChange={(nextValue) => onChange(items.map((existing, itemIndex) => itemIndex === index ? { ...existing, [childKey]: nextValue } : existing))} />;
};
return (
<div className="plugin-list-item" key={index}>
<div className="plugin-list-item-header">
<span className="truncate mr-2">{itemTitle}</span>
<Button variant="danger" size="compact" onClick={() => onChange(items.filter((_, itemIndex) => itemIndex !== index))}>{removeLabel}</Button>
</div>
<div className="flex flex-col gap-3">
{isReminders ? (
<>
{behaviorFields.length > 0 && (
<div className="plugin-config-group">
<div className="plugin-config-group-title">Identity & Behavior</div>
{behaviorFields.map(renderField)}
</div>
)}
{messageField && (
<div className="plugin-config-group">
<div className="plugin-config-group-title">Message</div>
{renderField(messageField)}
</div>
)}
{scheduleFields.length > 0 && (
<div className="plugin-config-group">
<div className="plugin-config-group-title">Schedule</div>
{scheduleFields.map(renderField)}
</div>
)}
{otherFields.map(renderField)}
</>
) : (
schemaEntries.map(renderField)
)}
</div>
</div>
);
})}
<Button variant="secondary" size="compact" disabled={maxed} onClick={() => onChange([...items, materializeListItemDefaults(field.itemSchema ?? {})])}>{addLabel}</Button>
</div>
</div>;
}
@ -1692,7 +1778,7 @@ function PluginsView() {
{filteredEntries.map((entry) => (
<article key={entry.id} className={`plugin-card ${entry.installed?.brokenReason ? "broken" : ""}`}>
<div className="plugin-card-body">
<span className="plugin-card-icon"><PluginGlyph /></span>
<span className="plugin-card-icon"><PluginIcon icon={pluginIcon(entry)} /></span>
<div className="plugin-card-content">
<strong>{pluginName(entry)}</strong>
<small>{pluginDescription(entry)}</small>
@ -1755,7 +1841,7 @@ function PluginsView() {
<GlassCard className="plugin-inspector">
{selected ? <>
<div className="plugin-inspector-head">
<span className="plugin-inspector-icon"><PluginGlyph /></span>
<span className="plugin-inspector-icon"><PluginIcon icon={pluginIcon(selected)} /></span>
<div className="flex-1 min-w-0"><p className="eyebrow">Plugin Configuration</p><h2>{pluginName(selected)}</h2><p className="desc">{pluginDescription(selected)}</p></div>
<Button variant="secondary" size="compact" icon={<CloseIcon />} onClick={() => setSelectedId("")}>Close</Button>
</div>
@ -1781,10 +1867,22 @@ function PluginsView() {
{!!installed.configErrors?.length && <section className="plugin-section plugin-section-danger"><div className="plugin-section-title"><small>Configuration</small><strong>Needs attention</strong></div><ul>{installed.configErrors.map((configError, index) => <li key={index}>{configError.message || String(configError)}</li>)}</ul></section>}
{installed.configSchema && <section className="plugin-section">
<div className="plugin-section-title"><small>Settings</small><strong>Configuration</strong></div>
<div className="plugin-config-form">{Object.entries(installed.configSchema).map(([key, field]) => <ConfigFieldEditor key={key} fieldKey={key} field={field} value={configDraft[key] ?? initialConfigValue(field)} onChange={(value) => updateDraft(key, value)} />)}</div>
<div className="plugin-config-form">{Object.entries(installed.configSchema).map(([key, field]) => <ConfigFieldEditor key={key} pluginId={installed.id} fieldKey={key} field={field} value={configDraft[key] ?? initialConfigValue(field)} onChange={(value) => updateDraft(key, value)} />)}</div>
<Button variant="primary" fullWidth icon={<SaveIcon />} disabled={!!busy} onClick={() => void run("Saving", async () => { applyResult(await api.savePluginConfig(installed.id, configDraft), "Plugin configuration saved."); })}>Save Configuration</Button>
</section>}
{!!installed.commands?.length && <section className="plugin-section"><div className="plugin-section-title"><small>Commands</small><strong>Quick actions</strong></div><div className="plugin-command-list">{installed.commands.map((command) => <Button key={command.id} variant="secondary" size="compact" disabled={!!busy} onClick={() => void run("Running", async () => { applyResult(await api.executePluginCommand(installed.id, command.id), "Plugin command ran."); })}>{command.title}</Button>)}</div></section>}
{!!installed.commands?.length && <section className="plugin-section">
<div className="plugin-section-title"><small>Commands</small><strong>Quick actions</strong></div>
<div className="plugin-command-list">
{installed.commands.map((command) => (
<div key={command.id} className="flex flex-col gap-2">
<Button variant="secondary" size="compact" disabled={!!busy} onClick={() => void run("Running", async () => { applyResult(await api.executePluginCommand(installed.id, command.id), "Plugin command ran."); })}>
{command.title}
</Button>
{command.description && <small className="text-[10px] text-slatecopy px-1 leading-tight">{command.description}</small>}
</div>
))}
</div>
</section>}
<section className="plugin-section plugin-actions-section">
<Button variant="secondary" disabled={!!busy} icon={<RefreshIcon />} onClick={() => void run("Reloading", async () => { applyResult(await api.reloadPlugin(installed.id), "Plugin reloaded."); })}>Reload</Button>
{installed.source === "catalog" && catalogPlugin && catalogPlugin.version !== installed.version && <Button variant="primary" icon={<InstallIcon />} disabled={!!busy} onClick={() => void run("Updating", async () => { await updateCatalogEntry(installed); })}>Update</Button>}

View file

@ -146,6 +146,7 @@
.plugin-card-body { @apply flex items-start gap-4 p-4; }
.plugin-card-icon { @apply grid h-14 w-14 shrink-0 place-items-center rounded-2xl border border-blue-100/60 bg-gradient-to-br from-white to-blue-100 text-brand shadow-sm; }
.plugin-card-icon .plugin-glyph { @apply h-7 w-7; }
.plugin-glyph { @apply h-7 w-7 stroke-[2.5]; }
.plugin-card-content { @apply flex min-w-0 flex-1 flex-col gap-1; }
@ -181,6 +182,7 @@
@keyframes plugin-pop-in { from { opacity: 0; transform: scale(0.96) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
.plugin-inspector-head { @apply flex items-center gap-4; }
.plugin-inspector-icon { @apply grid h-16 w-16 shrink-0 place-items-center rounded-[22px] border border-blue-100/70 bg-gradient-to-br from-white to-blue-100 text-brand shadow-sm; }
.plugin-inspector-icon .plugin-glyph { @apply h-8 w-8; }
.plugin-inspector h2 { @apply m-0 font-monoDisplay text-3xl font-black text-navy leading-tight; }
.plugin-status-strip { @apply flex items-center gap-2 rounded-2xl border border-blue-100/60 bg-blue-50/60 p-3 text-xs font-semibold text-slatecopy shadow-inner; }
.plugin-section { @apply flex flex-col gap-3 rounded-[24px] border border-blue-100/70 bg-white/65 p-5 shadow-sm; }
@ -192,6 +194,8 @@
.plugin-toggle-row { @apply rounded-2xl border-b-0 bg-white/65; }
.plugin-permissions { @apply mt-0; }
.plugin-config-form { @apply flex flex-col gap-3; }
.plugin-config-group { @apply flex flex-col gap-2 p-3 rounded-2xl border border-blue-100/40 bg-blue-50/10; }
.plugin-config-group-title { @apply font-monoDisplay text-[10px] font-black uppercase tracking-wider text-brand/70 mb-1 px-1; }
.plugin-config-row { @apply flex flex-col gap-2 rounded-2xl border border-blue-50 bg-white/60 p-3; }
.plugin-config-row > span:first-child { @apply flex flex-col gap-0.5; }
.plugin-config-row strong { @apply text-sm font-bold text-navy; }

View file

@ -11,10 +11,12 @@ assert.throws(() => validatePluginCatalog({ version: 1, generatedAt: "now", plug
assert.throws(() => validatePluginCatalog({ version: 1, generatedAt: "now", plugins: [entry, entry] }), /Duplicate plugin id/);
assert.throws(() => validatePluginCatalog({ version: 1, generatedAt: "now", plugins: [{ ...entry, sha256: "A".repeat(64) }] }), /sha256/);
const catalogV2 = validatePluginCatalog({ version: 2, generatedAt: "now", plugins: [{ ...entry, id: "js-plugin", runtime: "javascript", sdkVersion: "1.0.0", permissions: ["pet:speak", "network"], minOpenPetsVersion: "2.0.0", maxOpenPetsVersion: "3.0.0", deprecated: true, statusReason: "Use another plugin", network: { hosts: ["api.example.com"] } }] });
const catalogV2 = validatePluginCatalog({ version: 2, generatedAt: "now", plugins: [{ ...entry, id: "js-plugin", runtime: "javascript", icon: "github", sdkVersion: "1.0.0", permissions: ["pet:speak", "network"], minOpenPetsVersion: "2.0.0", maxOpenPetsVersion: "3.0.0", deprecated: true, statusReason: "Use another plugin", network: { hosts: ["api.example.com"] } }] });
assert.equal(catalogV2.version, 2);
assert.equal(catalogV2.plugins[0].runtime, "javascript");
assert.equal(catalogV2.plugins[0].icon, "github");
assert.throws(() => validatePluginCatalog({ version: 2, generatedAt: "now", plugins: [{ ...entry, runtime: "python" }] }), /runtime/);
assert.throws(() => validatePluginCatalog({ version: 2, generatedAt: "now", plugins: [{ ...entry, icon: "https://example.com/icon.svg" }] }), /icon/);
assert.throws(() => validatePluginCatalog({ version: 2, generatedAt: "now", plugins: [{ ...entry, runtime: "javascript", sdkVersion: "1.0.0", permissions: ["pet:speak", "network"], network: { hosts: ["*.example.com"] } }] }), /network/);
console.error("Plugin catalog validation passed.");

View file

@ -14,6 +14,7 @@ OpenPets is a pnpm/TypeScript monorepo for an Electron desktop companion app plu
- `packages/client/src/index.ts`: public IPC client API consumed by integrations and tools.
- `packages/cursor/src/index.ts`: Cursor MCP/rules setup API.
- `packages/pi/src/extension.ts`: Pi coding-agent extension runtime entry point.
- `plugins/official/`: first-party plugin product source consumed by desktop dev mode and web catalog sync.
## Directory Map
@ -48,6 +49,8 @@ OpenPets is a pnpm/TypeScript monorepo for an Electron desktop companion app plu
| `packages/pi/src/` | Pi extension entry point, event classification, OpenPets command parsing, and validation checks. | [View Map](packages/pi/src/codemap.md) |
| `packages/pet-format/` | Minimal package marker/type interface for OpenPets pet package identity. | [View Map](packages/pet-format/codemap.md) |
| `packages/pet-format/src/` | Marker source export for pet-format package consumers. | [View Map](packages/pet-format/src/codemap.md) |
| `plugins/` | Root product source for first-party OpenPets plugins before web catalog packaging and R2 upload. | |
| `plugins/official/` | Official plugin manifests and single-file JavaScript entries loaded by desktop dev mode and packaged by web sync. | |
## Architecture Flow

View file

@ -281,14 +281,20 @@ Plugin ZIP installs validate:
- JavaScript entry presence inside package
- safe install/uninstall path containment
The web repo builds catalog artifacts with:
The repository root provides safe plugin catalog commands:
```bash
cd web
node scripts/sync-plugins.js --dry-run --skip-r2
pnpm plugins:test
pnpm plugins:check
pnpm plugins:package
```
Publishing uploads plugin ZIPs and regenerates public catalog files.
`plugins:check` dry-runs package validation, while `plugins:package` writes `web/public/plugins/*.json` and stages ZIPs under `web/.data/plugin-zips` without uploading to R2. Publishing is separate:
```bash
pnpm plugins:publish
pnpm plugins:deploy
```
## Local development workflow
@ -303,7 +309,7 @@ For this repository, run from the root:
pnpm dev:desktop:plugins
```
This points desktop at `web/plugins/official`, snapshots each official plugin into the app data `plugins-dev` directory, auto-approves permissions for those explicit dev paths, preserves enabled state when permissions/hosts remain compatible, and starts the runtime.
This points desktop at `plugins/official`, snapshots each official plugin into the app data `plugins-dev` directory, auto-approves permissions for those explicit dev paths, preserves enabled state when permissions/hosts remain compatible, and starts the runtime.
To load one plugin manually:
@ -396,11 +402,12 @@ Desktop plugin/runtime validation:
pnpm --filter @open-pets/desktop test
```
Web plugin catalog dry-run:
Plugin catalog validation and local packaging:
```bash
cd web
node scripts/sync-plugins.js --dry-run --skip-r2
pnpm plugins:test
pnpm plugins:check
pnpm plugins:package
```
Manual dogfood:

View file

@ -130,32 +130,40 @@ Manual desktop QA:
Web release includes:
- `web/plugins/official/**` source plugins.
- `plugins/official/**` source plugins.
- `web/public/plugins/catalog.v2.json` with the three official plugins.
- `web/public/plugins/catalog.v1.json` with an empty plugin list.
- Removal of legacy sample plugin manifests.
- Updated `web/docs/plugin-publishing.md`.
Required validation from `web/`:
Required validation from the repository root:
```bash
node scripts/sync-plugins.js --dry-run --skip-r2
bun run generate
pnpm plugins:test
pnpm plugins:check
pnpm plugins:package
pnpm --dir web generate
```
Publishing sequence:
1. From `web/`, upload plugin ZIPs and regenerate catalogs:
1. From the repository root, validate and stage local catalog/ZIP artifacts:
```bash
bun run sync:plugins
pnpm plugins:test
pnpm plugins:check
pnpm plugins:package
```
2. Confirm `public/plugins/catalog.v2.json` has only the three official plugins.
3. Confirm `public/plugins/catalog.v1.json` has `plugins: []`.
4. Deploy web:
2. Confirm `web/public/plugins/catalog.v2.json` has only the three official plugins.
3. Confirm `web/public/plugins/catalog.v1.json` has `plugins: []`.
4. Upload plugin ZIPs to R2 and regenerate catalogs:
```bash
bun run deploy
pnpm plugins:publish
```
5. Verify live endpoints:
5. Deploy web:
```bash
pnpm plugins:deploy
```
6. Verify live endpoints:
- `https://openpets.dev/plugins/catalog.v2.json`
- `https://openpets.dev/plugins/catalog.v1.json`
- each `https://zip.openpets.dev/plugins/<plugin-id>.zip`

View file

@ -21,6 +21,11 @@
"package:desktop": "pnpm build && pnpm --filter @open-pets/desktop package",
"release:desktop": "node apps/desktop/scripts/release-local.mjs",
"release:npm": "node scripts/release-npm.mjs",
"plugins:test": "node scripts/test-plugins.mjs",
"plugins:check": "node web/scripts/sync-plugins.js --dry-run --skip-r2",
"plugins:package": "node web/scripts/sync-plugins.js --skip-r2",
"plugins:publish": "node web/scripts/sync-plugins.js",
"plugins:deploy": "pnpm --dir web deploy",
"test": "pnpm build && pnpm -r --if-present test",
"check": "pnpm -r check",
"typecheck": "pnpm -r typecheck",

View file

@ -0,0 +1,211 @@
export const MIN_INTERVAL_MINUTES = 10;
export const MAX_INTERVAL_MINUTES = 1440;
export const MAX_MESSAGE_LENGTH = 140;
export const MAX_ID_LENGTH = 64;
export const DEFAULT_REACTION = "waving";
export const DEFAULT_MESSAGE = "Time for a gentle reminder.";
export const DEFAULT_SNOOZE_MINUTES = 10;
export const VALID_REACTIONS = ["waving", "waiting", "success", "celebrating"];
const UNSAFE_MESSAGE_PATTERN = /```|<script|function\s+\w+|=>|\b(class|import|export|const|let|var)\b|https?:\/\/|www\.|\/[\w.-]+\/[\w./-]+|[A-Za-z]:\\|api[_-]?key|secret|token|password|passwd|BEGIN [A-Z ]+PRIVATE KEY/i;
export const DEFAULT_REMINDERS = [
{
id: "morning-focus",
enabled: true,
message: "Good morning! Pick one meaningful task to start with.",
reaction: "waving",
scheduleType: "daily",
time: "09:00",
days: ["1", "2", "3", "4", "5"],
intervalMinutes: 60,
},
{
id: "stretch-break",
enabled: true,
message: "Tiny stretch break: relax your shoulders and look away for a moment.",
reaction: "waiting",
scheduleType: "interval",
time: "10:00",
days: ["1", "2", "3", "4", "5"],
intervalMinutes: 90,
},
];
export function sanitizeId(value, index) {
const raw = typeof value === "string" && value.trim() ? value.trim() : `reminder-${index + 1}`;
return raw.replace(/[^A-Za-z0-9._:-]/g, "-").slice(0, MAX_ID_LENGTH) || `reminder-${index + 1}`;
}
export function normalizeMessage(value) {
const message = typeof value === "string" && value.trim() ? value.trim().replace(/[\r\n]+/g, " ").replace(/\s+/g, " ") : DEFAULT_MESSAGE;
const capped = message.length > MAX_MESSAGE_LENGTH ? message.slice(0, MAX_MESSAGE_LENGTH).trim() : message;
if (!capped || UNSAFE_MESSAGE_PATTERN.test(capped)) return DEFAULT_MESSAGE;
return capped;
}
export function normalizeTime(value) {
const match = /^(\d{2}):(\d{2})$/.exec(String(value ?? ""));
if (!match) return "09:00";
const hours = Number(match[1]);
const minutes = Number(match[2]);
return hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59 ? `${match[1]}:${match[2]}` : "09:00";
}
export function normalizeIntervalMinutes(value) {
const interval = Number(value);
if (!Number.isFinite(interval)) return MIN_INTERVAL_MINUTES;
return Math.min(MAX_INTERVAL_MINUTES, Math.max(MIN_INTERVAL_MINUTES, Math.floor(interval)));
}
export function normalizeSnoozeMinutes(value) {
const minutes = Number(value);
if (!Number.isFinite(minutes)) return DEFAULT_SNOOZE_MINUTES;
return Math.min(120, Math.max(1, Math.round(minutes)));
}
export function normalizeReminder(value, index) {
const reminder = value && typeof value === "object" ? value : {};
const scheduleType = reminder.scheduleType === "interval" ? "interval" : "daily";
const days = Array.isArray(reminder.days)
? reminder.days.map((day) => Number(day)).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6)
: undefined;
return {
id: sanitizeId(reminder.id, index),
enabled: reminder.enabled !== false,
message: normalizeMessage(reminder.message),
reaction: VALID_REACTIONS.includes(reminder.reaction) ? reminder.reaction : DEFAULT_REACTION,
scheduleType,
time: normalizeTime(reminder.time),
days,
intervalMinutes: normalizeIntervalMinutes(reminder.intervalMinutes),
};
}
export function getConfiguredReminders(config) {
return Array.isArray(config?.reminders) ? config.reminders : DEFAULT_REMINDERS;
}
export function getReminders(config) {
return getConfiguredReminders(config).map(normalizeReminder).filter((reminder) => reminder.enabled);
}
export async function fireReminder(ctx, reminder) {
await ctx.pet.speak(reminder.message);
await ctx.pet.react(reminder.reaction);
await ctx.storage.set("lastTriggered", { id: reminder.id, message: reminder.message, reaction: reminder.reaction, at: new Date().toISOString() });
}
export async function snoozeLast(ctx, config) {
const last = await ctx.storage.get("lastTriggered");
if (!last || typeof last !== "object" || !last.id) {
await ctx.pet.speak("No reminder to snooze yet. I will remember after one fires.");
return false;
}
const reminder = normalizeReminder(last, 0);
const minutes = normalizeSnoozeMinutes(config?.snoozeMinutes);
const safeId = `snooze-${sanitizeId(reminder.id, 0)}-${Date.now()}`.slice(0, MAX_ID_LENGTH);
await ctx.schedule.once(safeId, minutes * 60_000, () => fireReminder(ctx, reminder));
await ctx.storage.set("lastSnoozed", { id: reminder.id, at: new Date().toISOString(), minutes });
await ctx.pet.speak(`Snoozed ${reminder.id} for ${minutes} minutes.`);
return true;
}
export function statusText(reminders) {
if (reminders.length === 0) return { text: "No reminders enabled", tone: "warning" };
const daily = reminders.filter((reminder) => reminder.scheduleType === "daily").length;
const interval = reminders.length - daily;
const parts = [];
if (daily) parts.push(`${daily} daily`);
if (interval) parts.push(`${interval} interval`);
const intervals = reminders.filter((r) => r.scheduleType === "interval");
const dailies = reminders.filter((r) => r.scheduleType === "daily").sort((a, b) => a.time.localeCompare(b.time) || a.id.localeCompare(b.id));
const next = intervals.length
? `Next: every ${Math.min(...intervals.map((r) => r.intervalMinutes))} min`
: dailies[0]
? `Next: ${dailies[0].id} at ${dailies[0].time}`
: "";
return { text: `${parts.join(", ")} reminder${reminders.length === 1 ? "" : "s"} enabled${next ? ` · ${next}` : ""}`, tone: "info" };
}
export function summaryText(reminders) {
if (reminders.length === 0) return "No enabled reminders. Add or enable one in plugin settings.";
return reminders.map((reminder) => reminder.scheduleType === "interval" ? `${reminder.id}: every ${reminder.intervalMinutes} min` : `${reminder.id}: daily at ${reminder.time}${reminder.days?.length ? ` on days ${reminder.days.join(",")}` : ""}`).join("; ");
}
export function makeScheduleIds(reminders) {
const seen = new Set();
return reminders.map((reminder, index) => {
const rawBase = `reminder-${sanitizeId(reminder.id, index)}`;
for (let count = 0; count <= reminders.length; count += 1) {
const suffix = count === 0 ? "" : `-${count + 1}`;
const candidate = `${rawBase.slice(0, MAX_ID_LENGTH - suffix.length)}${suffix}`;
if (!seen.has(candidate)) {
seen.add(candidate);
return candidate;
}
}
const fallback = `reminder-${index + 1}`.slice(0, MAX_ID_LENGTH);
seen.add(fallback);
return fallback;
});
}
export async function reschedule(ctx, config) {
await ctx.schedule.cancelAll();
const reminders = getReminders(config);
const scheduleIds = makeScheduleIds(reminders);
let failed = false;
for (const [index, reminder] of reminders.entries()) {
const scheduleId = scheduleIds[index];
try {
if (reminder.scheduleType === "interval") {
await ctx.schedule.every(scheduleId, reminder.intervalMinutes * 60_000, () => fireReminder(ctx, reminder));
} else {
const spec = reminder.days && reminder.days.length > 0 ? { time: reminder.time, days: reminder.days } : { time: reminder.time };
await ctx.schedule.daily(scheduleId, spec, () => fireReminder(ctx, reminder));
}
} catch (error) {
failed = true;
ctx.log?.warn?.("Daily reminder schedule registration failed", scheduleId, error?.message || String(error));
}
}
await ctx.status.set(failed ? { text: "Reminder schedule registration failed", tone: "error" } : statusText(reminders));
}
export function register(OpenPetsPlugin) {
OpenPetsPlugin.register({
async start(ctx) {
let config = await ctx.config.get();
await reschedule(ctx, config);
await ctx.commands.register({ id: "preview-first", title: "Preview first reminder", description: "Speak and react with the first enabled reminder now." }, async () => {
const reminders = getReminders(await ctx.config.get());
if (reminders[0]) await fireReminder(ctx, reminders[0]);
});
const startupReminders = getReminders(config).slice(0, 16);
const startupScheduleIds = makeScheduleIds(startupReminders);
for (const [index, reminder] of startupReminders.entries()) {
await ctx.commands.register({ id: `preview-${startupScheduleIds[index]}`, title: `Preview: ${reminder.id}`, description: `Preview reminder ${reminder.id}.` }, async () => fireReminder(ctx, reminder));
}
await ctx.commands.register({ id: "show-summary", title: "Reminder Summary", description: "Speak a concise summary of enabled reminders." }, async () => {
await ctx.pet.speak(normalizeMessage(summaryText(getReminders(await ctx.config.get()))));
});
await ctx.commands.register({ id: "snooze-last", title: "Snooze last reminder", description: "Remind me again after the configured snooze time." }, async () => {
await snoozeLast(ctx, await ctx.config.get());
});
await ctx.commands.register({ id: "reload-reminders", title: "Reload reminder schedules", description: "Refresh reminder timers from current settings." }, async () => {
config = await ctx.config.get();
await reschedule(ctx, config);
});
ctx.config.onChange(async (nextConfig) => {
config = nextConfig;
await reschedule(ctx, config);
});
},
async stop() {}
});
}

View file

@ -0,0 +1,121 @@
{
"manifestVersion": 2,
"id": "openpets.daily-reminders",
"name": "Daily Reminders",
"version": "1.2.0",
"runtime": "javascript",
"icon": "bell",
"sdkVersion": "1.0.0",
"entry": "index.js",
"permissions": ["pet:speak", "pet:reaction", "schedule", "storage", "commands", "status"],
"configSchema": {
"snoozeMinutes": {
"type": "number",
"label": "Snooze minutes",
"description": "How long Snooze last reminder waits before reminding again.",
"default": 10,
"min": 1,
"max": 120,
"step": 5
},
"reminders": {
"type": "list",
"label": "Reminders",
"description": "Daily and interval reminders your pet can announce.",
"maxItems": 16,
"default": [
{
"id": "morning-focus",
"enabled": true,
"message": "Good morning! Pick one meaningful task to start with.",
"reaction": "waving",
"scheduleType": "daily",
"time": "09:00",
"days": ["1", "2", "3", "4", "5"],
"intervalMinutes": 60
},
{
"id": "stretch-break",
"enabled": true,
"message": "Tiny stretch break: relax your shoulders and look away for a moment.",
"reaction": "waiting",
"scheduleType": "interval",
"time": "10:00",
"days": ["1", "2", "3", "4", "5"],
"intervalMinutes": 90
}
],
"itemSchema": {
"id": {
"type": "text",
"label": "ID",
"description": "Unique reminder ID using letters, numbers, dot, dash, or underscore.",
"default": "reminder",
"maxLength": 48
},
"enabled": {
"type": "boolean",
"label": "Enabled",
"default": true
},
"message": {
"type": "textarea",
"label": "Message",
"description": "What your pet says when this reminder triggers.",
"default": "Time for a gentle reminder.",
"maxLength": 140
},
"reaction": {
"type": "select",
"label": "Reaction",
"default": "waving",
"options": [
{ "label": "Waving", "value": "waving" },
{ "label": "Waiting", "value": "waiting" },
{ "label": "Success", "value": "success" },
{ "label": "Celebrating", "value": "celebrating" }
]
},
"scheduleType": {
"type": "select",
"label": "Schedule type",
"default": "daily",
"options": [
{ "label": "Daily", "value": "daily" },
{ "label": "Interval", "value": "interval" }
]
},
"time": {
"type": "time",
"label": "Daily time",
"description": "Used for daily reminders in HH:mm format.",
"default": "09:00"
},
"days": {
"type": "multiSelect",
"label": "Days",
"description": "Days for daily reminders. Leave empty for every day.",
"default": ["1", "2", "3", "4", "5"],
"options": [
{ "label": "Sunday", "value": "0" },
{ "label": "Monday", "value": "1" },
{ "label": "Tuesday", "value": "2" },
{ "label": "Wednesday", "value": "3" },
{ "label": "Thursday", "value": "4" },
{ "label": "Friday", "value": "5" },
{ "label": "Saturday", "value": "6" }
]
},
"intervalMinutes": {
"type": "number",
"label": "Interval minutes",
"description": "Used for interval reminders. Minimum runtime interval is 10 minutes.",
"default": 60,
"min": 10,
"max": 1440,
"step": 5
}
}
}
}
}

View file

@ -0,0 +1,133 @@
import assert from "node:assert/strict";
import {
DEFAULT_REMINDERS,
MAX_ID_LENGTH,
MAX_MESSAGE_LENGTH,
DEFAULT_MESSAGE,
getReminders,
makeScheduleIds,
normalizeIntervalMinutes,
normalizeReminder,
normalizeSnoozeMinutes,
register,
reschedule,
snoozeLast,
statusText,
summaryText,
} from "./index.js";
function createCtx(config = {}) {
const store = new Map();
const calls = { speak: [], react: [], storage: [], daily: [], every: [], once: [], cancelAll: 0, status: [], commands: new Map(), warnings: [] };
const ctx = {
pet: {
speak: async (message) => calls.speak.push(message),
react: async (reaction) => calls.react.push(reaction),
},
storage: { get: async (key) => store.get(key), set: async (key, value) => { store.set(key, value); calls.storage.push([key, value]); } },
schedule: {
cancelAll: async () => calls.cancelAll++,
daily: (id, spec, fn) => calls.daily.push({ id, spec, fn }),
every: (id, interval, fn) => calls.every.push({ id, interval, fn }),
once: (id, delay, fn) => calls.once.push({ id, delay, fn }),
},
status: { set: async (value) => calls.status.push(value) },
commands: { register: async (command, fn) => calls.commands.set(command.id, { command, fn }) },
config: { get: async () => config, onChange: () => {} },
log: { warn: (...args) => calls.warnings.push(args) },
};
return { ctx, calls };
}
assert.equal(getReminders({}).length, DEFAULT_REMINDERS.length, "missing config uses manifest-equivalent defaults");
assert.equal(getReminders({ reminders: [] }).length, 0, "explicit empty config keeps reminders disabled");
const normalized = normalizeReminder({
id: "x".repeat(100),
message: "m".repeat(200),
reaction: "not-real",
time: "99:99",
intervalMinutes: Infinity,
}, 0);
assert.equal(normalized.id.length, MAX_ID_LENGTH);
assert.equal(normalized.message.length, MAX_MESSAGE_LENGTH);
assert.equal(normalized.reaction, "waving");
assert.equal(normalized.time, "09:00");
assert.equal(normalized.intervalMinutes, 10);
assert.equal(normalizeIntervalMinutes("bad"), 10);
assert.equal(normalizeSnoozeMinutes("bad"), 10);
assert.equal(normalizeSnoozeMinutes(0), 1);
assert.equal(normalizeSnoozeMinutes(999), 120);
assert.equal(normalizeReminder({ message: "line one\nline two" }, 0).message, "line one line two");
assert.equal(normalizeReminder({ message: "https://example.test" }, 0).message, DEFAULT_MESSAGE);
assert.equal(normalizeReminder({ message: "password reminder" }, 0).message, DEFAULT_MESSAGE);
assert.deepEqual(getReminders({ reminders: [{ id: "off", enabled: false }] }), [], "disabled reminders are skipped");
const duplicateReminders = getReminders({ reminders: [
{ id: "same", enabled: true },
{ id: "same", enabled: true },
{ id: "L".repeat(120), enabled: true },
{ id: `${"L".repeat(80)}-different`, enabled: true },
] });
const scheduleIds = makeScheduleIds(duplicateReminders);
assert.equal(new Set(scheduleIds).size, scheduleIds.length, "duplicate reminder ids produce unique schedule ids");
assert.ok(scheduleIds.every((id) => id.length <= MAX_ID_LENGTH), "schedule ids stay within max length");
{
const { ctx, calls } = createCtx({ reminders: [
{ id: "daily", scheduleType: "daily", time: "08:30", days: ["1", "5"] },
{ id: "interval", scheduleType: "interval", intervalMinutes: 15 },
{ id: "disabled", enabled: false, scheduleType: "interval", intervalMinutes: 15 },
] });
await reschedule(ctx, await ctx.config.get());
assert.equal(calls.cancelAll, 1);
assert.equal(calls.daily.length, 1);
assert.deepEqual(calls.daily[0].spec, { time: "08:30", days: [1, 5] });
assert.equal(calls.every.length, 1);
assert.equal(calls.every[0].interval, 15 * 60_000);
assert.ok(calls.status.at(-1).text.includes("Next: every 15 min"));
}
{
const { ctx, calls } = createCtx({ reminders: [{ id: "bad", scheduleType: "interval", intervalMinutes: 15 }] });
ctx.schedule.every = async () => { throw new Error("nope"); };
await reschedule(ctx, await ctx.config.get());
assert.equal(calls.warnings.length, 1);
assert.equal(calls.status.at(-1).tone, "error");
}
{
const { ctx, calls } = createCtx({ reminders: [{ id: "preview", message: "Preview me", reaction: "success" }] });
const plugin = { register: (definition) => { plugin.definition = definition; } };
register(plugin);
await plugin.definition.start(ctx);
await calls.commands.get("preview-first").fn();
assert.deepEqual(calls.speak, ["Preview me"]);
assert.deepEqual(calls.react, ["success"]);
assert.equal(calls.storage[0][0], "lastTriggered");
assert.ok(calls.commands.get("show-summary").command.title.includes("Summary"));
assert.ok(calls.commands.get("snooze-last").command.title.includes("Snooze"));
assert.ok(calls.commands.has("preview-reminder-preview"));
await calls.commands.get("preview-reminder-preview").fn();
assert.equal(calls.speak.at(-1), "Preview me");
await calls.commands.get("snooze-last").fn();
assert.equal(calls.once.length, 1);
assert.equal(calls.once[0].delay, 10 * 60_000);
await calls.commands.get("show-summary").fn();
assert.ok(calls.speak.at(-1).includes("preview"));
}
{
const { ctx, calls } = createCtx({ snoozeMinutes: 1200 });
await snoozeLast(ctx, await ctx.config.get());
assert.equal(calls.once.length, 0);
assert.ok(calls.speak[0].includes("No reminder"));
}
assert.deepEqual(statusText([]), { text: "No reminders enabled", tone: "warning" });
assert.ok(statusText(duplicateReminders).text.includes("daily"));
assert.ok(summaryText(duplicateReminders).includes("same"));
console.log("Daily Reminders plugin tests passed.");

View file

@ -0,0 +1,205 @@
export const MAX_REPOS = 10;
export const MAX_MESSAGE_LENGTH = 140;
export const DEFAULT_NOTIFICATION_MESSAGE = "New GitHub notification.";
export const EMPTY_BASELINE = "__openpets_empty__";
const UNSAFE_MESSAGE_PATTERN = /```|<script|function\s+\w+|=>|\b(class|import|export|const|let|var)\b|https?:\/\/|www\.|\/[\w.-]+\/[\w./-]+|[A-Za-z]:\\|api[_-]?key|secret|token|password|passwd|BEGIN [A-Z ]+PRIVATE KEY/i;
let checkRunning = false;
export function register(OpenPetsPlugin) {
OpenPetsPlugin.register({
async start(ctx) {
await ctx.commands.register({ id: "check-now", title: "Check GitHub now", description: "Check configured public repositories now." }, async () => {
void checkNow(ctx, true).catch((error) => ctx.log?.warn?.("GitHub manual check failed", error?.message || String(error)));
await ctx.status.set({ text: "GitHub: checking now…", tone: "info" });
});
await ctx.commands.register({ id: "reset-baseline", title: "Reset GitHub baseline", description: "Mark current releases, issues, pull requests, and failed workflows as seen." }, async () => {
void resetBaseline(ctx).catch((error) => ctx.log?.warn?.("GitHub baseline reset failed", error?.message || String(error)));
await ctx.status.set({ text: "GitHub: resetting baseline…", tone: "info" });
});
await ctx.commands.register({ id: "show-last-check", title: "Show last GitHub check", description: "Speak the latest GitHub notification check summary." }, async () => await showLastCheck(ctx));
await scheduleNext(ctx);
void checkNow(ctx, false).catch((error) => ctx.log?.warn?.("GitHub initial check failed", error?.message || String(error)));
},
});
}
if (typeof globalThis.OpenPetsPlugin !== "undefined") register(globalThis.OpenPetsPlugin);
export async function scheduleNext(ctx) {
const config = await ctx.config.get();
const interval = Math.max(10, Number(config.pollIntervalMinutes || 30));
await ctx.schedule.cancel("poll");
await ctx.schedule.every("poll", interval * 60 * 1000, async () => await checkNow(ctx, false));
await ctx.status.set({ text: `GitHub: next check ${new Date(Date.now() + interval * 60 * 1000).toLocaleTimeString()}`, tone: "info" });
}
export async function checkNow(ctx, manual) {
if (checkRunning) {
if (manual) await ctx.pet.speak("GitHub check already running.");
return { at: new Date().toISOString(), repos: 0, notifications: 0, failures: 0, skipped: true, reason: "already-running" };
}
checkRunning = true;
try {
const config = await ctx.config.get();
const parsed = parseReposDetailed(config.repositories);
const repoLimit = repoLimitForConfig(config);
const repos = parsed.repos.slice(0, repoLimit);
const truncated = parsed.truncated || parsed.repos.length > repoLimit;
if (repos.length === 0) { await ctx.status.set({ text: parsed.invalid.length ? "GitHub: fix invalid repositories" : "GitHub: add public repositories", tone: "warning" }); if (manual && parsed.invalid.length) await ctx.pet.speak(`Invalid GitHub repositories ignored: ${parsed.invalid.slice(0, 3).join(", ")}.`); return { repos: 0, notifications: 0, failures: 0, invalid: parsed.invalid, truncated }; }
const baseline = (await ctx.storage.get("baselineComplete")) === true;
const events = [];
let failures = 0;
let backoffSkipped = 0;
for (const repo of repos) {
try {
const backoffUntil = Number(await ctx.storage.get(`backoff:${repo}`) || 0);
if (backoffUntil > Date.now()) { backoffSkipped += 1; continue; }
if (config.notifyReleases !== false) events.push(...await checkRelease(ctx, repo, config, baseline));
if (config.notifyFailedWorkflows !== false) events.push(...await checkWorkflow(ctx, repo, config, baseline));
if (config.notifyIssues === true) events.push(...await checkIssue(ctx, repo, config, baseline));
if (config.notifyPullRequests === true) events.push(...await checkPullRequest(ctx, repo, config, baseline));
} catch (error) {
failures += 1;
if (isBackoffError(error)) await ctx.storage.set(`backoff:${repo}`, Date.now() + 15 * 60 * 1000);
ctx.log?.warn?.("GitHub repo check failed", repo, error?.message || String(error));
}
}
await notifyBatch(ctx, events);
if (!baseline) await ctx.storage.set("baselineComplete", true);
const notifications = events.length;
const summary = { at: new Date().toISOString(), repos: repos.length, notifications, failures, invalid: parsed.invalid, truncated, backoffSkipped };
await ctx.storage.set("lastCheck", summary);
await ctx.status.set({ text: `GitHub: checked ${repos.length}, ${notifications} new${failures ? `, ${failures} failed` : ""}`, tone: failures ? "warning" : notifications ? "success" : "info" });
if (manual) {
if (parsed.invalid.length || truncated) await ctx.pet.speak(`GitHub ignored ${parsed.invalid.length} invalid${truncated ? " and extra" : ""} repository entries.`);
else if (notifications === 0 && failures === 0) await ctx.pet.speak(backoffSkipped ? `No new GitHub notifications. ${backoffSkipped} repo checks are cooling down.` : "No new GitHub notifications.");
else if (failures) await ctx.pet.speak(notifications ? `GitHub check found ${notifications} new notifications, with ${failures} repo failures.` : `GitHub check had ${failures} repo failures and no new notifications.`);
}
const interval = Math.max(10, Number(config.pollIntervalMinutes || 30));
await ctx.schedule.cancel("poll");
await ctx.schedule.every("poll", interval * 60 * 1000, async () => await checkNow(ctx, false));
return summary;
} finally { checkRunning = false; }
}
export async function showLastCheck(ctx) {
const last = await ctx.storage.get("lastCheck");
if (!last || typeof last !== "object") { await ctx.pet.speak("No GitHub check has completed yet."); return; }
await ctx.pet.speak(`Last GitHub check: ${last.repos || 0} repos, ${last.notifications || 0} new, ${last.failures || 0} failed.`);
}
export async function resetBaseline(ctx) {
await ctx.storage.delete("baselineComplete");
const summary = await checkNow(ctx, false);
if (summary.failures || summary.backoffSkipped) await ctx.pet.speak(`GitHub baseline partially reset. ${summary.failures || 0} failed, ${summary.backoffSkipped || 0} cooling down.`);
else await ctx.pet.speak("GitHub notification baseline reset.");
}
async function checkRelease(ctx, repo, config, baseline) {
const res = await github(ctx, `/repos/${repo}/releases?per_page=1`, `etag:release:${repo}`);
if (res.notModified) return [];
const release = Array.isArray(res.json) ? res.json[0] : undefined;
return handleNewest(ctx, `release:${repo}`, release && String(release.id || release.tag_name || ""), baseline, { type: "release", repo, message: format(config.releaseMessage || "New release: {repo} {tag}", { repo, tag: release?.tag_name || "" }), reaction: config.releaseReaction || "celebrating" });
}
async function checkWorkflow(ctx, repo, config, baseline) {
const branch = await defaultBranch(ctx, repo);
const res = await github(ctx, `/repos/${repo}/actions/runs?status=completed&per_page=10&branch=${encodeURIComponent(branch)}`, `etag:workflow:${repo}`);
if (res.notModified) return [];
const run = (res.json?.workflow_runs || []).find((item) => ["failure", "timed_out", "action_required"].includes(item?.conclusion));
return handleNewest(ctx, `workflow:${repo}`, run && String(run.id || ""), baseline, { type: "workflow", repo, message: format(config.workflowMessage || "Workflow failed in {repo}: {name}", { repo, name: run?.name || "workflow" }), reaction: config.workflowReaction || "error" });
}
async function checkIssue(ctx, repo, config, baseline) {
const res = await github(ctx, `/repos/${repo}/issues?state=open&per_page=10&sort=created&direction=desc`, `etag:issue:${repo}`);
if (res.notModified) return [];
const issue = (Array.isArray(res.json) ? res.json : []).find((item) => item && !item.pull_request);
return handleNewest(ctx, `issue:${repo}`, issue && String(issue.id || issue.number || ""), baseline, { type: "issue", repo, message: format(config.issueMessage || "New issue in {repo}: {name}", { repo, name: issue?.title || `#${issue?.number}` }), reaction: config.issueReaction || "thinking" });
}
async function checkPullRequest(ctx, repo, config, baseline) {
const res = await github(ctx, `/repos/${repo}/pulls?state=open&per_page=1&sort=created&direction=desc`, `etag:pr:${repo}`);
if (res.notModified) return [];
const pr = Array.isArray(res.json) ? res.json[0] : undefined;
return handleNewest(ctx, `pr:${repo}`, pr && String(pr.id || pr.number || ""), baseline, { type: "pr", repo, message: format(config.pullRequestMessage || "New pull request in {repo}: {name}", { repo, name: pr?.title || `#${pr?.number}` }), reaction: config.pullRequestReaction || "waving" });
}
async function handleNewest(ctx, key, id, baseline, event) {
const previous = String((await ctx.storage.get(key)) || "");
const next = id || EMPTY_BASELINE;
if (!baseline || !previous) {
await ctx.storage.set(key, next);
return [];
}
if (!id) {
if (previous !== EMPTY_BASELINE) await ctx.storage.set(key, EMPTY_BASELINE);
return [];
}
await ctx.storage.set(key, id);
if (id === previous) return [];
return [event];
}
async function defaultBranch(ctx, repo) {
const key = `repo:${repo}`;
const cached = await ctx.storage.get(key);
if (cached?.default_branch) return cached.default_branch;
const res = await github(ctx, `/repos/${repo}`, `etag:repo:${repo}`);
const branch = res.json?.default_branch || "main";
await ctx.storage.set(key, { default_branch: branch });
return branch;
}
async function notifyBatch(ctx, events) {
if (!events.length) return;
const order = { workflow: 0, release: 1, pr: 2, issue: 3 };
events.sort((a, b) => order[a.type] - order[b.type]);
const first = events[0];
await ctx.pet.react(safeMessage(first.reaction, "idle"));
await ctx.pet.speak(events.length === 1 ? first.message : `GitHub: ${events.length} new notifications. ${first.message}`);
}
function isBackoffError(error) { const text = String(error?.message || error); return /\b(403|429)\b|network|fetch|timeout/i.test(text); }
export async function github(ctx, path, etagKey) {
const headers = { accept: "application/vnd.github+json", "user-agent": "OpenPets GitHub Notifications" };
const etag = await ctx.storage.get(etagKey);
if (typeof etag === "string" && etag) headers["if-none-match"] = etag;
const res = await ctx.http.fetch(`https://api.github.com${path}`, { headers, timeoutMs: 10000 });
if (res.headers?.etag) await ctx.storage.set(etagKey, res.headers.etag);
if (res.status === 304) return { ...res, json: undefined, notModified: true };
if (!res.ok) throw new Error(`GitHub API returned ${res.status}`);
return res;
}
export async function notify(ctx, message, reaction) { await ctx.pet.react(safeMessage(reaction, "idle")); await ctx.pet.speak(safeMessage(message)); }
export function safeMessage(value, fallback = DEFAULT_NOTIFICATION_MESSAGE) {
const message = typeof value === "string" && value.trim() ? value.trim().replace(/[\r\n]+/g, " ").replace(/\s+/g, " ") : fallback;
const capped = message.length > MAX_MESSAGE_LENGTH ? message.slice(0, MAX_MESSAGE_LENGTH).trim() : message;
if (!capped || UNSAFE_MESSAGE_PATTERN.test(capped)) return fallback;
return capped;
}
export function parseRepos(value) {
return parseReposDetailed(value).repos;
}
export function parseReposDetailed(value) {
const raw = Array.isArray(value) ? value.join("\n") : String(value || "");
const valid = [];
const invalid = [];
for (const item of raw.split(/[\n,\s]+/).map((x) => x.trim()).filter(Boolean)) (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(item) ? valid : invalid).push(item);
const unique = Array.from(new Set(valid));
return { repos: unique.slice(0, MAX_REPOS), invalid, truncated: unique.length > MAX_REPOS };
}
export function repoLimitForConfig(config = {}) {
let callsPerRepo = 0;
if (config.notifyReleases !== false) callsPerRepo += 1;
if (config.notifyFailedWorkflows !== false) callsPerRepo += 2;
if (config.notifyIssues === true) callsPerRepo += 1;
if (config.notifyPullRequests === true) callsPerRepo += 1;
return Math.max(1, Math.min(MAX_REPOS, Math.floor(28 / Math.max(1, callsPerRepo))));
}
export function format(template, values) { return safeMessage(String(template).replace(/\{(repo|tag|name)\}/g, (_m, key) => safeTemplateValue(values[key] || ""))); }
export function safeTemplateValue(value) { return String(value).replace(/[\r\n]+/g, " ").replace(/\//g, " ").replace(/\s+/g, " ").trim().slice(0, 80); }

View file

@ -0,0 +1,28 @@
{
"manifestVersion": 2,
"id": "openpets.github-notifications",
"name": "GitHub Notifications",
"version": "1.1.0",
"runtime": "javascript",
"icon": "github",
"sdkVersion": "1.0.0",
"entry": "index.js",
"permissions": ["network", "schedule", "storage", "pet:speak", "pet:reaction", "commands", "status"],
"network": { "hosts": ["api.github.com"] },
"configSchema": {
"repositories": { "type": "textarea", "label": "Public repositories", "description": "One owner/repo per line. Public repositories only.", "default": "" },
"pollIntervalMinutes": { "type": "number", "label": "Poll interval minutes", "default": 30, "min": 10, "max": 1440, "step": 5 },
"notifyReleases": { "type": "boolean", "label": "Notify new releases", "default": true },
"notifyFailedWorkflows": { "type": "boolean", "label": "Notify failed workflows", "default": true },
"notifyIssues": { "type": "boolean", "label": "Notify new issues", "default": false },
"notifyPullRequests": { "type": "boolean", "label": "Notify new pull requests", "default": false },
"releaseMessage": { "type": "text", "label": "Release message", "default": "New release: {repo} {tag}", "maxLength": 140 },
"workflowMessage": { "type": "text", "label": "Workflow failure message", "default": "Workflow failed in {repo}: {name}", "maxLength": 140 },
"issueMessage": { "type": "text", "label": "Issue message", "default": "New issue in {repo}: {name}", "maxLength": 140 },
"pullRequestMessage": { "type": "text", "label": "Pull request message", "default": "New pull request in {repo}: {name}", "maxLength": 140 },
"releaseReaction": { "type": "select", "label": "Release reaction", "default": "celebrating", "options": [{ "label": "Celebrating", "value": "celebrating" }, { "label": "Success", "value": "success" }, { "label": "Waving", "value": "waving" }, { "label": "Idle", "value": "idle" }] },
"workflowReaction": { "type": "select", "label": "Workflow reaction", "default": "error", "options": [{ "label": "Error", "value": "error" }, { "label": "Thinking", "value": "thinking" }, { "label": "Waiting", "value": "waiting" }, { "label": "Idle", "value": "idle" }] },
"issueReaction": { "type": "select", "label": "Issue reaction", "default": "thinking", "options": [{ "label": "Thinking", "value": "thinking" }, { "label": "Waving", "value": "waving" }, { "label": "Waiting", "value": "waiting" }, { "label": "Idle", "value": "idle" }] },
"pullRequestReaction": { "type": "select", "label": "Pull request reaction", "default": "waving", "options": [{ "label": "Waving", "value": "waving" }, { "label": "Success", "value": "success" }, { "label": "Thinking", "value": "thinking" }, { "label": "Idle", "value": "idle" }] }
}
}

View file

@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import { EMPTY_BASELINE, checkNow, format, parseRepos, parseReposDetailed, register, resetBaseline, repoLimitForConfig, safeMessage, showLastCheck } from "./index.js";
function harness(config, routes) {
const store = new Map();
const calls = { speak: [], react: [], status: [], every: [], cancel: [], commands: new Map(), warnings: [] };
return { store, calls, ctx: {
config: { get: async () => config }, storage: { get: async (k) => store.get(k), set: async (k, v) => store.set(k, v), delete: async (k) => store.delete(k) },
schedule: { cancel: async (id) => calls.cancel.push(id), every: async (id, ms, fn) => calls.every.push({ id, ms, fn }) }, status: { set: async (v) => calls.status.push(v) },
pet: { speak: async (m) => calls.speak.push(m), react: async (r) => calls.react.push(r) }, commands: { register: async (c, f) => calls.commands.set(c.id, { c, f }) }, log: { warn: (...a) => calls.warnings.push(a) },
http: { fetch: async (url) => { const key = new URL(url).pathname + new URL(url).search; const value = routes[key]; if (value instanceof Error) throw value; return { ok: true, status: 200, headers: {}, json: value || [] }; } }
}};
}
assert.deepEqual(parseRepos("a/b\na/b bad nope c/d"), ["a/b", "c/d"]);
assert.deepEqual(parseReposDetailed("a/b bad").invalid, ["bad"]);
assert.equal(repoLimitForConfig({ notifyIssues: true, notifyPullRequests: true }), 5);
assert.equal(repoLimitForConfig({ notifyIssues: false, notifyPullRequests: false }), 9);
assert.equal(safeMessage("secret token"), "New GitHub notification.");
assert.equal(format("New {repo}: {name}", { repo: "o/r", name: "hello/world" }), "New o r: hello world");
const routes1 = { "/repos/o/r": { default_branch: "trunk" }, "/repos/o/r/releases?per_page=1": [{ id: 1, tag_name: "v1" }], "/repos/o/r/actions/runs?status=completed&per_page=10&branch=trunk": { workflow_runs: [{ id: 2, name: "ci", conclusion: "failure" }] }, "/repos/o/r/issues?state=open&per_page=10&sort=created&direction=desc": [{ id: 3, title: "bug" }], "/repos/o/r/pulls?state=open&per_page=1&sort=created&direction=desc": [{ id: 4, title: "fix" }] };
{
const h = harness({ repositories: "o/r", notifyIssues: true, notifyPullRequests: true }, routes1);
await checkNow(h.ctx, false);
assert.equal(h.calls.speak.length, 0, "baseline does not notify");
await checkNow(h.ctx, true);
assert.equal(h.calls.speak.at(-1), "No new GitHub notifications.");
}
{
const emptyRoutes = { ...routes1, "/repos/o/r/releases?per_page=1": [], "/repos/o/r/actions/runs?status=completed&per_page=10&branch=trunk": { workflow_runs: [] }, "/repos/o/r/issues?state=open&per_page=10&sort=created&direction=desc": [], "/repos/o/r/pulls?state=open&per_page=1&sort=created&direction=desc": [] };
const h = harness({ repositories: "o/r", notifyIssues: true, notifyPullRequests: true }, emptyRoutes);
await checkNow(h.ctx, false);
assert.equal(h.store.get("release:o/r"), EMPTY_BASELINE);
h.ctx.http.fetch = async (url) => ({ ok: true, status: 200, headers: {}, json: routes1[new URL(url).pathname + new URL(url).search] || [] });
const result = await checkNow(h.ctx, false);
assert.equal(result.notifications, 4, "first real events after empty baseline notify");
}
{
const h = harness({ repositories: "o/r", notifyIssues: false, notifyPullRequests: false }, routes1);
await checkNow(h.ctx, false);
h.ctx.config.get = async () => ({ repositories: "o/r", notifyIssues: true, notifyPullRequests: true });
const result = await checkNow(h.ctx, false);
assert.equal(result.notifications, 0, "newly enabled event types baseline without announcing old items");
assert.equal(h.store.get("issue:o/r"), "3");
assert.equal(h.store.get("pr:o/r"), "4");
}
{
const h = harness({ repositories: "o/r", notifyFailedWorkflows: false }, routes1);
await checkNow(h.ctx, false);
assert.equal(h.store.get("release:o/r"), "1");
h.ctx.http.fetch = async () => ({ ok: true, status: 304, headers: {}, json: undefined });
await checkNow(h.ctx, false);
assert.equal(h.store.get("release:o/r"), "1", "304 does not overwrite existing seen id with empty baseline");
h.ctx.http.fetch = async (url) => ({ ok: true, status: 200, headers: {}, json: routes1[new URL(url).pathname + new URL(url).search] || [] });
const result = await checkNow(h.ctx, false);
assert.equal(result.notifications, 0, "200 -> 304 -> 200 same id does not re-announce");
}
{
const routes2 = { ...routes1, "/repos/o/r/releases?per_page=1": [{ id: 10, tag_name: "v2" }], "/repos/o/r/actions/runs?status=completed&per_page=10&branch=trunk": { workflow_runs: [{ id: 20, name: "ci", conclusion: "timed_out" }] }, "/repos/o/r/issues?state=open&per_page=10&sort=created&direction=desc": [{ id: 30, title: "bug2" }, { id: 31, pull_request: {}, title: "pr as issue" }], "/repos/o/r/pulls?state=open&per_page=1&sort=created&direction=desc": [{ id: 40, title: "fix2" }] };
const h = harness({ repositories: "o/r", notifyIssues: true, notifyPullRequests: true }, routes1);
await checkNow(h.ctx, false); h.ctx.http.fetch = async (url) => ({ ok: true, status: 200, headers: {}, json: routes2[new URL(url).pathname + new URL(url).search] || [] });
const result = await checkNow(h.ctx, false);
assert.equal(result.notifications, 4);
assert.equal(h.calls.speak.length, 1, "batched notifications use one speech");
}
{
const h = harness({ repositories: "o/r x/y", notifyIssues: true }, { ...routes1, "/repos/x/y/releases?per_page=1": new Error("boom") });
h.ctx.http.fetch = async (url) => { const key = new URL(url).pathname + new URL(url).search; const value = h.ctx.config && { ...routes1, "/repos/x/y/releases?per_page=1": new Error("boom") }[key]; if (value instanceof Error) throw value; return { ok: true, status: 200, headers: {}, json: value || [] }; };
const result = await checkNow(h.ctx, true);
assert.equal(result.failures, 1);
assert.ok(h.calls.speak.at(-1).includes("failures"));
await showLastCheck(h.ctx); assert.ok(h.calls.speak.at(-1).includes("Last GitHub check"));
}
{
const h = harness({ repositories: "bad o/r" }, routes1);
const result = await checkNow(h.ctx, true);
assert.equal(result.invalid.length, 1);
assert.ok(h.calls.speak.at(-1).includes("invalid"));
}
{
const h = harness({ repositories: "o/r" }, { ...routes1, "/repos/o/r/releases?per_page=1": new Error("network down") });
await checkNow(h.ctx, false);
assert.ok(Number(h.store.get("backoff:o/r")) > Date.now());
const result = await checkNow(h.ctx, true);
assert.equal(result.backoffSkipped, 1);
}
{
const h = harness({ repositories: "o/r x/y", notifyIssues: true }, { ...routes1, "/repos/x/y/releases?per_page=1": new Error("network down") });
h.ctx.http.fetch = async (url) => { const key = new URL(url).pathname + new URL(url).search; const value = { ...routes1, "/repos/x/y/releases?per_page=1": new Error("network down") }[key]; if (value instanceof Error) throw value; return { ok: true, status: 200, headers: {}, json: value || [] }; };
await resetBaseline(h.ctx);
assert.ok(h.calls.speak.at(-1).includes("partially reset"));
}
{
const h = harness({ repositories: "o/r" }, routes1);
let resolveFetch;
let blocked = true;
h.ctx.http.fetch = async (url) => {
if (blocked) { blocked = false; return await new Promise((resolve) => { resolveFetch = () => resolve({ ok: true, status: 200, headers: {}, json: [] }); }); }
const key = new URL(url).pathname + new URL(url).search;
return { ok: true, status: 200, headers: {}, json: routes1[key] || [] };
};
const first = checkNow(h.ctx, false);
await Promise.resolve(); await Promise.resolve();
const second = await checkNow(h.ctx, true);
assert.equal(second.reason, "already-running");
assert.equal(h.calls.speak.at(-1), "GitHub check already running.");
resolveFetch(); await first;
}
{
const h = harness({}, {}); const plugin = { register(def) { this.def = def; } }; register(plugin); await plugin.def.start(h.ctx); assert.ok(h.calls.commands.has("show-last-check"));
await h.calls.commands.get("check-now").f();
assert.ok(h.calls.status.at(-1).text.includes("checking"));
}
console.log("GitHub Notifications plugin tests passed.");

View file

@ -0,0 +1,271 @@
export const STATE_KEY = "pomodoroState";
export const SCHEDULE_ID = "phase-end";
const MIN_DELAY_MS = 1;
const MAX_MESSAGE_LENGTH = 140;
const UNSAFE_MESSAGE_PATTERN = /```|<script|function\s+\w+|=>|\b(class|import|export|const|let|var)\b|https?:\/\/|www\.|\/[\w.-]+\/[\w./-]+|[A-Za-z]:\\|api[_-]?key|secret|token|password|passwd|BEGIN [A-Z ]+PRIVATE KEY/i;
const DEFAULTS = {
focusMinutes: 25,
shortBreakMinutes: 5,
longBreakMinutes: 15,
sessionsBeforeLongBreak: 4,
autoStartBreaks: false,
autoStartFocus: false,
focusStartMessage: "Focus time! Pick one task and protect your attention.",
focusCompleteMessage: "Focus session complete. Nice work!",
breakStartMessage: "Break time. Stretch, hydrate, and rest your eyes.",
breakCompleteMessage: "Break complete. Ready for the next focus block?",
focusStartReaction: "waving",
focusCompleteReaction: "success",
breakStartReaction: "waiting",
breakCompleteReaction: "waving",
};
export function clampNumber(value, fallback, min, max) {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.min(max, Math.max(min, Math.round(number)));
}
export function normalizeConfig(config = {}) {
return {
focusMinutes: clampNumber(config.focusMinutes, DEFAULTS.focusMinutes, 1, 180),
shortBreakMinutes: clampNumber(config.shortBreakMinutes, DEFAULTS.shortBreakMinutes, 1, 60),
longBreakMinutes: clampNumber(config.longBreakMinutes, DEFAULTS.longBreakMinutes, 1, 120),
sessionsBeforeLongBreak: clampNumber(config.sessionsBeforeLongBreak, DEFAULTS.sessionsBeforeLongBreak, 1, 12),
autoStartBreaks: config.autoStartBreaks === true,
autoStartFocus: config.autoStartFocus === true,
focusStartMessage: text(config.focusStartMessage, DEFAULTS.focusStartMessage),
focusCompleteMessage: text(config.focusCompleteMessage, DEFAULTS.focusCompleteMessage),
breakStartMessage: text(config.breakStartMessage, DEFAULTS.breakStartMessage),
breakCompleteMessage: text(config.breakCompleteMessage, DEFAULTS.breakCompleteMessage),
focusStartReaction: text(config.focusStartReaction, DEFAULTS.focusStartReaction),
focusCompleteReaction: text(config.focusCompleteReaction, DEFAULTS.focusCompleteReaction),
breakStartReaction: text(config.breakStartReaction, DEFAULTS.breakStartReaction),
breakCompleteReaction: text(config.breakCompleteReaction, DEFAULTS.breakCompleteReaction),
};
}
function text(value, fallback) {
const message = typeof value === "string" && value.trim() ? value.trim().replace(/[\r\n]+/g, " ").replace(/\s+/g, " ") : fallback;
const capped = message.length > MAX_MESSAGE_LENGTH ? message.slice(0, MAX_MESSAGE_LENGTH).trim() : message;
if (!capped || UNSAFE_MESSAGE_PATTERN.test(capped)) return fallback;
return capped;
}
export function today() {
return new Date().toISOString().slice(0, 10);
}
export function idleState(completedSessions = 0, completedToday = 0) {
return { phase: "idle", completedSessions, completedToday, lastActiveDate: today() };
}
export async function getState(ctx) {
const saved = await ctx.storage.get(STATE_KEY);
if (!saved || typeof saved !== "object") return idleState();
const activeDate = typeof saved.lastActiveDate === "string" ? saved.lastActiveDate : today();
const sameDay = activeDate === today();
return {
phase: typeof saved.phase === "string" ? saved.phase : "idle",
previousPhase: typeof saved.previousPhase === "string" ? saved.previousPhase : undefined,
endAt: typeof saved.endAt === "string" ? saved.endAt : undefined,
remainingMs: Number.isFinite(Number(saved.remainingMs)) ? Number(saved.remainingMs) : undefined,
pendingBreakPhase: ["shortBreak", "longBreak"].includes(saved.pendingBreakPhase) ? saved.pendingBreakPhase : undefined,
completedSessions: Math.max(0, Math.round(Number(saved.completedSessions) || 0)),
completedToday: sameDay ? Math.max(0, Math.round(Number(saved.completedToday) || 0)) : 0,
lastCompletedAt: typeof saved.lastCompletedAt === "string" ? saved.lastCompletedAt : undefined,
lastActiveDate: today(),
};
}
export async function setState(ctx, state) {
await ctx.storage.set(STATE_KEY, { ...state, lastActiveDate: today() });
await updateStatus(ctx, state);
}
function phaseLabel(phase) {
if (phase === "focus") return "Focus";
if (phase === "shortBreak") return "Short break";
if (phase === "longBreak") return "Long break";
if (phase === "paused") return "Paused";
return "Idle";
}
export async function updateStatus(ctx, state) {
if (state.phase === "idle") {
if (state.pendingBreakPhase) {
await ctx.status.set({ text: `${phaseLabel(state.pendingBreakPhase)} ready (${state.completedToday || 0} completed today)`, tone: "success" });
return;
}
await ctx.status.set({ text: `Pomodoro idle (${state.completedToday || 0} completed today)`, tone: "info" });
return;
}
if (state.phase === "paused") {
await ctx.status.set({ text: `Paused with ${formatMs(state.remainingMs || 0)} left`, tone: "warning" });
return;
}
const end = state.endAt ? new Date(state.endAt) : undefined;
await ctx.status.set({ text: `${phaseLabel(state.phase)} until ${end && !Number.isNaN(end.getTime()) ? end.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "soon"}`, tone: "success" });
}
export function formatMs(ms) {
const minutes = Math.max(1, Math.ceil(ms / 60_000));
return `${minutes} min`;
}
export function durationForPhase(phase, config) {
if (phase === "focus") return config.focusMinutes * 60_000;
if (phase === "longBreak") return config.longBreakMinutes * 60_000;
return config.shortBreakMinutes * 60_000;
}
export function nextBreakPhase(completedSessions, config) {
return completedSessions > 0 && completedSessions % config.sessionsBeforeLongBreak === 0 ? "longBreak" : "shortBreak";
}
async function announce(ctx, message, reaction) {
await ctx.pet.speak(message);
await ctx.pet.react(reaction);
}
export async function schedulePhaseEnd(ctx, state) {
await ctx.schedule.cancel(SCHEDULE_ID);
if (!state.endAt || !["focus", "shortBreak", "longBreak"].includes(state.phase)) return;
const delay = new Date(state.endAt).getTime() - Date.now();
if (!Number.isFinite(delay) || delay < MIN_DELAY_MS) return;
await ctx.schedule.once(SCHEDULE_ID, Math.max(MIN_DELAY_MS, delay), () => completePhase(ctx));
}
export async function startPhase(ctx, phase, durationMs, options = {}) {
const previous = await getState(ctx);
const state = { phase, endAt: new Date(Date.now() + Math.max(MIN_DELAY_MS, durationMs)).toISOString(), remainingMs: undefined, completedSessions: previous.completedSessions || 0, completedToday: previous.completedToday || 0, lastCompletedAt: previous.lastCompletedAt, lastActiveDate: today() };
await setState(ctx, state);
await schedulePhaseEnd(ctx, state);
if (options.announce !== false) {
const config = normalizeConfig(await ctx.config.get());
if (phase === "focus") await announce(ctx, config.focusStartMessage, config.focusStartReaction);
else await announce(ctx, config.breakStartMessage, config.breakStartReaction);
}
}
export async function completePhase(ctx) {
await ctx.schedule.cancel(SCHEDULE_ID);
const config = normalizeConfig(await ctx.config.get());
const state = await getState(ctx);
if (state.phase === "focus") {
const completedSessions = (state.completedSessions || 0) + 1;
const completedToday = (state.completedToday || 0) + 1;
const lastCompletedAt = new Date().toISOString();
await announce(ctx, config.focusCompleteMessage, config.focusCompleteReaction);
const breakPhase = nextBreakPhase(completedSessions, config);
await setState(ctx, { ...idleState(completedSessions, completedToday), lastCompletedAt, phase: "idle", pendingBreakPhase: config.autoStartBreaks ? undefined : breakPhase });
if (config.autoStartBreaks) await startPhase(ctx, breakPhase, durationForPhase(breakPhase, config), { announce: false });
return;
}
if (state.phase === "shortBreak" || state.phase === "longBreak") {
await announce(ctx, config.breakCompleteMessage, config.breakCompleteReaction);
const completedSessions = state.completedSessions || 0;
await setState(ctx, { ...idleState(completedSessions, state.completedToday || 0), lastCompletedAt: state.lastCompletedAt });
if (config.autoStartFocus) await startPhase(ctx, "focus", durationForPhase("focus", config));
}
}
export async function pause(ctx) {
const state = await getState(ctx);
if (!["focus", "shortBreak", "longBreak"].includes(state.phase) || !state.endAt) return;
await ctx.schedule.cancel(SCHEDULE_ID);
await setState(ctx, { phase: "paused", previousPhase: state.phase, remainingMs: Math.max(MIN_DELAY_MS, new Date(state.endAt).getTime() - Date.now()), completedSessions: state.completedSessions || 0, completedToday: state.completedToday || 0, lastCompletedAt: state.lastCompletedAt, lastActiveDate: today() });
}
export async function resume(ctx) {
const state = await getState(ctx);
if (state.phase !== "paused") return;
const phase = ["focus", "shortBreak", "longBreak"].includes(state.previousPhase) ? state.previousPhase : "focus";
await startPhase(ctx, phase, Math.max(MIN_DELAY_MS, state.remainingMs || MIN_DELAY_MS), { announce: false });
}
export async function stop(ctx) {
const state = await getState(ctx);
await ctx.schedule.cancel(SCHEDULE_ID);
await setState(ctx, { ...idleState(state.completedSessions || 0, state.completedToday || 0), lastCompletedAt: state.lastCompletedAt });
}
export function statusSummary(state) {
if (state.phase === "paused") return `Pomodoro paused with ${formatMs(state.remainingMs || 0)} left.`;
if (["focus", "shortBreak", "longBreak"].includes(state.phase)) return `${phaseLabel(state.phase)} running until ${state.endAt ? new Date(state.endAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "soon"}. ${state.completedToday || 0} completed today.`;
if (state.pendingBreakPhase) return `Pomodoro idle. ${phaseLabel(state.pendingBreakPhase)} is ready. ${state.completedToday || 0} focus sessions completed today.`;
return `Pomodoro idle. ${state.completedToday || 0} focus sessions completed today.`;
}
export async function reconcileStartup(ctx) {
const state = await getState(ctx);
if (!["focus", "shortBreak", "longBreak"].includes(state.phase) || !state.endAt || new Date(state.endAt).getTime() > Date.now()) return state;
await ctx.schedule.cancel(SCHEDULE_ID);
const config = normalizeConfig(await ctx.config.get());
if (state.phase === "focus") {
const completedSessions = (state.completedSessions || 0) + 1;
const completedToday = (state.completedToday || 0) + 1;
const pendingBreakPhase = nextBreakPhase(completedSessions, config);
const next = { ...idleState(completedSessions, completedToday), lastCompletedAt: new Date().toISOString(), pendingBreakPhase };
await setState(ctx, next);
await announce(ctx, "Focus ended while you were away. Your next break is ready.", config.focusCompleteReaction);
return next;
}
const next = { ...idleState(state.completedSessions || 0, state.completedToday || 0), lastCompletedAt: state.lastCompletedAt };
await setState(ctx, next);
await announce(ctx, "Break ended while you were away.", config.breakCompleteReaction);
return next;
}
export async function startNextBreak(ctx) {
const state = await getState(ctx);
const phase = ["shortBreak", "longBreak"].includes(state.pendingBreakPhase) ? state.pendingBreakPhase : undefined;
if (!phase) { await ctx.pet.speak("No pending Pomodoro break is ready."); return false; }
const config = normalizeConfig(await ctx.config.get());
await startPhase(ctx, phase, durationForPhase(phase, config));
return true;
}
export async function resetCount(ctx) {
const state = await getState(ctx);
await setState(ctx, { ...state, completedSessions: 0, completedToday: 0, lastCompletedAt: undefined });
await ctx.pet.speak("Pomodoro counts reset for today.");
}
export function register(OpenPetsPlugin) {
OpenPetsPlugin.register({
async start(ctx) {
const state = await reconcileStartup(ctx);
await updateStatus(ctx, state);
await schedulePhaseEnd(ctx, state);
await ctx.commands.register({ id: "start-focus", title: "Start focus", description: "Start a Pomodoro focus session." }, async () => {
const config = normalizeConfig(await ctx.config.get());
await startPhase(ctx, "focus", durationForPhase("focus", config));
});
await ctx.commands.register({ id: "start-short-break", title: "Start short break", description: "Start a short Pomodoro break." }, async () => {
const config = normalizeConfig(await ctx.config.get());
await startPhase(ctx, "shortBreak", durationForPhase("shortBreak", config));
});
await ctx.commands.register({ id: "start-long-break", title: "Start long break", description: "Start a long Pomodoro break." }, async () => {
const config = normalizeConfig(await ctx.config.get());
await startPhase(ctx, "longBreak", durationForPhase("longBreak", config));
});
await ctx.commands.register({ id: "start-next-break", title: "Start next break", description: "Start the pending Pomodoro break." }, () => startNextBreak(ctx));
await ctx.commands.register({ id: "show-status", title: "Show Pomodoro status", description: "Speak the current timer phase and daily count." }, async () => ctx.pet.speak(statusSummary(await getState(ctx))));
await ctx.commands.register({ id: "reset-count", title: "Reset Pomodoro count", description: "Reset today's completed focus count." }, () => resetCount(ctx));
await ctx.commands.register({ id: "pause", title: "Pause Pomodoro", description: "Pause the current phase." }, () => pause(ctx));
await ctx.commands.register({ id: "resume", title: "Resume Pomodoro", description: "Resume a paused phase." }, () => resume(ctx));
await ctx.commands.register({ id: "stop", title: "Stop Pomodoro", description: "Stop and return to idle." }, () => stop(ctx));
await ctx.commands.register({ id: "skip-phase", title: "Skip phase", description: "Complete the current phase now." }, () => completePhase(ctx));
await ctx.commands.register({ id: "test-complete", title: "Test completion", description: "Preview the current phase completion message." }, async () => {
const config = normalizeConfig(await ctx.config.get());
const current = await getState(ctx);
if (current.phase === "shortBreak" || current.phase === "longBreak") await announce(ctx, config.breakCompleteMessage, config.breakCompleteReaction);
else await announce(ctx, config.focusCompleteMessage, config.focusCompleteReaction);
});
},
async stop() {}
});
}

View file

@ -0,0 +1,124 @@
{
"manifestVersion": 2,
"id": "openpets.pomodoro",
"name": "Pomodoro",
"version": "1.1.0",
"runtime": "javascript",
"icon": "timer",
"sdkVersion": "1.0.0",
"entry": "index.js",
"permissions": ["pet:speak", "pet:reaction", "schedule", "storage", "commands", "status"],
"configSchema": {
"focusMinutes": {
"type": "number",
"label": "Focus minutes",
"description": "Length of a focus session.",
"default": 25,
"min": 1,
"max": 180,
"step": 1
},
"shortBreakMinutes": {
"type": "number",
"label": "Short break minutes",
"default": 5,
"min": 1,
"max": 60,
"step": 1
},
"longBreakMinutes": {
"type": "number",
"label": "Long break minutes",
"default": 15,
"min": 1,
"max": 120,
"step": 1
},
"sessionsBeforeLongBreak": {
"type": "number",
"label": "Sessions before long break",
"default": 4,
"min": 1,
"max": 12,
"step": 1
},
"autoStartBreaks": {
"type": "boolean",
"label": "Auto-start breaks",
"default": false
},
"autoStartFocus": {
"type": "boolean",
"label": "Auto-start focus after breaks",
"default": false
},
"focusStartMessage": {
"type": "textarea",
"label": "Focus start message",
"default": "Focus time! Pick one task and protect your attention.",
"maxLength": 140
},
"focusCompleteMessage": {
"type": "textarea",
"label": "Focus complete message",
"default": "Focus session complete. Nice work!",
"maxLength": 140
},
"breakStartMessage": {
"type": "textarea",
"label": "Break start message",
"default": "Break time. Stretch, hydrate, and rest your eyes.",
"maxLength": 140
},
"breakCompleteMessage": {
"type": "textarea",
"label": "Break complete message",
"default": "Break complete. Ready for the next focus block?",
"maxLength": 140
},
"focusStartReaction": {
"type": "select",
"label": "Focus start reaction",
"default": "waving",
"options": [
{ "label": "Waving", "value": "waving" },
{ "label": "Waiting", "value": "waiting" },
{ "label": "Success", "value": "success" },
{ "label": "Celebrating", "value": "celebrating" }
]
},
"focusCompleteReaction": {
"type": "select",
"label": "Focus complete reaction",
"default": "success",
"options": [
{ "label": "Waving", "value": "waving" },
{ "label": "Waiting", "value": "waiting" },
{ "label": "Success", "value": "success" },
{ "label": "Celebrating", "value": "celebrating" }
]
},
"breakStartReaction": {
"type": "select",
"label": "Break start reaction",
"default": "waiting",
"options": [
{ "label": "Waving", "value": "waving" },
{ "label": "Waiting", "value": "waiting" },
{ "label": "Success", "value": "success" },
{ "label": "Celebrating", "value": "celebrating" }
]
},
"breakCompleteReaction": {
"type": "select",
"label": "Break complete reaction",
"default": "waving",
"options": [
{ "label": "Waving", "value": "waving" },
{ "label": "Waiting", "value": "waiting" },
{ "label": "Success", "value": "success" },
{ "label": "Celebrating", "value": "celebrating" }
]
}
}
}

View file

@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import { completePhase, getState, normalizeConfig, pause, reconcileStartup, register, resetCount, resume, startPhase, statusSummary } from "./index.js";
function ctx(config = {}) {
const store = new Map();
const calls = { speak: [], react: [], status: [], cancel: [], once: [], commands: new Map() };
return { calls, store, ctx: {
config: { get: async () => config },
storage: { get: async (k) => store.get(k), set: async (k, v) => store.set(k, v) },
schedule: { cancel: async (id) => calls.cancel.push(id), once: async (id, ms, fn) => calls.once.push({ id, ms, fn }) },
status: { set: async (v) => calls.status.push(v) },
pet: { speak: async (m) => calls.speak.push(m), react: async (r) => calls.react.push(r) },
commands: { register: async (cmd, fn) => calls.commands.set(cmd.id, { cmd, fn }) },
}};
}
assert.equal(normalizeConfig({ focusMinutes: 999, focusStartMessage: "token leak" }).focusMinutes, 180);
assert.equal(normalizeConfig({ focusStartMessage: "token leak" }).focusStartMessage, "Focus time! Pick one task and protect your attention.");
{
const h = ctx({ focusMinutes: 1 });
await startPhase(h.ctx, "focus", 60_000);
assert.equal(h.calls.once.length, 1);
assert.equal((await getState(h.ctx)).phase, "focus");
await completePhase(h.ctx);
const state = await getState(h.ctx);
assert.equal(state.completedSessions, 1);
assert.equal(state.completedToday, 1);
assert.equal(state.pendingBreakPhase, "shortBreak");
assert.ok(statusSummary(state).includes("ready"));
}
{
const h = ctx({ autoStartBreaks: true });
await startPhase(h.ctx, "focus", 60_000, { announce: false });
await completePhase(h.ctx);
assert.equal(h.calls.speak.length, 1, "auto transition avoids double speech");
assert.equal((await getState(h.ctx)).phase, "shortBreak");
}
{
const h = ctx();
await startPhase(h.ctx, "focus", 60_000, { announce: false });
await pause(h.ctx);
assert.equal((await getState(h.ctx)).phase, "paused");
await resume(h.ctx);
assert.equal((await getState(h.ctx)).phase, "focus");
}
{
const h = ctx();
const plugin = { register(def) { this.def = def; } };
register(plugin);
await plugin.def.start(h.ctx);
for (const id of ["start-focus", "start-short-break", "start-long-break", "start-next-break", "show-status", "reset-count"]) assert.ok(h.calls.commands.has(id), id);
await h.calls.commands.get("show-status").fn();
assert.ok(h.calls.speak.at(-1).includes("Pomodoro"));
assert.ok(statusSummary(await getState(h.ctx)).includes("idle"));
h.store.set("pomodoroState", { phase: "idle", pendingBreakPhase: "shortBreak", completedSessions: 1, completedToday: 1, lastActiveDate: new Date().toISOString().slice(0, 10) });
await h.calls.commands.get("start-next-break").fn();
assert.equal((await getState(h.ctx)).phase, "shortBreak");
await h.calls.commands.get("reset-count").fn();
assert.equal((await getState(h.ctx)).completedToday, 0);
await resetCount(h.ctx);
}
{
const h = ctx();
h.store.set("pomodoroState", { phase: "focus", endAt: new Date(Date.now() - 1000).toISOString(), completedSessions: 1, completedToday: 1, lastActiveDate: new Date().toISOString().slice(0, 10) });
const settled = await reconcileStartup(h.ctx);
assert.equal(settled.phase, "idle");
assert.equal(settled.completedToday, 2);
assert.equal(settled.pendingBreakPhase, "shortBreak");
assert.equal(h.calls.speak.length, 1);
}
console.log("Pomodoro plugin tests passed.");

66
scripts/test-plugins.mjs Normal file
View file

@ -0,0 +1,66 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = fileURLToPath(new URL("..", import.meta.url));
const officialDir = join(repoRoot, "plugins", "official");
const files = [];
const tests = [];
async function pathExists(path) {
try {
await readdir(path);
return true;
} catch (error) {
if (error.code === "ENOENT") return false;
throw error;
}
}
async function collectPluginChecks() {
if (!(await pathExists(officialDir))) return;
const plugins = await readdir(officialDir, { withFileTypes: true });
for (const plugin of plugins) {
if (!plugin.isDirectory() || plugin.name.startsWith(".")) continue;
const pluginDir = join(officialDir, plugin.name);
files.push(join(pluginDir, "index.js"));
await collectTestFiles(pluginDir);
}
}
async function collectTestFiles(dir) {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith(".")) continue;
const path = join(dir, entry.name);
if (entry.isDirectory()) {
await collectTestFiles(path);
} else if (entry.isFile() && entry.name === "test.js") {
files.push(path);
tests.push(path);
}
}
}
await collectPluginChecks();
if (files.length === 0) {
console.log("No plugin JavaScript files found.");
process.exit(0);
}
for (const file of files) {
const result = spawnSync(process.execPath, ["--check", file], { stdio: "inherit" });
if (result.status !== 0) process.exit(result.status ?? 1);
}
for (const file of tests) {
const result = spawnSync(process.execPath, [file], { stdio: "inherit" });
if (result.status !== 0) process.exit(result.status ?? 1);
}
console.log(`Checked ${files.length} plugin JavaScript file${files.length === 1 ? "" : "s"}; ran ${tests.length} plugin test${tests.length === 1 ? "" : "s"}.`);