release desktop v2.1.2
This commit is contained in:
parent
d19889b378
commit
8220d2259b
23 changed files with 1564 additions and 58 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
211
plugins/official/openpets.daily-reminders/index.js
Normal file
211
plugins/official/openpets.daily-reminders/index.js
Normal 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() {}
|
||||
});
|
||||
}
|
||||
121
plugins/official/openpets.daily-reminders/openpets.plugin.json
Normal file
121
plugins/official/openpets.daily-reminders/openpets.plugin.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
133
plugins/official/openpets.daily-reminders/test.js
Normal file
133
plugins/official/openpets.daily-reminders/test.js
Normal 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.");
|
||||
205
plugins/official/openpets.github-notifications/index.js
Normal file
205
plugins/official/openpets.github-notifications/index.js
Normal 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); }
|
||||
|
|
@ -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" }] }
|
||||
}
|
||||
}
|
||||
115
plugins/official/openpets.github-notifications/test.js
Normal file
115
plugins/official/openpets.github-notifications/test.js
Normal 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.");
|
||||
271
plugins/official/openpets.pomodoro/index.js
Normal file
271
plugins/official/openpets.pomodoro/index.js
Normal 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() {}
|
||||
});
|
||||
}
|
||||
124
plugins/official/openpets.pomodoro/openpets.plugin.json
Normal file
124
plugins/official/openpets.pomodoro/openpets.plugin.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
77
plugins/official/openpets.pomodoro/test.js
Normal file
77
plugins/official/openpets.pomodoro/test.js
Normal 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
66
scripts/test-plugins.mjs
Normal 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"}.`);
|
||||
Loading…
Add table
Reference in a new issue