diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..f3fa385 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,91 @@ +# ssync + +ssync is a local workflow surface for monitoring and operating SLURM work across configured HPC clusters. The language below keeps the user-facing concepts precise across the CLI, web UI, mobile app, VS Code extension, and Raycast extension. + +## Language + +**Host**: +A configured HPC cluster endpoint that can report and operate on SLURM jobs. Jobs are grouped by host when showing cross-cluster status. +_Avoid_: Backend, cluster when referring to the configured endpoint + +**ssync API Server**: +The local service that exposes ssync status, job detail, output, launch, watcher, and configuration endpoints to clients. +_Avoid_: Backend + +**ssync API URL**: +The URL where a client reaches the ssync API server. +_Avoid_: Server URL + +**ssync API Key**: +The credential used by a client when the ssync API server requires authentication. +_Avoid_: Token, password + +**ssync Connection**: +A client configuration consisting of an ssync API URL and an optional ssync API key that has passed a connection test. +_Avoid_: Configuration when specifically referring to the tested client connection + +**Job**: +A SLURM workload tracked by ssync on a host. A job may be active, waiting, completed, failed, cancelled, timed out, or unknown. +_Avoid_: Task, run when referring to the scheduler object + +**Running Job**: +A job that is currently executing on allocated resources. +_Avoid_: Active job when specifically meaning running only + +**Pending Job**: +A job that is waiting for scheduling or resources before execution. +_Avoid_: Queued task + +**Historical Job**: +A job that is no longer running or pending, such as completed, failed, cancelled, timed out, or unknown work. +_Avoid_: The rest, old job + +**Historical Job Window**: +The time range used when loading historical jobs for a client view. +_Avoid_: Completed jobs window + +**Job Detail**: +The focused view of one job, including its scheduler metadata, resource allocation, timing, paths, outputs, script, manifest, and related watcher information when available. +_Avoid_: Job page when speaking across multiple clients + +**Job Output**: +The stdout and stderr content associated with a job. +_Avoid_: Logs when specifically referring to scheduler output files + +**Local Job Output Copy**: +A desktop-local copy of Job Output used when a client opens the output in an external application. +_Avoid_: Remote output file + +**Job Script**: +The submitted batch script associated with a job. +_Avoid_: Launch script + +**Manual Relaunch**: +A user-initiated launch of a new job based on a previous job's script or stored launch request. +_Avoid_: Resubmit when the user is manually starting a new job + +**Watcher**: +A persistent rule attached to a job or workflow that observes job output or state and can perform follow-up behavior. +_Avoid_: Monitor, daemon when referring to the rule itself + +**Watcher Event**: +A recorded occurrence from watcher evaluation or action execution. +_Avoid_: Notification event, log line + +**Watcher Resubmission**: +A watcher-initiated launch of a new job from cached job script or manifest context. +_Avoid_: Manual relaunch + +## Example Dialogue + +Dev: Should the Raycast extension show every job in one flat list? + +Domain expert: No. Group jobs by host, show running jobs first, then pending jobs, then historical jobs. + +Dev: When a user opens a job, should watcher information live somewhere else? + +Domain expert: No. Job detail should include related watchers and watcher events so the user can understand what follow-up behavior is attached to that job. + +Dev: Should a user action and a watcher action both be called resubmit? + +Domain expert: No. A user starts a manual relaunch; a watcher performs watcher resubmission. diff --git a/docs/raycast-extension-plan.md b/docs/raycast-extension-plan.md new file mode 100644 index 0000000..79fc003 --- /dev/null +++ b/docs/raycast-extension-plan.md @@ -0,0 +1,60 @@ +# Raycast Extension Plan + +## Scope + +The first Raycast extension release is a monitoring surface for ssync jobs. It should make running and pending work easy to inspect without adding background load to the ssync API server. + +## V1 Features + +- Configure and test an ssync connection before first use. +- Load jobs cache-first, then revalidate when the cached snapshot is older than 60 seconds. +- Show the main Jobs command as lifecycle-first sections: + - Running Jobs + - Pending Jobs + - Historical Jobs +- Default historical job window: `3d`. +- Default job limit: `50`. +- Search locally across the loaded job snapshot. +- Show Job Detail as a sectioned read-only inspector list with lazy secondary actions. +- Fetch Job Output only when requested. +- Default Job Output to stdout, tail-limited. +- Allow switching from stdout to stderr when needed. +- Allow opening a refreshed Local Job Output Copy in a configured external editor. +- Fetch Job Script only when requested. +- Fetch Watchers and Watcher Events only when requested. +- Keep watchers read-only in v1. +- Include a menu-bar command with running jobs first and pending jobs second. +- Keep menu-bar background refresh conservative, defaulting to 5 minutes. +- Allow blank API keys when the ssync API server accepts the connection. +- Guard cancellation behind confirmation. + +## Deferred + +- Manual relaunch. +- Watcher edit, delete, attach, pause, resume, or trigger actions. +- WebSocket support. +- Launch recipe browsing or submission. +- Historical jobs in the menu-bar dropdown. + +## Backend Load Rules + +- Do not poll on every search keystroke. +- Do not fetch output, script, manifest, watchers, or watcher events while rendering the main Jobs list. +- Do not fetch both stdout and stderr by default. +- Do not force-refresh main job status by default. +- Manual refresh may ask the ssync API server for fresh job status. +- Background menu-bar refresh must only fetch job status. +- Opening Job Output should force-refresh the selected stream once, then perform at most one delayed follow-up read if the ssync API server queued a background output refresh. + +## Output And Script Views + +- Job Detail uses a Raycast `List` inspector so status, placement, timing, paths, and related views are selectable with native keyboard navigation. +- Job Output defaults to `output_type=stdout&lines=300`. +- Stderr is a secondary action, not loaded by default. +- Full output is a deliberate secondary action, not the default. +- Opening a Local Job Output Copy downloads the full selected stream and stores it under the system temporary directory before opening it externally. +- The external output editor is user-configurable: system default, Visual Studio Code, Cursor, Neovim in Ghostty, or a custom Raycast app picker value. +- Job Script opens as a separate formatted detail view. +- Output and script text should be displayed in a monospace code block with job metadata in Raycast metadata sidebars. +- Avoid markdown tables in Raycast views; use `Detail.Metadata` or `List.Item.Detail.Metadata` for structured facts. +- Raycast action shortcuts may support view-level commands such as refresh, switch to stderr, copy, and load full output. Custom vim-style single-key scrolling is not assumed to be available inside Raycast extension views. diff --git a/raycast-extension/.prettierrc b/raycast-extension/.prettierrc new file mode 100644 index 0000000..f3bcd4c --- /dev/null +++ b/raycast-extension/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": false, + "trailingComma": "all" +} diff --git a/raycast-extension/assets/icon.png b/raycast-extension/assets/icon.png new file mode 100644 index 0000000..c1c19cd Binary files /dev/null and b/raycast-extension/assets/icon.png differ diff --git a/raycast-extension/assets/icon.svg b/raycast-extension/assets/icon.svg new file mode 100644 index 0000000..0dd0e09 --- /dev/null +++ b/raycast-extension/assets/icon.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/raycast-extension/package-lock.json b/raycast-extension/package-lock.json new file mode 100644 index 0000000..38de2a5 --- /dev/null +++ b/raycast-extension/package-lock.json @@ -0,0 +1,1545 @@ +{ + "name": "ssync", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ssync", + "license": "Apache-2.0", + "dependencies": { + "@raycast/api": "^1.104.19", + "react": "19.0.0" + }, + "devDependencies": { + "@types/node": "22.19.17", + "@types/react": "19.0.10", + "typescript": "^5.9.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@oclif/core": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/@oclif/core/-/core-4.11.4.tgz", + "integrity": "sha512-URwiQ5ALx/sJ2iH4vzXEd+H4K6NAI7LRs6Jag3hrgKEpGmaE6alfRC8qjO4GIgb6A3ACaJumqP9twi/M9ywdHQ==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.3.2", + "ansis": "^3.17.0", + "clean-stack": "^3.0.1", + "cli-spinners": "^2.9.2", + "debug": "^4.4.3", + "ejs": "^3.1.10", + "get-package-type": "^0.1.0", + "indent-string": "^4.0.0", + "is-wsl": "^2.2.0", + "lilconfig": "^3.1.3", + "minimatch": "^10.2.5", + "semver": "^7.8.1", + "string-width": "^4.2.3", + "supports-color": "^8", + "tinyglobby": "^0.2.16", + "widest-line": "^3.1.0", + "wordwrap": "^1.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@oclif/plugin-autocomplete": { + "version": "3.2.50", + "resolved": "https://registry.npmjs.org/@oclif/plugin-autocomplete/-/plugin-autocomplete-3.2.50.tgz", + "integrity": "sha512-SQRIJSYue/1tIn7X55W/97gTb8UkSoHeFAcBng2r2YMJyWj8uB1DtFl28D8BDXPQXPTiPK89hQGejoT7RdkR2w==", + "license": "MIT", + "dependencies": { + "@oclif/core": "^4", + "ansis": "^3.16.0", + "debug": "^4.4.1", + "ejs": "^3.1.10" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@oclif/plugin-help": { + "version": "6.2.50", + "resolved": "https://registry.npmjs.org/@oclif/plugin-help/-/plugin-help-6.2.50.tgz", + "integrity": "sha512-rNCG4hUm+kPXFbhJfAVk/fZ3OdWJYwBDASlyX8CqOLP0MssjIGl7iEgfZz7TMuZFa+KucupKU5NRSc0KWfPTQA==", + "license": "MIT", + "dependencies": { + "@oclif/core": "^4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@oclif/plugin-not-found": { + "version": "3.2.87", + "resolved": "https://registry.npmjs.org/@oclif/plugin-not-found/-/plugin-not-found-3.2.87.tgz", + "integrity": "sha512-lKyZ4INrx5vB14HNWIkM6Vla/4rWVhOA2U7uCAj6gEBg36/KVmwYXxpZ9ckzZS0+jtLE84TVqS8NCYEhQiQojw==", + "license": "MIT", + "dependencies": { + "@inquirer/prompts": "^7.10.1", + "@oclif/core": "^4.11.4", + "ansis": "^3.17.0", + "fast-levenshtein": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@raycast/api": { + "version": "1.104.19", + "resolved": "https://registry.npmjs.org/@raycast/api/-/api-1.104.19.tgz", + "integrity": "sha512-SAVg56BAzxZGy/OPQ0jekUG3pJaoX5pCqleALvFo9JRE7P2tvKoglWnYRcIJArRhLlvV8FtFNMDafd+NNwXXCw==", + "license": "MIT", + "dependencies": { + "@oclif/core": "^4.8.4", + "@oclif/plugin-autocomplete": "^3.2.40", + "@oclif/plugin-help": "^6.2.37", + "@oclif/plugin-not-found": "^3.2.74", + "@types/node": "22.19.17", + "@types/react": "19.0.10", + "esbuild": "^0.27.3", + "react": "19.0.0" + }, + "bin": { + "ray": "bin/run.js" + }, + "engines": { + "node": ">=22.22.2" + }, + "peerDependencies": { + "@types/node": "22.19.17", + "@types/react": "19.0.10", + "react-devtools": "6.1.1" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "react-devtools": { + "optional": true + } + } + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.0.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz", + "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-3.17.0.tgz", + "integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "license": "MIT" + }, + "node_modules/clean-stack": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-3.0.1.tgz", + "integrity": "sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fast-levenshtein": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-3.0.0.tgz", + "integrity": "sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ==", + "license": "MIT", + "dependencies": { + "fastest-levenshtein": "^1.0.7" + } + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/react": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", + "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "license": "MIT", + "dependencies": { + "string-width": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/raycast-extension/package.json b/raycast-extension/package.json new file mode 100644 index 0000000..4f99297 --- /dev/null +++ b/raycast-extension/package.json @@ -0,0 +1,95 @@ +{ + "name": "ssync", + "title": "Ssync", + "description": "Monitor ssync SLURM jobs from Raycast", + "icon": "icon.png", + "author": "ramlaoui", + "license": "MIT", + "categories": [ + "Developer Tools" + ], + "platforms": [ + "macOS" + ], + "preferences": [ + { + "name": "outputEditor", + "type": "dropdown", + "required": false, + "title": "Output Editor", + "description": "Application used when opening downloaded ssync job output files.", + "default": "default", + "data": [ + { + "title": "System Default", + "value": "default" + }, + { + "title": "Visual Studio Code", + "value": "vscode" + }, + { + "title": "Cursor", + "value": "cursor" + }, + { + "title": "Neovim in Ghostty", + "value": "ghostty-nvim" + }, + { + "title": "Custom Application", + "value": "custom" + } + ] + }, + { + "name": "outputEditorApplication", + "type": "appPicker", + "required": false, + "title": "Custom Output Editor", + "description": "Application used when Output Editor is set to Custom Application." + } + ], + "commands": [ + { + "name": "jobs", + "title": "Jobs", + "subtitle": "ssync", + "description": "Monitor running, pending, and historical ssync jobs", + "mode": "view", + "keywords": [ + "slurm", + "hpc", + "cluster" + ] + }, + { + "name": "menu-bar", + "title": "Job Summary", + "subtitle": "ssync", + "description": "Show running and pending ssync jobs in the menu bar", + "mode": "menu-bar", + "interval": "5m", + "keywords": [ + "slurm", + "hpc", + "cluster" + ] + } + ], + "dependencies": { + "@raycast/api": "^1.104.19", + "react": "19.0.0" + }, + "devDependencies": { + "@types/node": "22.19.17", + "@types/react": "19.0.10", + "typescript": "^5.9.3" + }, + "scripts": { + "dev": "ray develop", + "build": "ray build", + "lint": "ray lint", + "fix-lint": "ray lint --fix" + } +} diff --git a/raycast-extension/raycast-env.d.ts b/raycast-extension/raycast-env.d.ts new file mode 100644 index 0000000..22a0148 --- /dev/null +++ b/raycast-extension/raycast-env.d.ts @@ -0,0 +1,32 @@ +/// + +/* 馃毀 馃毀 馃毀 + * This file is auto-generated from the extension's manifest. + * Do not modify manually. Instead, update the `package.json` file. + * 馃毀 馃毀 馃毀 */ + +/* eslint-disable @typescript-eslint/ban-types */ + +type ExtensionPreferences = { + /** Output Editor - Application used when opening downloaded ssync job output files. */ + "outputEditor": "default" | "vscode" | "cursor" | "ghostty-nvim" | "custom", + /** Custom Output Editor - Application used when Output Editor is set to Custom Application. */ + "outputEditorApplication"?: import("@raycast/api").Application +} + +/** Preferences accessible in all the extension's commands */ +declare type Preferences = ExtensionPreferences + +declare namespace Preferences { + /** Preferences accessible in the `jobs` command */ + export type Jobs = ExtensionPreferences & {} + /** Preferences accessible in the `menu-bar` command */ + export type MenuBar = ExtensionPreferences & {} +} + +declare namespace Arguments { + /** Arguments passed to the `jobs` command */ + export type Jobs = {} + /** Arguments passed to the `menu-bar` command */ + export type MenuBar = {} +} diff --git a/raycast-extension/src/api/client.ts b/raycast-extension/src/api/client.ts new file mode 100644 index 0000000..02317bc --- /dev/null +++ b/raycast-extension/src/api/client.ts @@ -0,0 +1,276 @@ +import http from "node:http"; +import https from "node:https"; +import type { + ConnectionSettings, + JobInfo, + JobOutputResponse, + JobScriptResponse, + JobStatusResponse, + WatcherEventsResponse, + WatchersResponse, +} from "../types/ssync"; + +type RequestOptions = { + method?: "GET" | "POST"; + params?: Record; + body?: unknown; + timeoutMs?: number; +}; + +export type DownloadedOutput = { + filename: string; + content: Buffer; +}; + +export class SsyncApiError extends Error { + constructor( + message: string, + public readonly statusCode?: number, + ) { + super(message); + this.name = "SsyncApiError"; + } +} + +export class SsyncClient { + constructor(private readonly connection: Pick) {} + + async testConnection(): Promise { + await this.request>("/api/info", { timeoutMs: 10_000 }); + } + + async getStatus(options: { + since?: string; + limit?: number; + forceRefresh?: boolean; + }): Promise { + return this.request("/api/status", { + params: { + since: options.since, + limit: options.limit, + group_array_jobs: false, + force_refresh: options.forceRefresh || undefined, + }, + timeoutMs: 45_000, + }); + } + + async getJob(job: JobInfo, forceRefresh = false): Promise { + return this.request(`/api/jobs/${encodeURIComponent(job.job_id)}`, { + params: { + host: job.hostname, + cache_first: true, + force_refresh: forceRefresh || undefined, + }, + timeoutMs: 30_000, + }); + } + + async getOutput(options: { + job: JobInfo; + outputType: "stdout" | "stderr"; + lines?: number; + fullOutput?: boolean; + forceRefresh?: boolean; + }): Promise { + return this.request(`/api/jobs/${encodeURIComponent(options.job.job_id)}/output`, { + params: { + host: options.job.hostname, + output_type: options.outputType, + lines: options.fullOutput ? undefined : options.lines, + all: options.fullOutput || undefined, + force_refresh: options.forceRefresh || undefined, + }, + timeoutMs: options.fullOutput ? 90_000 : 45_000, + }); + } + + async downloadOutput(options: { + job: JobInfo; + outputType: "stdout" | "stderr"; + forceRefresh?: boolean; + }): Promise { + return this.requestBuffer(`/api/jobs/${encodeURIComponent(options.job.job_id)}/output/download`, { + params: { + host: options.job.hostname, + output_type: options.outputType, + compressed: false, + force_refresh: options.forceRefresh || undefined, + }, + timeoutMs: 120_000, + fallbackFilename: `job_${options.job.job_id}_${options.outputType}.log`, + }); + } + + async getScript(job: JobInfo): Promise { + return this.request(`/api/jobs/${encodeURIComponent(job.job_id)}/script`, { + params: { host: job.hostname }, + timeoutMs: 45_000, + }); + } + + async getWatchers(job: JobInfo): Promise { + return this.request(`/api/jobs/${encodeURIComponent(job.job_id)}/watchers`, { + params: { host: job.hostname }, + timeoutMs: 30_000, + }); + } + + async getWatcherEvents(job: JobInfo, limit = 100): Promise { + return this.request("/api/watchers/events", { + params: { + job_id: job.job_id, + limit, + }, + timeoutMs: 30_000, + }); + } + + async cancelJob(job: JobInfo): Promise { + await this.request>(`/api/jobs/${encodeURIComponent(job.job_id)}/cancel`, { + method: "POST", + params: { host: job.hostname }, + timeoutMs: 30_000, + }); + } + + private request(path: string, options: RequestOptions = {}): Promise { + const url = this.buildUrl(path, options.params); + const isHttps = url.protocol === "https:"; + const transport = isHttps ? https : http; + + return new Promise((resolve, reject) => { + const request = transport.request( + url, + { + method: options.method || "GET", + headers: this.requestHeaders("application/json"), + timeout: options.timeoutMs || 30_000, + ...(isHttps ? { rejectUnauthorized: false } : {}), + }, + (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk: Buffer) => chunks.push(chunk)); + response.on("end", () => { + const text = Buffer.concat(chunks).toString("utf8"); + const statusCode = response.statusCode || 0; + + if (statusCode >= 400) { + reject(new SsyncApiError(errorMessage(statusCode, text), statusCode)); + return; + } + + if (!text) { + resolve(undefined as T); + return; + } + + try { + resolve(JSON.parse(text) as T); + } catch { + reject(new SsyncApiError(`Invalid JSON response from ssync API: ${text.slice(0, 120)}`, statusCode)); + } + }); + }, + ); + + request.on("timeout", () => { + request.destroy(new SsyncApiError("Request timed out")); + }); + request.on("error", (error) => { + reject(error instanceof SsyncApiError ? error : new SsyncApiError(error.message)); + }); + + if (options.body !== undefined) request.write(JSON.stringify(options.body)); + request.end(); + }); + } + + private requestBuffer( + path: string, + options: RequestOptions & { fallbackFilename: string }, + ): Promise { + const url = this.buildUrl(path, options.params); + const isHttps = url.protocol === "https:"; + const transport = isHttps ? https : http; + + return new Promise((resolve, reject) => { + const request = transport.request( + url, + { + method: options.method || "GET", + headers: this.requestHeaders("text/plain"), + timeout: options.timeoutMs || 60_000, + ...(isHttps ? { rejectUnauthorized: false } : {}), + }, + (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk: Buffer) => chunks.push(chunk)); + response.on("end", () => { + const content = Buffer.concat(chunks); + const statusCode = response.statusCode || 0; + + if (statusCode >= 400) { + reject(new SsyncApiError(errorMessage(statusCode, content.toString("utf8")), statusCode)); + return; + } + + resolve({ + filename: filenameFromContentDisposition(response.headers["content-disposition"], options.fallbackFilename), + content, + }); + }); + }, + ); + + request.on("timeout", () => { + request.destroy(new SsyncApiError("Request timed out")); + }); + request.on("error", (error) => { + reject(error instanceof SsyncApiError ? error : new SsyncApiError(error.message)); + }); + request.end(); + }); + } + + private buildUrl(path: string, params?: RequestOptions["params"]): URL { + const base = this.connection.apiUrl.replace(/\/+$/, ""); + const url = new URL(path, base); + for (const [key, value] of Object.entries(params || {})) { + if (value === undefined || value === null || value === "") continue; + url.searchParams.set(key, String(value)); + } + return url; + } + + private requestHeaders(accept: string): Record { + const headers: Record = { + Accept: accept, + "Content-Type": "application/json", + }; + if (this.connection.apiKey) headers["X-API-Key"] = this.connection.apiKey; + return headers; + } +} + +function errorMessage(statusCode: number, text: string): string { + if (!text) return `ssync API returned HTTP ${statusCode}`; + try { + const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown }; + const detail = parsed.detail || parsed.message; + if (typeof detail === "string") return detail; + } catch { + // Fall through to raw body preview. + } + return `ssync API returned HTTP ${statusCode}: ${text.slice(0, 200)}`; +} + +function filenameFromContentDisposition(header: string | string[] | undefined, fallback: string): string { + const raw = Array.isArray(header) ? header[0] : header; + const match = raw?.match(/filename="?([^";]+)"?/); + return sanitizeFilename(match?.[1] || fallback); +} + +function sanitizeFilename(value: string): string { + return value.replace(/[^a-zA-Z0-9._-]/g, "_") || "output.log"; +} diff --git a/raycast-extension/src/api/storage.ts b/raycast-extension/src/api/storage.ts new file mode 100644 index 0000000..4b1a1cf --- /dev/null +++ b/raycast-extension/src/api/storage.ts @@ -0,0 +1,127 @@ +import { LocalStorage, OAuth } from "@raycast/api"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import type { ConnectionSettings, JobCache } from "../types/ssync"; + +const CONNECTION_KEY = "ssync.connection.v1"; +const JOB_CACHE_KEY = "ssync.jobs.cache.v1"; +const oauthClient = new OAuth.PKCEClient({ + redirectMethod: OAuth.RedirectMethod.App, + providerName: "ssync", + providerId: "ssync-api-key", +}); + +export const DEFAULT_API_URL = "https://localhost:8042"; +export const DEFAULT_HISTORY_WINDOW = "3d"; +export const DEFAULT_JOB_LIMIT = 50; +export const STALE_JOB_CACHE_MS = 60_000; + +export async function getConnection(): Promise { + const raw = await LocalStorage.getItem(CONNECTION_KEY); + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw) as ConnectionSettings; + if (!parsed.apiUrl) return undefined; + if (parsed.apiKey) { + await saveApiKey(parsed.apiKey); + delete parsed.apiKey; + await LocalStorage.setItem(CONNECTION_KEY, JSON.stringify(parsed)); + } + return { + apiUrl: parsed.apiUrl, + apiKey: await getStoredApiKey(), + historyWindow: parsed.historyWindow || DEFAULT_HISTORY_WINDOW, + jobLimit: Number(parsed.jobLimit) || DEFAULT_JOB_LIMIT, + updatedAt: parsed.updatedAt || Date.now(), + }; + } catch { + return undefined; + } +} + +export async function saveConnection(settings: Omit): Promise { + const next: ConnectionSettings = { + ...settings, + apiUrl: settings.apiUrl.trim().replace(/\/+$/, ""), + apiKey: settings.apiKey?.trim() || "", + historyWindow: settings.historyWindow || DEFAULT_HISTORY_WINDOW, + jobLimit: Number(settings.jobLimit) || DEFAULT_JOB_LIMIT, + updatedAt: Date.now(), + }; + if (next.apiKey) { + await saveApiKey(next.apiKey); + } else { + await clearApiKey(); + } + const { apiKey: _apiKey, ...nonSecretSettings } = next; + await LocalStorage.setItem(CONNECTION_KEY, JSON.stringify(nonSecretSettings)); + return next; +} + +export async function clearConnection(): Promise { + await LocalStorage.removeItem(CONNECTION_KEY); + await clearApiKey(); +} + +export async function getJobCache(): Promise { + const raw = await LocalStorage.getItem(JOB_CACHE_KEY); + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw) as JobCache; + if (!Array.isArray(parsed.responses) || typeof parsed.loadedAt !== "number") return undefined; + return parsed; + } catch { + return undefined; + } +} + +export async function saveJobCache(cache: JobCache): Promise { + await LocalStorage.setItem(JOB_CACHE_KEY, JSON.stringify(cache)); +} + +export async function clearJobCache(): Promise { + await LocalStorage.removeItem(JOB_CACHE_KEY); +} + +export function readLocalApiKey(): string { + const keyPath = join(homedir(), ".config", "ssync", ".api_key"); + if (!existsSync(keyPath)) return ""; + + try { + const raw = readFileSync(keyPath, "utf8").trim(); + if (!raw) return ""; + + try { + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed === "string") return parsed; + if (parsed && typeof parsed === "object") { + const record = parsed as Record; + const values = Object.values(record); + const stringValue = values.find((value): value is string => typeof value === "string" && value.length > 0); + if (stringValue) return stringValue; + const firstKey = Object.keys(record)[0]; + if (firstKey) return firstKey; + } + } catch { + return raw; + } + + return raw; + } catch { + return ""; + } +} + +async function getStoredApiKey(): Promise { + const tokenSet = await oauthClient.getTokens(); + return tokenSet?.accessToken || ""; +} + +async function saveApiKey(apiKey: string): Promise { + await oauthClient.setTokens({ accessToken: apiKey }); +} + +async function clearApiKey(): Promise { + await oauthClient.removeTokens(); +} diff --git a/raycast-extension/src/components/ConnectionForm.tsx b/raycast-extension/src/components/ConnectionForm.tsx new file mode 100644 index 0000000..de96807 --- /dev/null +++ b/raycast-extension/src/components/ConnectionForm.tsx @@ -0,0 +1,100 @@ +import { Action, ActionPanel, Form, Icon, Toast, showToast } from "@raycast/api"; +import { useMemo, useState } from "react"; +import { SsyncClient } from "../api/client"; +import { + DEFAULT_API_URL, + DEFAULT_HISTORY_WINDOW, + DEFAULT_JOB_LIMIT, + readLocalApiKey, + saveConnection, +} from "../api/storage"; +import type { ConnectionSettings } from "../types/ssync"; + +type Values = { + apiUrl: string; + apiKey: string; + historyWindow: string; + jobLimit: string; +}; + +type Props = { + initial?: ConnectionSettings; + onConfigured: (connection: ConnectionSettings) => void; +}; + +export function ConnectionForm({ initial, onConfigured }: Props) { + const [isTesting, setIsTesting] = useState(false); + const detectedKey = useMemo(() => readLocalApiKey(), []); + + async function submit(values: Values) { + const apiUrl = values.apiUrl.trim().replace(/\/+$/, ""); + const apiKey = values.apiKey.trim(); + const jobLimit = Number(values.jobLimit || DEFAULT_JOB_LIMIT); + + if (!apiUrl) { + await showToast({ style: Toast.Style.Failure, title: "ssync API URL is required" }); + return; + } + if (!Number.isFinite(jobLimit) || jobLimit <= 0) { + await showToast({ style: Toast.Style.Failure, title: "Job limit must be a positive number" }); + return; + } + + setIsTesting(true); + const toast = await showToast({ style: Toast.Style.Animated, title: "Testing ssync connection" }); + try { + const candidate = { + apiUrl, + apiKey, + historyWindow: values.historyWindow || DEFAULT_HISTORY_WINDOW, + jobLimit, + }; + await new SsyncClient(candidate).testConnection(); + const saved = await saveConnection(candidate); + toast.style = Toast.Style.Success; + toast.title = "Connected to ssync"; + onConfigured(saved); + } catch (error) { + toast.style = Toast.Style.Failure; + toast.title = "Connection failed"; + toast.message = error instanceof Error ? error.message : String(error); + } finally { + setIsTesting(false); + } + } + + const defaultKey = initial?.apiKey || detectedKey; + + return ( +
+ + + } + > + + + + + + + + + + + + ); +} diff --git a/raycast-extension/src/components/JobDetail.tsx b/raycast-extension/src/components/JobDetail.tsx new file mode 100644 index 0000000..be3f5e7 --- /dev/null +++ b/raycast-extension/src/components/JobDetail.tsx @@ -0,0 +1,550 @@ +import { Action, ActionPanel, Alert, Icon, Keyboard, List, Toast, confirmAlert, open, showToast } from "@raycast/api"; +import type { ReactNode } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { SsyncClient } from "../api/client"; +import { + compactJobSubtitle, + formatDate, + isPending, + isRunning, + metadataText, + stateColor, + stateIcon, + stateLabel, + webJobUrl, +} from "../lib/format"; +import { bulletList, codeBlock, escapeMarkdown } from "../lib/markdown"; +import { openJobOutputFile } from "../lib/output-file"; +import type { ConnectionSettings, JobInfo } from "../types/ssync"; +import { OutputView } from "./OutputView"; +import { ScriptView } from "./ScriptView"; +import { WatchersView } from "./WatchersView"; + +type Props = { + connection: ConnectionSettings; + job: JobInfo; + onJobUpdated?: (job: JobInfo) => void; +}; + +type InspectorItemProps = { + id: string; + title: string; + subtitle?: string | null; + icon?: List.Item.Props["icon"]; + accessories?: List.Item.Props["accessories"]; + keywords?: string[]; + markdown: string; + metadata?: ReactNode; + actions: ReactNode; +}; + +type JobInspectorActionsProps = { + connection: ConnectionSettings; + job: JobInfo; + canCancel: boolean; + refreshJob: () => Promise; + cancelJob: () => Promise; + primary?: ReactNode; + includeRelatedViews?: boolean; +}; + +export function JobDetail({ connection, job, onJobUpdated }: Props) { + const client = useMemo(() => new SsyncClient(connection), [connection]); + const [currentJob, setCurrentJob] = useState(job); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + setCurrentJob(job); + }, [job]); + + async function refreshJob(forceRefresh = false) { + setIsLoading(true); + try { + const next = await client.getJob(currentJob, forceRefresh); + setCurrentJob(next); + onJobUpdated?.(next); + await showToast({ style: Toast.Style.Success, title: "Job refreshed" }); + } catch (error) { + await showToast({ + style: Toast.Style.Failure, + title: "Failed to refresh job", + message: error instanceof Error ? error.message : String(error), + }); + } finally { + setIsLoading(false); + } + } + + async function cancelJob() { + const confirmed = await confirmAlert({ + title: `Cancel job ${currentJob.job_id}?`, + message: `${currentJob.name || "This job"} on ${currentJob.hostname} will be cancelled with scancel.`, + primaryAction: { + title: "Cancel Job", + style: Alert.ActionStyle.Destructive, + }, + }); + if (!confirmed) return; + + const toast = await showToast({ style: Toast.Style.Animated, title: "Cancelling job" }); + try { + await client.cancelJob(currentJob); + toast.style = Toast.Style.Success; + toast.title = "Job cancelled"; + await refreshJob(); + } catch (error) { + toast.style = Toast.Style.Failure; + toast.title = "Failed to cancel job"; + toast.message = error instanceof Error ? error.message : String(error); + } + } + + const canCancel = isRunning(currentJob) || isPending(currentJob); + const actions = { + connection, + job: currentJob, + canCancel, + refreshJob: () => refreshJob(), + cancelJob, + }; + + return ( + + + } + actions={ + } + /> + } + /> + } + actions={ + } + /> + } + /> + + + + } + actions={} />} + /> + } + actions={} + /> + + + + } + actions={} + /> + + + + } + actions={ + : undefined} + /> + } + /> + } + actions={ + + } /> + {currentJob.stdout_file ? ( + openJobOutputFile({ client, job: currentJob, outputType: "stdout" })} + /> + ) : null} + {currentJob.stdout_file ? : null} + + } + /> + } + /> + } + actions={ + + } /> + {currentJob.stderr_file ? ( + openJobOutputFile({ client, job: currentJob, outputType: "stderr" })} + /> + ) : null} + {currentJob.stderr_file ? : null} + + } + /> + } + /> + + + + } + actions={ + } />} + /> + } + /> + } + actions={ + } />} + /> + } + /> + } + actions={ + } />} + /> + } + /> + } + actions={ + open(webJobUrl(connection.apiUrl, currentJob))} />} + /> + } + /> + + + ); +} + +function InspectorItem({ id, title, subtitle, icon, accessories, keywords, markdown, metadata, actions }: InspectorItemProps) { + return ( + } + actions={actions} + /> + ); +} + +function JobInspectorActions({ + connection, + job, + canCancel, + refreshJob, + cancelJob, + primary, + includeRelatedViews = true, +}: JobInspectorActionsProps) { + return ( + + {primary ? {primary} : null} + {includeRelatedViews ? ( + + } /> + } /> + } /> + + ) : null} + + + {canCancel ? : null} + open(webJobUrl(connection.apiUrl, job))} /> + + + + {job.work_dir ? : null} + {job.stdout_file ? : null} + {job.stderr_file ? : null} + + + ); +} + +function StatusMetadata({ job }: { job: JobInfo }) { + return ( + + + + + + + ); +} + +function IdentityMetadata({ job }: { job: JobInfo }) { + return ( + + + + + + + ); +} + +function PlacementMetadata({ job }: { job: JobInfo }) { + return ( + + + + + + + ); +} + +function ResourcesMetadata({ job }: { job: JobInfo }) { + return ( + + + + + + ); +} + +function TimingMetadata({ job }: { job: JobInfo }) { + return ( + + + + + + + + ); +} + +function PathMetadata({ title, value, job }: { title: string; value?: string | null; job: JobInfo }) { + return ( + + + + + + ); +} + +function RelatedViewMetadata({ job, kind }: { job: JobInfo; kind: string }) { + return ( + + + + + + ); +} + +function statusMarkdown(job: JobInfo): string { + return [ + `# ${escapeMarkdown(stateLabel(job.state))}`, + "", + bulletList([ + ["Raw state", job.state], + ["Reason", job.reason], + ["Exit code", job.exit_code], + ]), + ].join("\n"); +} + +function identityMarkdown(job: JobInfo): string { + return [ + `# ${escapeMarkdown(job.name || `Job ${job.job_id}`)}`, + "", + bulletList([ + ["Job ID", job.job_id], + ["User", job.user], + ["Host", job.hostname], + ]), + ].join("\n"); +} + +function placementMarkdown(job: JobInfo): string { + return [ + "# Placement", + "", + bulletList([ + ["Host", job.hostname], + ["Partition", job.partition], + ["Account", job.account], + ["QoS", job.qos], + ]), + ].join("\n"); +} + +function resourcesMarkdown(job: JobInfo): string { + return [ + "# Resources", + "", + bulletList([ + ["Nodes", job.nodes], + ["CPUs", job.cpus], + ["Memory", job.memory], + ]), + ].join("\n"); +} + +function timingMarkdown(job: JobInfo): string { + return [ + "# Timing", + "", + bulletList([ + ["Submitted", formatDate(job.submit_time)], + ["Started", formatDate(job.start_time)], + ["Ended", formatDate(job.end_time)], + ["Runtime", job.runtime], + ["Time limit", job.time_limit], + ]), + ].join("\n"); +} + +function pathMarkdown(title: string, value?: string | null): string { + return [`# ${escapeMarkdown(title)}`, "", codeBlock(value, "text")].join("\n"); +} + +function relatedViewMarkdown(title: string, description: string): string { + return [`# ${escapeMarkdown(title)}`, "", escapeMarkdown(description)].join("\n"); +} + +function statusSubtitle(job: JobInfo): string { + const parts = [stateLabel(job.state)]; + if (job.reason) parts.push(job.reason); + if (job.exit_code) parts.push(`exit ${job.exit_code}`); + return parts.join(" 路 "); +} + +function placementSubtitle(job: JobInfo): string { + return [job.hostname, job.partition, job.account, job.qos].filter(Boolean).join(" 路 ") || "n/a"; +} + +function resourcesSubtitle(job: JobInfo): string { + return [`${metadataText(job.nodes)} nodes`, `${metadataText(job.cpus)} CPUs`, metadataText(job.memory)].join(" 路 "); +} + +function timingSubtitle(job: JobInfo): string { + return [job.runtime, job.time_limit ? `limit ${job.time_limit}` : undefined].filter(Boolean).join(" 路 ") || "n/a"; +} + +function keywords(...values: (string | number | null | undefined)[]): string[] { + return values + .filter((value): value is string | number => value !== undefined && value !== null && value !== "") + .map((value) => String(value)); +} diff --git a/raycast-extension/src/components/OutputView.tsx b/raycast-extension/src/components/OutputView.tsx new file mode 100644 index 0000000..190ab25 --- /dev/null +++ b/raycast-extension/src/components/OutputView.tsx @@ -0,0 +1,125 @@ +import { Action, ActionPanel, Detail, Icon, Keyboard, Toast, showToast } from "@raycast/api"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { SsyncClient } from "../api/client"; +import { bytesLabel, formatDate, metadataText } from "../lib/format"; +import { codeBlock, escapeMarkdown } from "../lib/markdown"; +import { openJobOutputFile } from "../lib/output-file"; +import type { ConnectionSettings, JobInfo, JobOutputResponse } from "../types/ssync"; + +type Props = { + connection: ConnectionSettings; + job: JobInfo; + initialOutputType?: OutputType; +}; + +type OutputType = "stdout" | "stderr"; + +export function OutputView({ connection, job, initialOutputType = "stdout" }: Props) { + const client = useMemo(() => new SsyncClient(connection), [connection]); + const [outputType, setOutputType] = useState(initialOutputType); + const [lines, setLines] = useState(300); + const [fullOutput, setFullOutput] = useState(false); + const [output, setOutput] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const queuedRefreshTimer = useRef(null); + + function clearQueuedRefresh() { + if (!queuedRefreshTimer.current) return; + clearTimeout(queuedRefreshTimer.current); + queuedRefreshTimer.current = null; + } + + async function load( + next?: { outputType?: OutputType; lines?: number; fullOutput?: boolean; forceRefresh?: boolean }, + options?: { followQueuedRefresh?: boolean }, + ) { + clearQueuedRefresh(); + const requestedType = next?.outputType || outputType; + const requestedFull = next?.fullOutput ?? fullOutput; + const requestedLines = requestedFull ? undefined : next?.lines ?? lines; + setOutputType(requestedType); + setFullOutput(requestedFull); + setLines(requestedLines); + setIsLoading(true); + setError(null); + try { + const data = await client.getOutput({ + job, + outputType: requestedType, + lines: requestedLines, + fullOutput: requestedFull, + forceRefresh: next?.forceRefresh, + }); + setOutput(data); + if (data.refresh_queued && options?.followQueuedRefresh) { + queuedRefreshTimer.current = setTimeout(() => { + void load({ outputType: requestedType, lines: requestedLines, fullOutput: requestedFull }); + }, 2_500); + } + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : String(loadError)); + } finally { + setIsLoading(false); + } + } + + useEffect(() => { + void load({ outputType: initialOutputType, lines: 300, fullOutput: false, forceRefresh: true }, { followQueuedRefresh: true }); + return clearQueuedRefresh; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [job.job_id, job.hostname, initialOutputType]); + + async function refresh() { + await load({ forceRefresh: true }, { followQueuedRefresh: true }); + await showToast({ style: Toast.Style.Success, title: "Output refreshed" }); + } + + const content = outputType === "stdout" ? output?.stdout : output?.stderr; + const metadata = outputType === "stdout" ? output?.stdout_metadata : output?.stderr_metadata; + const title = `${outputType} 路 ${job.job_id} @ ${job.hostname}`; + const markdown = error ? `# ${escapeMarkdown(outputType)}\n\n**Error:** ${escapeMarkdown(error)}` : codeBlock(content, "text"); + + return ( + + + + + + + + + + + + + + } + actions={ + + + + load({ outputType: outputType === "stdout" ? "stderr" : "stdout", lines: 300, fullOutput: false, forceRefresh: true }, { followQueuedRefresh: true })} /> + load({ lines: 1000, fullOutput: false })} /> + load({ fullOutput: true })} /> + openJobOutputFile({ client, job, outputType })} + /> + + + + {metadata?.path ? : null} + + + } + /> + ); +} diff --git a/raycast-extension/src/components/ScriptView.tsx b/raycast-extension/src/components/ScriptView.tsx new file mode 100644 index 0000000..22feb7a --- /dev/null +++ b/raycast-extension/src/components/ScriptView.tsx @@ -0,0 +1,70 @@ +import { Action, ActionPanel, Detail, Icon, Keyboard, Toast, showToast } from "@raycast/api"; +import { useEffect, useMemo, useState } from "react"; +import { SsyncClient } from "../api/client"; +import { metadataText } from "../lib/format"; +import { codeBlock, escapeMarkdown } from "../lib/markdown"; +import type { ConnectionSettings, JobInfo, JobScriptResponse } from "../types/ssync"; + +type Props = { + connection: ConnectionSettings; + job: JobInfo; +}; + +export function ScriptView({ connection, job }: Props) { + const client = useMemo(() => new SsyncClient(connection), [connection]); + const [script, setScript] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + async function load() { + setIsLoading(true); + setError(null); + try { + setScript(await client.getScript(job)); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : String(loadError)); + } finally { + setIsLoading(false); + } + } + + useEffect(() => { + void load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [job.job_id, job.hostname]); + + async function refresh() { + await load(); + await showToast({ style: Toast.Style.Success, title: "Script refreshed" }); + } + + const markdown = error + ? `# Script for ${escapeMarkdown(job.name || job.job_id)}\n\n**Error:** ${escapeMarkdown(error)}` + : codeBlock(script?.script_content, "bash"); + + return ( + + + + + + + + } + actions={ + + + + + {script?.local_source_dir ? : null} + + + } + /> + ); +} diff --git a/raycast-extension/src/components/WatchersView.tsx b/raycast-extension/src/components/WatchersView.tsx new file mode 100644 index 0000000..425820d --- /dev/null +++ b/raycast-extension/src/components/WatchersView.tsx @@ -0,0 +1,247 @@ +import { Action, ActionPanel, Color, Icon, Keyboard, List, Toast, showToast } from "@raycast/api"; +import { useEffect, useMemo, useState } from "react"; +import { SsyncClient } from "../api/client"; +import { formatDate, metadataText } from "../lib/format"; +import { codeBlock, escapeMarkdown } from "../lib/markdown"; +import type { ConnectionSettings, JobInfo, Watcher, WatcherAction, WatcherEvent } from "../types/ssync"; + +type Props = { + connection: ConnectionSettings; + job: JobInfo; +}; + +export function WatchersView({ connection, job }: Props) { + const client = useMemo(() => new SsyncClient(connection), [connection]); + const [watchers, setWatchers] = useState([]); + const [events, setEvents] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + async function load() { + setIsLoading(true); + setError(null); + try { + const [watcherPayload, eventPayload] = await Promise.all([client.getWatchers(job), client.getWatcherEvents(job, 100)]); + setWatchers(watcherPayload.watchers || []); + setEvents(eventPayload.events || []); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : String(loadError)); + } finally { + setIsLoading(false); + } + } + + useEffect(() => { + void load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [job.job_id, job.hostname]); + + async function refresh() { + await load(); + await showToast({ style: Toast.Style.Success, title: "Watchers refreshed" }); + } + + return ( + + {error ? ( + + + + } + /> + ) : null} + {!error && watchers.length === 0 && events.length === 0 ? ( + + ) : null} + {watchers.length > 0 ? ( + + {watchers.map((watcher) => ( + } />} + actions={} + /> + ))} + + ) : null} + {events.length > 0 ? ( + + {events.map((event) => ( + } />} + actions={} + /> + ))} + + ) : null} + + ); +} + +function WatcherActions({ watcher, onRefresh }: { watcher: Watcher; onRefresh: () => void }) { + return ( + + + + + + + ); +} + +function EventActions({ event, onRefresh }: { event: WatcherEvent; onRefresh: () => void }) { + return ( + + + + + + + ); +} + +function WatcherMetadata({ watcher }: { watcher: Watcher }) { + return ( + + + + + + + + + + + + + + + + + + + ); +} + +function EventMetadata({ event }: { event: WatcherEvent }) { + return ( + + + + + + + + + + + ); +} + +function watcherMarkdown(watcher: Watcher): string { + const lines = [`# ${escapeMarkdown(watcher.name)}`]; + if (watcher.condition) { + lines.push("", `**Condition:** ${escapeMarkdown(watcher.condition)}`); + } + lines.push( + "", + "## Pattern", + "", + codeBlock(watcher.pattern, "text"), + "", + "## Captures", + "", + stringList(watcher.captures), + "", + "## Captured Variables", + "", + objectList(watcher.variables), + "", + "## Actions", + "", + watcher.actions.length > 0 ? watcher.actions.map(actionMarkdown).join("\n\n") : "_No actions configured._", + ); + return lines.join("\n"); +} + +function eventMarkdown(event: WatcherEvent): string { + return [ + `# ${escapeMarkdown(event.action_type)} event`, + "", + "## Matched Text", + "", + codeBlock(event.matched_text, "text"), + "", + "## Captured Variables", + "", + objectList(event.captured_vars), + ...(event.action_result ? ["", "## Action Result", "", codeBlock(event.action_result, "text")] : []), + ].join("\n"); +} + +function stringList(values?: string[]): string { + if (!values || values.length === 0) return "_No captures configured._"; + return values.map((value) => `- ${escapeMarkdown(value)}`).join("\n"); +} + +function objectList(values?: Record): string { + const entries = Object.entries(values || {}); + if (entries.length === 0) return "_No values captured._"; + return entries.map(([key, value]) => `- **${escapeMarkdown(key)}:** ${escapeMarkdown(formatUnknown(value))}`).join("\n"); +} + +function actionMarkdown(action: WatcherAction, index: number): string { + const lines = [`### ${index + 1}. ${escapeMarkdown(action.type)}`]; + if (action.condition) lines.push("", `**Condition:** ${escapeMarkdown(action.condition)}`); + if (action.params && Object.keys(action.params).length > 0) lines.push("", "**Params**", "", objectList(action.params)); + if (action.config && Object.keys(action.config).length > 0) lines.push("", "**Config**", "", objectList(action.config)); + return lines.join("\n"); +} + +function formatUnknown(value: unknown): string { + if (value === undefined || value === null || value === "") return "n/a"; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return metadataText(value); + return JSON.stringify(value); +} + +function watcherStateColor(state: string): Color { + switch (state) { + case "active": + return Color.Green; + case "paused": + return Color.Yellow; + case "failed": + return Color.Red; + case "completed": + return Color.Blue; + default: + return Color.SecondaryText; + } +} diff --git a/raycast-extension/src/jobs.tsx b/raycast-extension/src/jobs.tsx new file mode 100644 index 0000000..988a121 --- /dev/null +++ b/raycast-extension/src/jobs.tsx @@ -0,0 +1,375 @@ +import { + Action, + ActionPanel, + Alert, + Icon, + Keyboard, + LaunchProps, + List, + Toast, + confirmAlert, + open, + showToast, + useNavigation, +} from "@raycast/api"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { SsyncClient } from "./api/client"; +import { + STALE_JOB_CACHE_MS, + clearJobCache, + getConnection, + getJobCache, + saveJobCache, +} from "./api/storage"; +import { ConnectionForm } from "./components/ConnectionForm"; +import { JobDetail } from "./components/JobDetail"; +import { OutputView } from "./components/OutputView"; +import { ScriptView } from "./components/ScriptView"; +import { WatchersView } from "./components/WatchersView"; +import { + compactJobSubtitle, + flattenJobs, + formatRelativeAge, + isHistorical, + isPending, + isRunning, + jobTitle, + sortJobs, + stateColor, + stateIcon, + stateLabel, + webJobUrl, +} from "./lib/format"; +import type { ConnectionSettings, JobCache, JobInfo, JobsLaunchContext } from "./types/ssync"; + +type Props = LaunchProps<{ launchContext?: JobsLaunchContext }>; + +export default function Command(props: Props) { + const [connection, setConnection] = useState(); + const [didLoadConnection, setDidLoadConnection] = useState(false); + + useEffect(() => { + let cancelled = false; + void getConnection().then((loaded) => { + if (cancelled) return; + setConnection(loaded); + setDidLoadConnection(true); + }); + return () => { + cancelled = true; + }; + }, []); + + if (!didLoadConnection) { + return ; + } + + if (!connection) { + return ; + } + + return ( + + ); +} + +function JobsList({ + connection, + initialContext, + onConnectionChanged, +}: { + connection: ConnectionSettings; + initialContext?: JobsLaunchContext; + onConnectionChanged: (connection: ConnectionSettings) => void; +}) { + const client = useMemo(() => new SsyncClient(connection), [connection]); + const { push, pop } = useNavigation(); + const didPushInitialContext = useRef(false); + const [cache, setCache] = useState(); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const job = initialContext?.job; + if (didPushInitialContext.current || !job) return; + didPushInitialContext.current = true; + push(targetForLaunchContext({ job, view: initialContext.view }, connection)); + }, [connection, initialContext, push]); + + useEffect(() => { + let cancelled = false; + async function loadInitial() { + setIsLoading(true); + const storedCache = await getJobCache(); + if (cancelled) return; + if (storedCache) { + setCache(storedCache); + setIsLoading(false); + } + if (!storedCache || Date.now() - storedCache.loadedAt > STALE_JOB_CACHE_MS) { + await refreshJobs({ silent: Boolean(storedCache) }); + } else { + setIsLoading(false); + } + } + void loadInitial(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [connection.apiUrl, connection.apiKey, connection.historyWindow, connection.jobLimit]); + + async function refreshJobs(options: { silent?: boolean; forceRefresh?: boolean } = {}) { + if (!options.silent) setIsLoading(true); + setError(null); + try { + const responses = await client.getStatus({ + since: connection.historyWindow, + limit: connection.jobLimit, + forceRefresh: options.forceRefresh, + }); + const nextCache = { loadedAt: Date.now(), responses }; + setCache(nextCache); + await saveJobCache(nextCache); + } catch (refreshError) { + const message = refreshError instanceof Error ? refreshError.message : String(refreshError); + setError(message); + if (!cache) { + await showToast({ style: Toast.Style.Failure, title: "Failed to load jobs", message }); + } + } finally { + if (!options.silent) setIsLoading(false); + } + } + + async function cancelJob(job: JobInfo) { + const confirmed = await confirmAlert({ + title: `Cancel job ${job.job_id}?`, + message: `${job.name || "This job"} on ${job.hostname} will be cancelled with scancel.`, + primaryAction: { + title: "Cancel Job", + style: Alert.ActionStyle.Destructive, + }, + }); + if (!confirmed) return; + + const toast = await showToast({ + style: Toast.Style.Animated, + title: "Cancelling job", + message: `${job.job_id} @ ${job.hostname}`, + }); + try { + await client.cancelJob(job); + toast.style = Toast.Style.Success; + toast.title = "Job cancelled"; + toast.message = `${job.job_id} @ ${job.hostname}`; + await refreshJobs({ silent: true, forceRefresh: true }); + } catch (cancelError) { + toast.style = Toast.Style.Failure; + toast.title = "Failed to cancel job"; + toast.message = cancelError instanceof Error ? cancelError.message : String(cancelError); + } + } + + const jobs = useMemo(() => sortJobs(flattenJobs(cache?.responses || [])), [cache]); + const runningJobs = jobs.filter(isRunning); + const pendingJobs = jobs.filter(isPending); + const historicalJobs = jobs.filter(isHistorical); + const cacheAge = cache ? formatRelativeAge(cache.loadedAt) : "never"; + + function configureConnection() { + push( + { + onConnectionChanged(nextConnection); + pop(); + void clearJobCache(); + setCache(undefined); + }} + />, + ); + } + + return ( + + refreshJobs()} /> + + + } + > + {error ? ( + + refreshJobs()} /> + + + } + /> + ) : null} + {!error && jobs.length === 0 && !isLoading ? ( + + refreshJobs()} /> + + + } + /> + ) : null} + refreshJobs()} + onCancel={cancelJob} + onConfigure={configureConnection} + /> + refreshJobs()} + onCancel={cancelJob} + onConfigure={configureConnection} + /> + refreshJobs()} + onCancel={cancelJob} + onConfigure={configureConnection} + /> + + ); +} + +function targetForLaunchContext(context: { job: JobInfo; view?: JobsLaunchContext["view"] }, connection: ConnectionSettings) { + if (context.view === "output") { + return ; + } + if (context.view === "script") { + return ; + } + if (context.view === "watchers") { + return ; + } + return ; +} + +function JobSection({ + title, + jobs, + connection, + onRefresh, + onCancel, + onConfigure, +}: { + title: string; + jobs: JobInfo[]; + connection: ConnectionSettings; + onRefresh: () => void; + onCancel: (job: JobInfo) => Promise; + onConfigure: () => void; +}) { + if (jobs.length === 0) return null; + return ( + + {jobs.map((job) => ( + + ))} + + ); +} + +function JobListItem({ + connection, + job, + onRefresh, + onCancel, + onConfigure, +}: { + connection: ConnectionSettings; + job: JobInfo; + onRefresh: () => void; + onCancel: (job: JobInfo) => Promise; + onConfigure: () => void; +}) { + const accessories = [ + { tag: { value: stateLabel(job.state), color: stateColor(job.state) } }, + { text: job.hostname, tooltip: "Host" }, + job.runtime ? { text: job.runtime, tooltip: "Runtime" } : undefined, + job.partition ? { text: job.partition, tooltip: "Partition" } : undefined, + ].filter((item): item is NonNullable => Boolean(item)); + + const keywords = [ + job.job_id, + job.hostname, + job.name, + job.state, + job.partition || "", + job.reason || "", + job.work_dir || "", + ].filter(Boolean); + + return ( + + + } /> + } /> + } /> + } /> + + + + {isRunning(job) || isPending(job) ? ( + onCancel(job)} + /> + ) : null} + open(webJobUrl(connection.apiUrl, job))} /> + + + + + {job.work_dir ? : null} + + + } + /> + ); +} diff --git a/raycast-extension/src/lib/format.ts b/raycast-extension/src/lib/format.ts new file mode 100644 index 0000000..2a31b82 --- /dev/null +++ b/raycast-extension/src/lib/format.ts @@ -0,0 +1,153 @@ +import { Color, Icon } from "@raycast/api"; +import type { JobInfo, JobState } from "../types/ssync"; + +export function stateLabel(state: JobState): string { + switch (state) { + case "PD": + return "Pending"; + case "R": + return "Running"; + case "CD": + return "Completed"; + case "F": + return "Failed"; + case "CA": + return "Cancelled"; + case "TO": + return "Timed out"; + case "UNKNOWN": + return "Unknown"; + default: + return String(state); + } +} + +export function stateIcon(state: JobState): Icon { + switch (state) { + case "R": + return Icon.Play; + case "PD": + return Icon.Clock; + case "CD": + return Icon.CheckCircle; + case "F": + return Icon.XmarkCircle; + case "CA": + return Icon.Stop; + case "TO": + return Icon.Hourglass; + default: + return Icon.QuestionMark; + } +} + +export function stateColor(state: JobState): Color { + switch (state) { + case "R": + return Color.Green; + case "PD": + return Color.Yellow; + case "CD": + return Color.Blue; + case "F": + case "TO": + return Color.Red; + case "CA": + return Color.Orange; + default: + return Color.SecondaryText; + } +} + +export function isRunning(job: JobInfo): boolean { + return job.state === "R"; +} + +export function isPending(job: JobInfo): boolean { + return job.state === "PD"; +} + +export function isHistorical(job: JobInfo): boolean { + return !isRunning(job) && !isPending(job); +} + +export function jobSortTime(job: JobInfo): number { + const raw = job.start_time || job.submit_time || job.end_time || ""; + const parsed = Date.parse(raw); + return Number.isNaN(parsed) ? 0 : parsed; +} + +export function sortJobs(jobs: JobInfo[]): JobInfo[] { + return [...jobs].sort((left, right) => { + const byTime = jobSortTime(right) - jobSortTime(left); + if (byTime !== 0) return byTime; + return right.job_id.localeCompare(left.job_id); + }); +} + +export function flattenJobs(responses: { hostname: string; jobs: JobInfo[] }[]): JobInfo[] { + return responses.flatMap((response) => + (response.jobs || []).map((job) => ({ + ...job, + hostname: job.hostname || response.hostname, + })), + ); +} + +export function jobTitle(job: JobInfo): string { + return job.name || `Job ${job.job_id}`; +} + +export function compactJobSubtitle(job: JobInfo): string { + const parts = [job.hostname, `#${job.job_id}`]; + if (job.runtime) parts.push(job.runtime); + if (job.partition) parts.push(job.partition); + if (job.reason && job.state === "PD") parts.push(job.reason); + return parts.filter(Boolean).join(" 路 "); +} + +export function formatDate(value?: string | null): string { + if (!value) return "n/a"; + const parsed = Date.parse(value); + if (Number.isNaN(parsed)) return value; + return new Date(parsed).toLocaleString(); +} + +export function formatRelativeAge(timestamp: number): string { + const seconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000)); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.round(hours / 24)}d ago`; +} + +export function bytesLabel(value?: number | null): string { + if (value === undefined || value === null) return "n/a"; + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`; + return `${(value / 1024 / 1024).toFixed(1)} MiB`; +} + +export function metadataText(value?: string | number | boolean | null): string { + if (value === undefined || value === null || value === "") return "n/a"; + if (typeof value === "boolean") return value ? "yes" : "no"; + return String(value); +} + +export function webJobUrl(apiUrl: string, job: JobInfo): string { + const base = apiUrl.replace(/\/+$/, ""); + return `${base}/#/jobs/${encodeURIComponent(job.job_id)}/${encodeURIComponent(job.hostname)}`; +} + +export function stateCountLabel(jobs: JobInfo[]): string { + const running = jobs.filter(isRunning).length; + const pending = jobs.filter(isPending).length; + const failed = jobs.filter((job) => job.state === "F" || job.state === "TO").length; + const parts = []; + if (running) parts.push(`${running}R`); + if (pending) parts.push(`${pending}PD`); + if (failed) parts.push(`${failed}F`); + return parts.join(" "); +} diff --git a/raycast-extension/src/lib/markdown.ts b/raycast-extension/src/lib/markdown.ts new file mode 100644 index 0000000..73413bd --- /dev/null +++ b/raycast-extension/src/lib/markdown.ts @@ -0,0 +1,21 @@ +export function escapeMarkdown(value: string): string { + return value.replace(/[\\`*_{}[\]()#+\-.!|]/g, "\\$&"); +} + +export function codeBlock(content?: string | null, language = ""): string { + const body = content && content.length > 0 ? content : "No content available."; + return `~~~${language}\n${body.replace(/\n?$/, "\n")}~~~`; +} + +export function fieldLine(label: string, value?: string | number | null): string | null { + if (value === undefined || value === null || value === "") return null; + return `**${escapeMarkdown(label)}:** ${escapeMarkdown(String(value))}`; +} + +export function bulletList(rows: [string, string | number | null | undefined][]): string { + const lines = rows + .map(([label, value]) => fieldLine(label, value)) + .filter((line): line is string => Boolean(line)) + .map((line) => `- ${line}`); + return lines.length > 0 ? lines.join("\n") : "_No values available._"; +} diff --git a/raycast-extension/src/lib/output-file.ts b/raycast-extension/src/lib/output-file.ts new file mode 100644 index 0000000..9a8524f --- /dev/null +++ b/raycast-extension/src/lib/output-file.ts @@ -0,0 +1,117 @@ +import { type Application, Toast, getPreferenceValues, open, showToast } from "@raycast/api"; +import { execFile } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { SsyncClient } from "../api/client"; +import type { JobInfo } from "../types/ssync"; + +type OutputType = "stdout" | "stderr"; + +type OutputEditor = "default" | "vscode" | "cursor" | "ghostty-nvim" | "custom"; + +type Preferences = { + outputEditor?: OutputEditor; + outputEditorApplication?: Application | string; +}; + +const execFileAsync = promisify(execFile); + +export async function openJobOutputFile(options: { + client: SsyncClient; + job: JobInfo; + outputType: OutputType; +}): Promise { + const toast = await showToast({ + style: Toast.Style.Animated, + title: `Downloading ${options.outputType}`, + message: "Refreshing the full output file", + }); + + try { + const download = await options.client.downloadOutput({ + job: options.job, + outputType: options.outputType, + forceRefresh: true, + }); + const filePath = await writeOutputFile({ + hostname: options.job.hostname, + jobId: options.job.job_id, + outputType: options.outputType, + filename: download.filename, + content: download.content, + }); + + toast.title = `Opening ${options.outputType}`; + toast.message = filePath; + await openWithConfiguredEditor(filePath); + + toast.style = Toast.Style.Success; + toast.title = `${options.outputType} opened`; + toast.message = filePath; + } catch (error) { + toast.style = Toast.Style.Failure; + toast.title = `Failed to open ${options.outputType}`; + toast.message = error instanceof Error ? error.message : String(error); + } +} + +async function writeOutputFile(options: { + hostname: string; + jobId: string; + outputType: OutputType; + filename: string; + content: Buffer; +}): Promise { + const filePath = join( + tmpdir(), + "ssync-raycast-output", + safePathSegment(options.hostname), + safePathSegment(options.jobId), + safeOutputFilename(options.filename, options.outputType), + ); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, options.content); + return filePath; +} + +async function openWithConfiguredEditor(filePath: string): Promise { + const preferences = getPreferenceValues(); + switch (preferences.outputEditor || "default") { + case "vscode": + await open(filePath, "Visual Studio Code"); + return; + case "cursor": + await open(filePath, "Cursor"); + return; + case "ghostty-nvim": + await openInGhosttyNvim(filePath); + return; + case "custom": + await open(filePath, preferences.outputEditorApplication || undefined); + return; + case "default": + default: + await open(filePath); + } +} + +async function openInGhosttyNvim(filePath: string): Promise { + const initialCommand = `direct:nvim ${filePath}`; + if (process.platform === "darwin") { + await execFileAsync("/usr/bin/open", ["-na", "Ghostty", "--args", `--initial-command=${initialCommand}`]); + return; + } + await execFileAsync("ghostty", [`--initial-command=${initialCommand}`]); +} + +function safePathSegment(value: string): string { + return value.replace(/[^a-zA-Z0-9._-]/g, "_") || "unknown"; +} + +function safeOutputFilename(filename: string, outputType: OutputType): string { + const safe = safePathSegment(filename); + if (safe.endsWith(".log")) return safe; + return `${safe || outputType}.log`; +} diff --git a/raycast-extension/src/menu-bar.tsx b/raycast-extension/src/menu-bar.tsx new file mode 100644 index 0000000..f6c5105 --- /dev/null +++ b/raycast-extension/src/menu-bar.tsx @@ -0,0 +1,137 @@ +import { Clipboard, Icon, LaunchType, MenuBarExtra, Toast, launchCommand, showToast } from "@raycast/api"; +import { useEffect, useMemo, useState } from "react"; +import { SsyncClient } from "./api/client"; +import { STALE_JOB_CACHE_MS, getConnection, getJobCache, saveJobCache } from "./api/storage"; +import { + compactJobSubtitle, + flattenJobs, + isPending, + isRunning, + jobTitle, + sortJobs, + stateColor, + stateCountLabel, + stateIcon, +} from "./lib/format"; +import type { ConnectionSettings, JobCache, JobInfo, JobsLaunchContext } from "./types/ssync"; + +export default function Command() { + const [connection, setConnection] = useState(); + const [cache, setCache] = useState(); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + async function load() { + const loadedConnection = await getConnection(); + if (cancelled) return; + setConnection(loadedConnection); + if (!loadedConnection) { + setIsLoading(false); + return; + } + + const storedCache = await getJobCache(); + if (cancelled) return; + if (storedCache) { + setCache(storedCache); + setIsLoading(false); + } + if (!storedCache || Date.now() - storedCache.loadedAt > STALE_JOB_CACHE_MS) { + await refresh(loadedConnection, { silent: Boolean(storedCache) }); + } else { + setIsLoading(false); + } + } + void load(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function refresh(connectionOverride?: ConnectionSettings, options: { silent?: boolean } = {}) { + const activeConnection = connectionOverride || connection; + if (!activeConnection) { + await openJobsCommand(); + return; + } + const activeClient = new SsyncClient(activeConnection); + if (!options.silent) setIsLoading(true); + setError(null); + try { + const responses = await activeClient.getStatus({ + since: activeConnection.historyWindow, + limit: activeConnection.jobLimit, + }); + const nextCache = { loadedAt: Date.now(), responses }; + setCache(nextCache); + await saveJobCache(nextCache); + } catch (refreshError) { + setError(refreshError instanceof Error ? refreshError.message : String(refreshError)); + } finally { + if (!options.silent) setIsLoading(false); + } + } + + const jobs = sortJobs(flattenJobs(cache?.responses || [])); + const runningJobs = jobs.filter(isRunning); + const pendingJobs = jobs.filter(isPending); + const label = stateCountLabel(jobs); + + if (!connection) { + return ( + + openJobsCommand()} /> + + ); + } + + return ( + + {error ? openJobsCommand()} /> : null} + + {runningJobs.length ? runningJobs.map((job) => ) : } + + + {pendingJobs.length ? pendingJobs.map((job) => ) : } + + + openJobsCommand()} /> + { + await refresh(); + await showToast({ style: Toast.Style.Success, title: "ssync jobs refreshed" }); + }} + /> + openJobsCommand()} /> + + + ); +} + +function JobMenuItem({ job }: { job: JobInfo }) { + return ( + + + openJobsCommand({ job, view: "detail" })} /> + openJobsCommand({ job, view: "output" })} /> + openJobsCommand({ job, view: "script" })} /> + Clipboard.copy(job.job_id)} /> + + ); +} + +async function openJobsCommand(context?: JobsLaunchContext) { + await launchCommand({ + name: "jobs", + type: LaunchType.UserInitiated, + context, + }); +} diff --git a/raycast-extension/src/types/ssync.ts b/raycast-extension/src/types/ssync.ts new file mode 100644 index 0000000..2bb8de5 --- /dev/null +++ b/raycast-extension/src/types/ssync.ts @@ -0,0 +1,178 @@ +export type JobState = "PD" | "R" | "CD" | "F" | "CA" | "TO" | "UNKNOWN" | string; + +export interface ConnectionSettings { + apiUrl: string; + apiKey?: string; + historyWindow: string; + jobLimit: number; + updatedAt: number; +} + +export interface JobInfo { + job_id: string; + name: string; + state: JobState; + hostname: string; + user?: string | null; + partition?: string | null; + nodes?: string | null; + cpus?: string | null; + memory?: string | null; + time_limit?: string | null; + runtime?: string | null; + reason?: string | null; + work_dir?: string | null; + stdout_file?: string | null; + stderr_file?: string | null; + submit_time?: string | null; + submit_line?: string | null; + start_time?: string | null; + end_time?: string | null; + node_list?: string | null; + node_hostnames?: string[] | null; + batch_host?: string | null; + exit_code?: string | null; + account?: string | null; + qos?: string | null; + priority?: string | null; + array_job_id?: string | null; + array_task_id?: string | null; + alloc_tres?: string | null; + req_tres?: string | null; + gres?: string | null; + tres_per_node?: string | null; + cpu_time?: string | null; + total_cpu?: string | null; + user_cpu?: string | null; + system_cpu?: string | null; + ave_cpu?: string | null; + ave_cpu_freq?: string | null; + req_cpu_freq_min?: string | null; + req_cpu_freq_max?: string | null; + max_rss?: string | null; + ave_rss?: string | null; + max_vmsize?: string | null; + ave_vmsize?: string | null; + max_disk_read?: string | null; + max_disk_write?: string | null; + ave_disk_read?: string | null; + ave_disk_write?: string | null; + consumed_energy?: string | null; + cached?: boolean; + stale?: boolean; + refresh_queued?: boolean; +} + +export interface JobStatusResponse { + hostname: string; + jobs: JobInfo[]; + total_jobs: number; + query_time: string; + cached?: boolean; + group_array_jobs?: boolean; +} + +export interface JobCache { + loadedAt: number; + responses: JobStatusResponse[]; +} + +export interface FileMetadata { + path: string; + exists: boolean; + size_bytes: number | null; + last_modified: string | null; + access_path: string | null; +} + +export interface JobOutputResponse { + job_id: string; + hostname: string; + output_type: "stdout" | "stderr" | "both"; + stdout: string | null; + stderr: string | null; + stdout_metadata: FileMetadata | null; + stderr_metadata: FileMetadata | null; + content_truncated?: boolean; + content_limit_bytes?: number | null; + cached?: boolean; + stale?: boolean; + refresh_queued?: boolean; +} + +export interface JobScriptResponse { + job_id: string; + hostname: string; + script_content: string; + content_length: number; + local_source_dir?: string | null; +} + +export interface WatcherAction { + type: string; + params?: Record; + config?: Record; + condition?: string; +} + +export interface Watcher { + id: number; + job_id: string; + hostname: string; + name: string; + job_name?: string | null; + pattern: string; + interval_seconds: number; + captures: string[]; + condition?: string; + actions: WatcherAction[]; + state: string; + trigger_count: number; + failure_count?: number; + max_failures?: number | null; + last_check?: string | null; + last_position?: number | null; + created_at?: string | null; + timer_mode_enabled?: boolean; + timer_interval_seconds?: number; + timer_mode_active?: boolean; + trigger_on_job_end?: boolean; + trigger_job_states?: string[]; + variables?: Record; + remaining_resubmits?: number; + is_array_template?: boolean; + array_spec?: string | null; + parent_watcher_id?: number | null; + discovered_task_count?: number; + expected_task_count?: number | null; +} + +export interface WatcherEvent { + id: number; + watcher_id: number; + watcher_name: string; + job_id: string; + hostname: string; + timestamp: string; + matched_text: string; + captured_vars: Record; + action_type: string; + action_result?: string | null; + success: boolean; +} + +export interface WatchersResponse { + job_id?: string; + watchers: Watcher[]; + count?: number; +} + +export interface WatcherEventsResponse { + events: WatcherEvent[]; + count: number; +} + +export interface JobsLaunchContext { + job?: JobInfo; + view?: "detail" | "output" | "script" | "watchers"; +} diff --git a/raycast-extension/tsconfig.json b/raycast-extension/tsconfig.json new file mode 100644 index 0000000..92a8193 --- /dev/null +++ b/raycast-extension/tsconfig.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "commonjs", + "jsx": "react-jsx", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "types": ["node", "react"] + }, + "include": ["src"] +} diff --git a/src/ssync/api/client.py b/src/ssync/api/client.py index f250af1..2f42a88 100644 --- a/src/ssync/api/client.py +++ b/src/ssync/api/client.py @@ -184,6 +184,7 @@ def download_job_output( host: str, output_type: str = "stdout", compressed: bool = False, + force_refresh: bool = False, timeout: int = 60, ) -> tuple[str, bytes]: """Download a job output file from the API. @@ -197,6 +198,7 @@ def download_job_output( "host": host, "output_type": output_type, "compressed": "true" if compressed else "false", + "force_refresh": "true" if force_refresh else "false", }, headers=self._get_headers(), timeout=timeout, diff --git a/src/ssync/web/api/job.py b/src/ssync/web/api/job.py index ed4c36f..a907877 100644 --- a/src/ssync/web/api/job.py +++ b/src/ssync/web/api/job.py @@ -226,6 +226,9 @@ async def download_job_output( host: str = Query(..., description="Host where job is running"), output_type: str = Query("stdout", regex="^(stdout|stderr)$"), compressed: bool = Query(default=False, description="Download as gzip"), + force_refresh: bool = Query( + False, description="Force refresh from SSH even if cached" + ), _authenticated: bool = Depends(verify_api_key_dependency), ): """Download job output file, optionally compressed.""" @@ -236,6 +239,7 @@ async def download_job_output( host=host, output_type=output_type, compressed=compressed, + force_refresh=force_refresh, get_slurm_manager=get_slurm_manager, ) diff --git a/src/ssync/web/services/jobs.py b/src/ssync/web/services/jobs.py index 3b9d6da..eddea57 100644 --- a/src/ssync/web/services/jobs.py +++ b/src/ssync/web/services/jobs.py @@ -713,6 +713,7 @@ async def build_download_job_output_response( host: str, output_type: str, compressed: bool, + force_refresh: bool = False, get_slurm_manager, ) -> StreamingResponse: cache = get_cache() @@ -722,7 +723,7 @@ async def build_download_job_output_response( content = None compression = "none" original_size = 0 - if cached_job: + if cached_job and not force_refresh: content, compression, original_size = get_cached_output_payload( cached_job, output_type, diff --git a/tests/unit/test_api_client_output.py b/tests/unit/test_api_client_output.py index 5e4daef..e4308aa 100644 --- a/tests/unit/test_api_client_output.py +++ b/tests/unit/test_api_client_output.py @@ -37,3 +37,38 @@ def fake_get(url, **kwargs): "output_type": "stdout", "all": "true", } + + +@pytest.mark.unit +def test_download_job_output_sends_force_refresh_query_param(monkeypatch): + calls = {} + + class _Response: + headers = {"Content-Disposition": 'attachment; filename="job_1234_stdout.log"'} + content = b"full-output" + + def raise_for_status(self): + return None + + def fake_get(url, **kwargs): + calls["url"] = url + calls["kwargs"] = kwargs + return _Response() + + monkeypatch.setattr("ssync.api.client.requests.get", fake_get) + + client = APIClient(base_url="https://ssync.test", api_key="secret") + assert client.download_job_output( + job_id="1234", + host="entalpic", + output_type="stdout", + force_refresh=True, + ) == ("job_1234_stdout.log", b"full-output") + + assert calls["url"] == "https://ssync.test/api/jobs/1234/output/download" + assert calls["kwargs"]["params"] == { + "host": "entalpic", + "output_type": "stdout", + "compressed": "false", + "force_refresh": "true", + }