@odyn-app/api 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,10 +2,18 @@
2
2
 
3
3
  The building kit for [Odyn](https://github.com/New-Genre/odyn-extensions) extensions.
4
4
 
5
+ Start a new extension, work on it, and publish it:
6
+
5
7
  ```bash
6
- bun add @odyn-app/api
8
+ bunx --package @odyn-app/api odyn-extension create "My extension"
9
+ cd my-extension
10
+ bun install
11
+ bun run dev # rebuilds on every save; Odyn reloads it
12
+ bun run publish # sends it to Odyn's Marketplace for review
7
13
  ```
8
14
 
15
+ Your extension's entry file:
16
+
9
17
  ```ts
10
18
  import { defineExtension } from '@odyn-app/api'
11
19
 
@@ -16,10 +24,13 @@ defineExtension({
16
24
  })
17
25
  ```
18
26
 
27
+ - `odyn-extension`: the command-line tool above — `create`, `dev`, `build`, `check`, `pull <id>` and `publish`.
19
28
  - `@odyn-app/api`: `defineExtension`, the `odyn` bridge, the manifest types and the manifest checker.
20
29
  - `@odyn-app/api/ui`: Odyn's `Button`, `Skeleton`, `SettingRow`, `Orb` and `cn`, for extension pages.
21
30
  - `@odyn-app/api/theme.css`: Odyn's colours, fonts and light/dark themes. Add `@import "@odyn-app/api/theme.css";` to a page's stylesheet.
22
31
 
23
- Every extension runs sandboxed, and each call is checked against the permissions the user granted. To make one and publish it to Odyn's Marketplace, see the [contribution guide](https://github.com/New-Genre/odyn-extensions/blob/main/CONTRIBUTING.md).
32
+ Every extension runs sandboxed, and each call is checked against the permissions the user granted.
33
+
34
+ **[Read the docs](https://www.odyn.build/docs/extensions)** for what extensions can and can't do, every permission and limit, and the full reference. To publish one to Odyn's Marketplace, see the [contribution guide](https://github.com/New-Genre/odyn-extensions/blob/main/CONTRIBUTING.md).
24
35
 
25
36
  MIT licence.
package/cli/build.mjs ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Builds one extension folder. Shared by `odyn-extension build` (an author's
3
+ * own folder, built in place into `dist/`) and the public repo's release
4
+ * build (built into a separate folder that becomes the download).
5
+ *
6
+ * What gets built, when the extension has it:
7
+ * - commands, `src/index.ts` → `dist/index.js` (one self-contained script)
8
+ * - a page, `index.html` → `dist/panel/`
9
+ * - a background helper, `src/helper.ts` → `dist/helper.js`
10
+ * Every manifest is checked with the same checker Odyn runs on a download,
11
+ * so a broken one fails here rather than on someone's Mac.
12
+ */
13
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
14
+ import { join, relative, resolve } from 'node:path'
15
+ import react from '@vitejs/plugin-react'
16
+ import tailwindcss from '@tailwindcss/postcss'
17
+ import { build } from 'vite'
18
+ import { validateManifest } from '../dist/index.js'
19
+
20
+ const PANEL_DIR = join('dist', 'panel')
21
+ const HELPER_SOURCE = join('src', 'helper.ts')
22
+ const COMMANDS_SOURCE = join('src', 'index.ts')
23
+ /** The Chinese hand-drawn font is 12 MB on its own; Chinese text falls back to a system font. */
24
+ const SKIPPED_FONT_FAMILIES = new Set(['Xiaolai'])
25
+ /** The drawing library's folder, as it appears in the paths of the files a build uses. */
26
+ const DRAWING_LIBRARY_PATH = '/node_modules/@excalidraw/excalidraw/'
27
+ /** The only parts of an extension's `package.json` Odyn reads. */
28
+ const SHIPPED_PACKAGE_KEYS = ['name', 'version', 'private', 'odyn']
29
+
30
+ /** Reads an extension's `package.json`. */
31
+ export function readPackage(sourceDir) {
32
+ return JSON.parse(readFileSync(join(sourceDir, 'package.json'), 'utf8'))
33
+ }
34
+
35
+ /**
36
+ * Checks an extension folder's manifest. Returns the problems found.
37
+ * `official` relaxes the rules for extensions made by Odyn.
38
+ */
39
+ export function checkExtension(sourceDir, { official = false } = {}) {
40
+ if (!existsSync(join(sourceDir, 'package.json'))) return ['This folder has no package.json.']
41
+ const pkg = readPackage(sourceDir)
42
+ if (typeof pkg.odyn !== 'object' || pkg.odyn === null) return ['package.json has no "odyn" section.']
43
+ const problems = []
44
+ if (pkg.private !== true) problems.push('"private": true is required, so it is never published to npm by mistake.')
45
+ const manifest = { ...pkg.odyn, version: pkg.version }
46
+ problems.push(...validateManifest(manifest, { official }))
47
+ return problems
48
+ }
49
+
50
+ /**
51
+ * Stops the build if a page or helper reaches outside its own folder — an
52
+ * extension must never quietly bundle another's files. Installed packages
53
+ * (the kit, React and so on) are fine.
54
+ */
55
+ function stayInsideFolder(sourceDir, onDrawingLibrary) {
56
+ return {
57
+ name: 'odyn-stay-inside-folder',
58
+ generateBundle() {
59
+ for (const moduleId of this.getModuleIds()) {
60
+ const library = moduleId.indexOf(DRAWING_LIBRARY_PATH)
61
+ if (library !== -1) onDrawingLibrary(moduleId.slice(0, library + DRAWING_LIBRARY_PATH.length))
62
+ const file = moduleId.split('?')[0]
63
+ if (!file.startsWith('/') || file.includes('/node_modules/') || !existsSync(file)) continue
64
+ if (!file.startsWith(`${sourceDir}/`)) {
65
+ this.error(`${relative(sourceDir, file)} is outside this extension's folder. Extensions may only use their own files and installed packages.`)
66
+ }
67
+ }
68
+ },
69
+ }
70
+ }
71
+
72
+ /** Copies the drawing library's fonts in, when the page uses the library. */
73
+ function copyFonts(libraryRoot, panelDir) {
74
+ const fontsRoot = join(libraryRoot, 'dist', 'prod', 'fonts')
75
+ if (!existsSync(fontsRoot)) throw new Error(`Drawing fonts not found at ${fontsRoot}`)
76
+ for (const family of readdirSync(fontsRoot)) {
77
+ if (SKIPPED_FONT_FAMILIES.has(family)) continue
78
+ cpSync(join(fontsRoot, family), join(panelDir, 'fonts', family), { recursive: true })
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Builds the extension in `sourceDir`. With no `outputDir` it builds in place
84
+ * (into `sourceDir/dist`), ready for "Install from folder". With one, it
85
+ * writes a complete download there: the built files, the agent guide and a
86
+ * trimmed `package.json`.
87
+ */
88
+ export async function buildExtension(sourceDir, { outputDir = null, official = false } = {}) {
89
+ const root = resolve(sourceDir)
90
+ const problems = checkExtension(root, { official })
91
+ if (problems.length > 0) throw new Error(problems.join(' '))
92
+ const pkg = readPackage(root)
93
+ const inPlace = outputDir === null || resolve(outputDir) === root
94
+ const target = inPlace ? root : resolve(outputDir)
95
+ rmSync(inPlace ? join(root, 'dist') : target, { recursive: true, force: true })
96
+ mkdirSync(join(target, 'dist'), { recursive: true })
97
+
98
+ if (existsSync(join(root, 'index.html'))) {
99
+ const panelDir = join(target, PANEL_DIR)
100
+ /** Where the drawing library is installed, once the page is seen to use it. */
101
+ let drawingLibraryRoot = null
102
+ await build({
103
+ configFile: false,
104
+ root,
105
+ base: './',
106
+ logLevel: 'warn',
107
+ plugins: [react(), stayInsideFolder(root, (libraryRoot) => { drawingLibraryRoot = libraryRoot })],
108
+ // Tailwind reads the extension's own folder (and the kit, which its theme points at).
109
+ css: { postcss: { plugins: [tailwindcss({ base: root })] } },
110
+ build: { outDir: panelDir, emptyOutDir: true, sourcemap: false, chunkSizeWarningLimit: 4096 },
111
+ })
112
+ if (drawingLibraryRoot !== null) copyFonts(drawingLibraryRoot, panelDir)
113
+ }
114
+
115
+ // Commands and the background helper each run in a page with no module
116
+ // loader, so everything is bundled into a single plain script.
117
+ const scripts = [
118
+ { source: COMMANDS_SOURCE, name: 'OdynExtension', fileName: 'index.js' },
119
+ { source: HELPER_SOURCE, name: 'OdynHelper', fileName: 'helper.js' },
120
+ ]
121
+ for (const script of scripts.filter(({ source }) => existsSync(join(root, source)))) {
122
+ await build({
123
+ configFile: false,
124
+ root,
125
+ logLevel: 'warn',
126
+ plugins: [stayInsideFolder(root, () => undefined)],
127
+ build: {
128
+ outDir: join(target, 'dist'),
129
+ emptyOutDir: false,
130
+ sourcemap: false,
131
+ minify: false,
132
+ lib: { entry: join(root, script.source), formats: ['iife'], name: script.name, fileName: () => script.fileName },
133
+ },
134
+ })
135
+ }
136
+
137
+ if (!inPlace) {
138
+ const skillFolder = pkg.odyn?.agent?.skillFolder
139
+ if (typeof skillFolder === 'string' && existsSync(join(root, skillFolder))) {
140
+ cpSync(join(root, skillFolder), join(target, skillFolder), { recursive: true })
141
+ }
142
+ const shipped = Object.fromEntries(SHIPPED_PACKAGE_KEYS.filter((key) => key in pkg).map((key) => [key, pkg[key]]))
143
+ writeFileSync(join(target, 'package.json'), `${JSON.stringify(shipped, null, 2)}\n`)
144
+ }
145
+ return { id: pkg.odyn.id, version: pkg.version }
146
+ }
package/cli/github.mjs ADDED
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Talking to GitHub for `odyn-extension publish` and `pull`.
3
+ *
4
+ * Publishing never clones the public repo. It copies the repo into the
5
+ * author's own GitHub account (a fork), writes the extension's files there
6
+ * as one commit and opens a pull request — all through GitHub's web API.
7
+ */
8
+ import { execFileSync, spawn } from 'node:child_process'
9
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
10
+ import { homedir } from 'node:os'
11
+ import { dirname, join } from 'node:path'
12
+
13
+ export const UPSTREAM_OWNER = 'New-Genre'
14
+ export const UPSTREAM_REPO = 'odyn-extensions'
15
+ export const UPSTREAM_BRANCH = 'main'
16
+ const API = 'https://api.github.com'
17
+ /**
18
+ * Odyn's GitHub sign-in app (New-Genre's "Odyn extensions" OAuth app, with
19
+ * device sign-in switched on). Device sign-in — a short code you type on
20
+ * github.com — needs no secret, so the id is safe to ship.
21
+ */
22
+ const ODYN_GITHUB_CLIENT_ID = 'Ov23lipXr50lgj85CbN5'
23
+ const GITHUB_CLIENT_ID = process.env.ODYN_GITHUB_CLIENT_ID?.trim() || ODYN_GITHUB_CLIENT_ID
24
+ const TOKEN_FILE = join(homedir(), '.config', 'odyn-extension', 'github.json')
25
+ const FORK_READY_ATTEMPTS = 20
26
+ const FORK_READY_DELAY_MS = 3000
27
+
28
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms))
29
+
30
+ /** Opens a web page in the user's browser. Best effort. */
31
+ function openInBrowser(url) {
32
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
33
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
34
+ try {
35
+ spawn(command, args, { stdio: 'ignore', detached: true }).unref()
36
+ } catch (error) {
37
+ console.warn(`Couldn't open the browser (${error instanceof Error ? error.message : String(error)}). Open ${url} yourself.`)
38
+ }
39
+ }
40
+
41
+ function readSavedToken() {
42
+ if (!existsSync(TOKEN_FILE)) return null
43
+ try {
44
+ const saved = JSON.parse(readFileSync(TOKEN_FILE, 'utf8'))
45
+ return typeof saved.token === 'string' && saved.token !== '' ? saved.token : null
46
+ } catch (error) {
47
+ console.warn(`Ignoring an unreadable saved sign-in: ${error instanceof Error ? error.message : String(error)}`)
48
+ return null
49
+ }
50
+ }
51
+
52
+ function saveToken(token) {
53
+ mkdirSync(dirname(TOKEN_FILE), { recursive: true })
54
+ writeFileSync(TOKEN_FILE, `${JSON.stringify({ token })}\n`, { mode: 0o600 })
55
+ chmodSync(TOKEN_FILE, 0o600)
56
+ }
57
+
58
+ /** The GitHub CLI's token, when it is installed and signed in. */
59
+ function ghCliToken() {
60
+ try {
61
+ const token = execFileSync('gh', ['auth', 'token'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
62
+ return token === '' ? null : token
63
+ } catch {
64
+ return null
65
+ }
66
+ }
67
+
68
+ /** Signs in through the browser: shows a short code, the user approves it on github.com. */
69
+ async function deviceSignIn() {
70
+ if (GITHUB_CLIENT_ID === '') {
71
+ throw new Error('Sign in to GitHub first: install the GitHub CLI and run `gh auth login`, or set GITHUB_TOKEN.')
72
+ }
73
+ const start = await fetch('https://github.com/login/device/code', {
74
+ method: 'POST',
75
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
76
+ body: JSON.stringify({ client_id: GITHUB_CLIENT_ID, scope: 'public_repo' }),
77
+ })
78
+ const code = await start.json()
79
+ if (!start.ok || typeof code.device_code !== 'string') {
80
+ throw new Error(`GitHub sign-in couldn't start: ${code.error_description ?? code.error ?? start.status}`)
81
+ }
82
+ console.log(`\nSign in to GitHub: enter the code ${code.user_code} at ${code.verification_uri}`)
83
+ openInBrowser(code.verification_uri)
84
+ let interval = (code.interval ?? 5) * 1000
85
+ const deadline = Date.now() + (code.expires_in ?? 900) * 1000
86
+ while (Date.now() < deadline) {
87
+ await sleep(interval)
88
+ const poll = await fetch('https://github.com/login/oauth/access_token', {
89
+ method: 'POST',
90
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
91
+ body: JSON.stringify({ client_id: GITHUB_CLIENT_ID, device_code: code.device_code, grant_type: 'urn:ietf:params:oauth:grant-type:device_code' }),
92
+ })
93
+ const result = await poll.json()
94
+ if (typeof result.access_token === 'string') return result.access_token
95
+ if (result.error === 'slow_down') interval += 5000
96
+ else if (result.error !== 'authorization_pending') throw new Error(`GitHub sign-in failed: ${result.error_description ?? result.error}`)
97
+ }
98
+ throw new Error('GitHub sign-in timed out. Run the command again.')
99
+ }
100
+
101
+ /**
102
+ * A GitHub token: `GITHUB_TOKEN`, then a saved sign-in, then the GitHub CLI,
103
+ * and finally a fresh sign-in through the browser.
104
+ */
105
+ export async function getToken() {
106
+ const fromEnv = process.env.GITHUB_TOKEN?.trim()
107
+ if (fromEnv) return fromEnv
108
+ const saved = readSavedToken()
109
+ if (saved) return saved
110
+ const fromCli = ghCliToken()
111
+ if (fromCli) return fromCli
112
+ const fresh = await deviceSignIn()
113
+ saveToken(fresh)
114
+ return fresh
115
+ }
116
+
117
+ /** Forgets the saved sign-in, e.g. after GitHub rejects it. */
118
+ export function forgetToken() {
119
+ if (existsSync(TOKEN_FILE)) writeFileSync(TOKEN_FILE, '{}\n', { mode: 0o600 })
120
+ }
121
+
122
+ /** A small GitHub API client. Throws a readable error on any failure. */
123
+ export function githubClient(token) {
124
+ return async function request(method, path, body) {
125
+ const response = await fetch(`${API}${path}`, {
126
+ method,
127
+ headers: {
128
+ accept: 'application/vnd.github+json',
129
+ 'x-github-api-version': '2022-11-28',
130
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
131
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
132
+ },
133
+ body: body === undefined ? undefined : JSON.stringify(body),
134
+ })
135
+ if (response.status === 204) return null
136
+ const text = await response.text()
137
+ const data = text === '' ? null : JSON.parse(text)
138
+ if (!response.ok) {
139
+ const error = new Error(`GitHub said ${response.status} for ${method} ${path}: ${data?.message ?? text}`)
140
+ error.status = response.status
141
+ throw error
142
+ }
143
+ return data
144
+ }
145
+ }
146
+
147
+ /** Every file in the public repo's extension folder, as `{ path, sha }` relative to it. */
148
+ export async function upstreamFiles(request, extensionId) {
149
+ const branch = await request('GET', `/repos/${UPSTREAM_OWNER}/${UPSTREAM_REPO}/branches/${UPSTREAM_BRANCH}`)
150
+ const tree = await request('GET', `/repos/${UPSTREAM_OWNER}/${UPSTREAM_REPO}/git/trees/${branch.commit.sha}?recursive=1`)
151
+ const prefix = `extensions/${extensionId}/`
152
+ const files = tree.tree
153
+ .filter((entry) => entry.type === 'blob' && entry.path.startsWith(prefix))
154
+ .map((entry) => ({ path: entry.path.slice(prefix.length), sha: entry.sha }))
155
+ return { commitSha: branch.commit.sha, treeSha: branch.commit.commit.tree.sha, files }
156
+ }
157
+
158
+ /** Makes sure the user has their own copy of the public repo, up to date. Returns its owner. */
159
+ export async function ensureFork(request, login) {
160
+ try {
161
+ await request('GET', `/repos/${login}/${UPSTREAM_REPO}`)
162
+ } catch (error) {
163
+ if (error.status !== 404) throw error
164
+ console.log(`Making your copy of ${UPSTREAM_OWNER}/${UPSTREAM_REPO} on GitHub…`)
165
+ await request('POST', `/repos/${UPSTREAM_OWNER}/${UPSTREAM_REPO}/forks`, { default_branch_only: true })
166
+ for (let attempt = 0; attempt < FORK_READY_ATTEMPTS; attempt += 1) {
167
+ await sleep(FORK_READY_DELAY_MS)
168
+ try {
169
+ await request('GET', `/repos/${login}/${UPSTREAM_REPO}/branches/${UPSTREAM_BRANCH}`)
170
+ return login
171
+ } catch (pollError) {
172
+ if (pollError.status !== 404 && pollError.status !== 409) throw pollError
173
+ }
174
+ }
175
+ throw new Error('GitHub is still making your copy of the repo. Try again in a minute.')
176
+ }
177
+ await request('POST', `/repos/${login}/${UPSTREAM_REPO}/merge-upstream`, { branch: UPSTREAM_BRANCH }).catch((error) => {
178
+ console.warn(`Couldn't bring your copy up to date first (${error.message}). Carrying on.`)
179
+ })
180
+ return login
181
+ }
182
+
183
+ /** Downloads one file of the public repo's main branch. */
184
+ export async function downloadUpstreamFile(path) {
185
+ const url = `https://raw.githubusercontent.com/${UPSTREAM_OWNER}/${UPSTREAM_REPO}/${UPSTREAM_BRANCH}/${path.split('/').map(encodeURIComponent).join('/')}`
186
+ const response = await fetch(url)
187
+ if (!response.ok) throw new Error(`Couldn't download ${path} (${response.status}).`)
188
+ return Buffer.from(await response.arrayBuffer())
189
+ }
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * odyn-extension — make, test and publish Odyn extensions.
4
+ *
5
+ * odyn-extension create <name> start a new extension in a new folder
6
+ * odyn-extension dev build, then rebuild on every save (Odyn reloads it)
7
+ * odyn-extension build build once into dist/
8
+ * odyn-extension check check the manifest
9
+ * odyn-extension pull <id> download a published extension's source to work on it
10
+ * odyn-extension publish send this extension to the Marketplace as a pull request
11
+ */
12
+ import { execFileSync } from 'node:child_process'
13
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, watch, writeFileSync } from 'node:fs'
14
+ import { dirname, join, relative, resolve, sep } from 'node:path'
15
+ import { userInfo } from 'node:os'
16
+ import { fileURLToPath } from 'node:url'
17
+ import { buildExtension, checkExtension, readPackage } from './build.mjs'
18
+ import {
19
+ UPSTREAM_BRANCH,
20
+ UPSTREAM_OWNER,
21
+ UPSTREAM_REPO,
22
+ downloadUpstreamFile,
23
+ ensureFork,
24
+ forgetToken,
25
+ getToken,
26
+ githubClient,
27
+ upstreamFiles,
28
+ } from './github.mjs'
29
+ import { starterFiles } from '../dist/index.js'
30
+
31
+ const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
32
+ const KIT_VERSION = JSON.parse(readFileSync(join(KIT_ROOT, 'package.json'), 'utf8')).version
33
+ /** Saves that land within this window become one rebuild. */
34
+ const REBUILD_DELAY_MS = 150
35
+ /** Never sent to the public repo: installed packages, build output, local clutter. */
36
+ const UNPUBLISHED = new Set(['node_modules', 'dist', 'build', '.git', '.DS_Store', 'bun.lock', 'bun.lockb', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'])
37
+ /** A single file bigger than this is refused; the download as a whole is capped by the repo. */
38
+ const MAX_PUBLISHED_FILE_BYTES = 5 * 1024 * 1024
39
+ const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
40
+
41
+ const USAGE = `odyn-extension — make, test and publish Odyn extensions
42
+
43
+ create <name> start a new extension in a new folder
44
+ dev build, then rebuild on every save (Odyn reloads it)
45
+ build build once into dist/
46
+ check check the manifest
47
+ pull <id> download a published extension's source to work on it
48
+ publish send this extension to the Marketplace as a pull request
49
+
50
+ Docs: https://www.odyn.build/docs/extensions`
51
+
52
+ /** "Standup notes!" → "standup-notes". */
53
+ function toId(name) {
54
+ return name.toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
55
+ }
56
+
57
+ /** The user's name from git, or their login name. */
58
+ function authorName() {
59
+ try {
60
+ const name = execFileSync('git', ['config', 'user.name'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
61
+ if (name !== '') return name
62
+ } catch {
63
+ // No git, or no name set — fall back below.
64
+ }
65
+ return userInfo().username
66
+ }
67
+
68
+ function writeFiles(folder, files) {
69
+ for (const [path, content] of Object.entries(files)) {
70
+ const target = join(folder, path)
71
+ mkdirSync(dirname(target), { recursive: true })
72
+ writeFileSync(target, content)
73
+ }
74
+ }
75
+
76
+ function create(args) {
77
+ const name = args.join(' ').trim()
78
+ if (name === '') throw new Error('Give the extension a name, e.g. `odyn-extension create "Standup notes"`.')
79
+ const id = toId(name)
80
+ if (!ID_PATTERN.test(id)) throw new Error(`"${name}" needs at least one letter or digit.`)
81
+ const folder = resolve(id)
82
+ if (existsSync(folder) && readdirSync(folder).length > 0) throw new Error(`${folder} already exists and isn't empty.`)
83
+ const title = name === id ? name.replace(/-/g, ' ').replace(/^./, (letter) => letter.toUpperCase()) : name
84
+ writeFiles(folder, starterFiles({ id, title, author: authorName(), kitVersion: `^${KIT_VERSION}` }))
85
+ console.log(`Made ${relative(process.cwd(), folder) || '.'}/
86
+
87
+ Next:
88
+ cd ${id}
89
+ bun install
90
+ bun run dev
91
+
92
+ Then in Odyn: Marketplace → Extensions → Install from folder, and pick the folder.`)
93
+ }
94
+
95
+ /** The extension folder the command was run in. */
96
+ function currentExtension() {
97
+ const folder = process.cwd()
98
+ if (!existsSync(join(folder, 'package.json'))) throw new Error('Run this inside an extension folder (one with a package.json).')
99
+ return folder
100
+ }
101
+
102
+ async function buildOnce(folder) {
103
+ const started = Date.now()
104
+ const { id, version } = await buildExtension(folder)
105
+ console.log(`Built ${id} ${version} in ${Date.now() - started} ms`)
106
+ }
107
+
108
+ async function dev() {
109
+ const folder = currentExtension()
110
+ await buildOnce(folder)
111
+ console.log('Watching for changes. Odyn reloads the extension after each build. Press Ctrl+C to stop.')
112
+ let timer = null
113
+ let building = false
114
+ let again = false
115
+ const rebuild = async () => {
116
+ if (building) {
117
+ again = true
118
+ return
119
+ }
120
+ building = true
121
+ try {
122
+ await buildOnce(folder)
123
+ } catch (error) {
124
+ console.error(`Build failed: ${error instanceof Error ? error.message : String(error)}`)
125
+ } finally {
126
+ building = false
127
+ if (again) {
128
+ again = false
129
+ void rebuild()
130
+ }
131
+ }
132
+ }
133
+ watch(folder, { recursive: true }, (_event, file) => {
134
+ if (typeof file !== 'string') return
135
+ const top = file.split(sep)[0]
136
+ if (UNPUBLISHED.has(top)) return
137
+ if (timer !== null) clearTimeout(timer)
138
+ timer = setTimeout(() => { timer = null; void rebuild() }, REBUILD_DELAY_MS)
139
+ })
140
+ }
141
+
142
+ function check() {
143
+ const problems = checkExtension(currentExtension())
144
+ if (problems.length > 0) throw new Error(problems.join('\n'))
145
+ console.log('The manifest looks good.')
146
+ }
147
+
148
+ async function pull(args) {
149
+ const id = args[0]
150
+ if (!id || !ID_PATTERN.test(id)) throw new Error('Give the extension id, e.g. `odyn-extension pull standup-notes`.')
151
+ const folder = resolve(id)
152
+ if (existsSync(folder) && readdirSync(folder).length > 0) throw new Error(`${folder} already exists and isn't empty.`)
153
+ const request = githubClient(process.env.GITHUB_TOKEN?.trim() || null)
154
+ const { files } = await upstreamFiles(request, id)
155
+ if (files.length === 0) throw new Error(`There's no extension called "${id}" in ${UPSTREAM_OWNER}/${UPSTREAM_REPO}.`)
156
+ for (const file of files) {
157
+ const target = join(folder, file.path)
158
+ mkdirSync(dirname(target), { recursive: true })
159
+ writeFileSync(target, await downloadUpstreamFile(`extensions/${id}/${file.path}`))
160
+ }
161
+ // Extensions in the repo share its tools; on its own the folder needs the kit
162
+ // and the usual shortcuts.
163
+ const pkg = readPackage(folder)
164
+ const starter = JSON.parse(starterFiles({ id, title: id, author: '', kitVersion: `^${KIT_VERSION}` })['package.json'])
165
+ pkg.scripts = { ...starter.scripts, ...pkg.scripts }
166
+ pkg.dependencies = { ...starter.dependencies, ...pkg.dependencies }
167
+ writeFileSync(join(folder, 'package.json'), `${JSON.stringify(pkg, null, 2)}\n`)
168
+ console.log(`Downloaded ${files.length} files into ${id}/
169
+
170
+ Next:
171
+ cd ${id}
172
+ bun install
173
+ bun run dev
174
+
175
+ When you're done, \`bun run publish\` sends your changes for review.`)
176
+ }
177
+
178
+ /** Every file to publish, relative to the folder, skipping installed packages and build output. */
179
+ function publishableFiles(folder) {
180
+ const files = []
181
+ const walk = (dir) => {
182
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
183
+ if (UNPUBLISHED.has(entry.name)) continue
184
+ const path = join(dir, entry.name)
185
+ if (entry.isDirectory()) walk(path)
186
+ else if (entry.isFile()) files.push(relative(folder, path).split(sep).join('/'))
187
+ }
188
+ }
189
+ walk(folder)
190
+ return files.sort()
191
+ }
192
+
193
+ async function publish() {
194
+ const folder = currentExtension()
195
+ const problems = checkExtension(folder)
196
+ if (problems.length > 0) throw new Error(`Fix these before publishing:\n${problems.join('\n')}`)
197
+ // Building first catches mistakes before anyone has to review them.
198
+ await buildOnce(folder)
199
+
200
+ let token = await getToken()
201
+ let request = githubClient(token)
202
+ let user
203
+ try {
204
+ user = await request('GET', '/user')
205
+ } catch (error) {
206
+ if (error.status !== 401) throw error
207
+ forgetToken()
208
+ token = await getToken()
209
+ request = githubClient(token)
210
+ user = await request('GET', '/user')
211
+ }
212
+ const login = user.login
213
+
214
+ // The person publishing looks after the extension, unless someone already does.
215
+ const pkg = readPackage(folder)
216
+ const id = pkg.odyn.id
217
+ const upstream = await upstreamFiles(request, id)
218
+ const isNew = upstream.files.length === 0
219
+ if (!Array.isArray(pkg.odyn.owners) || pkg.odyn.owners.length === 0) {
220
+ pkg.odyn.owners = [login]
221
+ writeFileSync(join(folder, 'package.json'), `${JSON.stringify(pkg, null, 2)}\n`)
222
+ console.log(`Added you (${login}) as the owner in package.json.`)
223
+ }
224
+
225
+ const fork = await ensureFork(request, login)
226
+ const local = publishableFiles(folder)
227
+ for (const path of local) {
228
+ if (statSync(join(folder, path)).size > MAX_PUBLISHED_FILE_BYTES) throw new Error(`${path} is over 5 MB. Leave large files out.`)
229
+ }
230
+ const prefix = `extensions/${id}/`
231
+ const entries = []
232
+ for (const path of local) {
233
+ const blob = await request('POST', `/repos/${fork}/${UPSTREAM_REPO}/git/blobs`, {
234
+ content: readFileSync(join(folder, path)).toString('base64'),
235
+ encoding: 'base64',
236
+ })
237
+ entries.push({ path: `${prefix}${path}`, mode: '100644', type: 'blob', sha: blob.sha })
238
+ }
239
+ // Files that were published before but are gone now are removed.
240
+ const kept = new Set(local)
241
+ for (const file of upstream.files) {
242
+ if (!kept.has(file.path)) entries.push({ path: `${prefix}${file.path}`, mode: '100644', type: 'blob', sha: null })
243
+ }
244
+ const tree = await request('POST', `/repos/${fork}/${UPSTREAM_REPO}/git/trees`, { base_tree: upstream.treeSha, tree: entries })
245
+ if (tree.sha === upstream.treeSha) {
246
+ console.log('Nothing has changed since the published version.')
247
+ return
248
+ }
249
+ const version = pkg.version
250
+ const message = isNew ? `Add ${pkg.odyn.title} ${version}` : `Update ${pkg.odyn.title} to ${version}`
251
+ const commit = await request('POST', `/repos/${fork}/${UPSTREAM_REPO}/git/commits`, { message, tree: tree.sha, parents: [upstream.commitSha] })
252
+ const branch = `extension/${id}`
253
+ try {
254
+ await request('PATCH', `/repos/${fork}/${UPSTREAM_REPO}/git/refs/heads/${branch}`, { sha: commit.sha, force: true })
255
+ } catch (error) {
256
+ if (error.status !== 404 && error.status !== 422) throw error
257
+ await request('POST', `/repos/${fork}/${UPSTREAM_REPO}/git/refs`, { ref: `refs/heads/${branch}`, sha: commit.sha })
258
+ }
259
+
260
+ const open = await request('GET', `/repos/${UPSTREAM_OWNER}/${UPSTREAM_REPO}/pulls?state=open&head=${encodeURIComponent(`${fork}:${branch}`)}`)
261
+ if (open.length > 0) {
262
+ console.log(`Updated your pull request: ${open[0].html_url}`)
263
+ return
264
+ }
265
+ const body = [
266
+ isNew ? `Adds **${pkg.odyn.title}** (\`${id}\`) ${version}.` : `Updates **${pkg.odyn.title}** (\`${id}\`) to ${version}.`,
267
+ '',
268
+ pkg.odyn.description,
269
+ '',
270
+ `Permissions: ${pkg.odyn.permissions.length === 0 ? 'none' : pkg.odyn.permissions.map((permission) => `\`${permission}\``).join(', ')}`,
271
+ '',
272
+ `Published with \`odyn-extension publish\` (kit ${KIT_VERSION}).`,
273
+ ].join('\n')
274
+ const pr = await request('POST', `/repos/${UPSTREAM_OWNER}/${UPSTREAM_REPO}/pulls`, {
275
+ title: message,
276
+ head: `${fork}:${branch}`,
277
+ base: UPSTREAM_BRANCH,
278
+ body,
279
+ maintainer_can_modify: true,
280
+ })
281
+ console.log(`Opened a pull request: ${pr.html_url}
282
+ Once it's reviewed and merged, ${pkg.odyn.title} appears in Odyn's Marketplace.
283
+ To change it later, edit this folder and run \`bun run publish\` again.`)
284
+ }
285
+
286
+ async function main() {
287
+ const [command, ...args] = process.argv.slice(2)
288
+ switch (command) {
289
+ case 'create': return create(args)
290
+ case 'dev': return dev()
291
+ case 'build': return buildOnce(currentExtension())
292
+ case 'check': return check()
293
+ case 'pull': return pull(args)
294
+ case 'publish': return publish()
295
+ case undefined:
296
+ case 'help':
297
+ case '--help':
298
+ case '-h':
299
+ console.log(USAGE)
300
+ return undefined
301
+ default:
302
+ throw new Error(`Unknown command "${command}".\n\n${USAGE}`)
303
+ }
304
+ }
305
+
306
+ main().catch((error) => {
307
+ console.error(error instanceof Error ? error.message : String(error))
308
+ process.exit(1)
309
+ })
310
+
package/dist/index.d.ts CHANGED
@@ -7,6 +7,14 @@ export declare const EXTENSION_PERMISSIONS: readonly ["chats:read", "projects:re
7
7
  export type ExtensionPermission = (typeof EXTENSION_PERMISSIONS)[number];
8
8
  /** Permissions only extensions made by Odyn may ask for. */
9
9
  export declare const OFFICIAL_ONLY_PERMISSIONS: readonly ExtensionPermission[];
10
+ /**
11
+ * The extensions made by Odyn. Only these may run Mac programs or a background
12
+ * helper, and only these show as "Official". Everything else in Odyn's
13
+ * Marketplace — however it got there — follows the normal rules.
14
+ */
15
+ export declare const ODYN_EXTENSION_IDS: readonly string[];
16
+ /** Whether an extension id is one of Odyn's own. */
17
+ export declare function isOdynExtensionId(id: string): boolean;
10
18
  /** Systems an extension can say it runs on. Leave out to run everywhere. */
11
19
  export declare const EXTENSION_PLATFORMS: readonly ["darwin", "linux", "win32"];
12
20
  export type ExtensionPlatform = (typeof EXTENSION_PLATFORMS)[number];
@@ -142,6 +150,11 @@ export interface ExtensionManifest {
142
150
  readonly helper?: ExtensionHelper;
143
151
  /** A guide and permission-checked commands for the agent. */
144
152
  readonly agent?: ExtensionAgent;
153
+ /**
154
+ * GitHub usernames of the people who look after it. Changes to the
155
+ * extension in the public repo need one of them (or Odyn) to approve.
156
+ */
157
+ readonly owners?: readonly string[];
145
158
  }
146
159
  export interface ExtensionAction {
147
160
  readonly id: string;
@@ -262,9 +275,10 @@ export declare function defineExtension(definition: ExtensionDefinition): Extens
262
275
  /** Returns every problem with a manifest; an empty list means it is fine. */
263
276
  export interface ManifestCheckOptions {
264
277
  /**
265
- * Made by Odyn and shipped inside the app. An official extension may be
266
- * nothing but an on/off switch for a feature built into Odyn, so it doesn't
267
- * need a command, page, helper or agent section.
278
+ * One of Odyn's own extensions (`ODYN_EXTENSION_IDS`). These may run Mac
279
+ * programs through a background helper, and may be nothing but an on/off
280
+ * switch for a feature built into Odyn, so they don't need a command, page,
281
+ * helper or agent section.
268
282
  */
269
283
  readonly official?: boolean;
270
284
  }
@@ -490,3 +504,13 @@ export interface HelperBridge {
490
504
  }
491
505
  /** The one call a helper's script makes. Anywhere but Odyn it just hands the definition back. */
492
506
  export declare function defineHelper(definition: HelperDefinition): HelperDefinition;
507
+ export interface StarterOptions {
508
+ /** Lowercase words joined by dashes, e.g. "standup-notes". */
509
+ readonly id: string;
510
+ readonly title: string;
511
+ readonly author: string;
512
+ /** The kit version range to depend on, e.g. "^0.2.0". */
513
+ readonly kitVersion: string;
514
+ }
515
+ /** The files of a working extension with one command, keyed by path. */
516
+ export declare function starterFiles(options: StarterOptions): Readonly<Record<string, string>>;
package/dist/index.js CHANGED
@@ -33,6 +33,24 @@ export const EXTENSION_PERMISSIONS = [
33
33
  ];
34
34
  /** Permissions only extensions made by Odyn may ask for. */
35
35
  export const OFFICIAL_ONLY_PERMISSIONS = ['programs:run'];
36
+ /**
37
+ * The extensions made by Odyn. Only these may run Mac programs or a background
38
+ * helper, and only these show as "Official". Everything else in Odyn's
39
+ * Marketplace — however it got there — follows the normal rules.
40
+ */
41
+ export const ODYN_EXTENSION_IDS = [
42
+ 'agent-inboxes',
43
+ 'computer-use',
44
+ 'design-skills',
45
+ 'fast-browser',
46
+ 'message-outline',
47
+ 'phones',
48
+ 'scratchpad',
49
+ ];
50
+ /** Whether an extension id is one of Odyn's own. */
51
+ export function isOdynExtensionId(id) {
52
+ return ODYN_EXTENSION_IDS.includes(id);
53
+ }
36
54
  /** Systems an extension can say it runs on. Leave out to run everywhere. */
37
55
  export const EXTENSION_PLATFORMS = ['darwin', 'linux', 'win32'];
38
56
  /** Where an extension can show up inside Odyn. */
@@ -98,6 +116,8 @@ export function defineExtension(definition) {
98
116
  }
99
117
  // --- Checking a manifest --------------------------------------------------
100
118
  const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
119
+ /** A GitHub username: letters, digits and single dashes, up to 39 characters. */
120
+ const GITHUB_USERNAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
101
121
  const ENTRY_PATTERN = /^(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+\.html$/;
102
122
  function surfaceProblems(surfaces, apiVersion) {
103
123
  if (surfaces.length === 0)
@@ -269,7 +289,12 @@ export function validateManifest(value, options = {}) {
269
289
  problems.push(`Unknown permissions: ${unknown.join(', ')}.`);
270
290
  if (permissions.includes('network') && !(manifest.allowedHosts?.length))
271
291
  problems.push('`network` needs at least one entry in `allowedHosts`.');
292
+ const odynOnly = permissions.filter((permission) => OFFICIAL_ONLY_PERMISSIONS.includes(permission));
293
+ if (!options.official && odynOnly.length > 0)
294
+ problems.push(`\`${odynOnly.join('`, `')}\` is only for extensions made by Odyn.`);
272
295
  }
296
+ if (!options.official && manifest.helper !== undefined)
297
+ problems.push('A `helper` is only for extensions made by Odyn.');
273
298
  const surfaces = manifest.surfaces ?? [];
274
299
  if (!Array.isArray(surfaces))
275
300
  problems.push('`surfaces` must be a list.');
@@ -283,6 +308,11 @@ export function validateManifest(value, options = {}) {
283
308
  }
284
309
  }
285
310
  problems.push(...helperProblems(manifest, apiVersion), ...agentProblems(manifest, apiVersion));
311
+ if (manifest.owners !== undefined) {
312
+ if (!Array.isArray(manifest.owners) || manifest.owners.some((owner) => typeof owner !== 'string' || !GITHUB_USERNAME_PATTERN.test(owner))) {
313
+ problems.push('`owners` must be a list of GitHub usernames.');
314
+ }
315
+ }
286
316
  const hasSurfaces = Array.isArray(surfaces) && surfaces.length > 0;
287
317
  const hasHelper = manifest.helper !== undefined;
288
318
  // From version 4 a guide for the agent is enough on its own.
@@ -485,3 +515,57 @@ export function defineHelper(definition) {
485
515
  settle(null, () => definition.start(odyn));
486
516
  return definition;
487
517
  }
518
+ /** The files of a working extension with one command, keyed by path. */
519
+ export function starterFiles(options) {
520
+ const { id, title, author, kitVersion } = options;
521
+ const manifest = {
522
+ name: `odyn-extension-${id}`,
523
+ version: '1.0.0',
524
+ private: true,
525
+ type: 'module',
526
+ scripts: { dev: 'odyn-extension dev', build: 'odyn-extension build', check: 'odyn-extension check', publish: 'odyn-extension publish' },
527
+ dependencies: { '@odyn-app/api': kitVersion },
528
+ odyn: {
529
+ id,
530
+ title,
531
+ description: `${title} for Odyn.`,
532
+ author,
533
+ apiVersion: EXTENSION_API_VERSION,
534
+ permissions: [],
535
+ commands: [{ name: 'say-hello', title: 'Say hello', placement: 'sidebarPanel' }],
536
+ },
537
+ };
538
+ return {
539
+ 'package.json': `${JSON.stringify(manifest, null, 2)}\n`,
540
+ 'src/index.ts': [
541
+ "import { defineExtension } from '@odyn-app/api'",
542
+ '',
543
+ 'defineExtension({',
544
+ " commands: {",
545
+ ` 'say-hello': async () => ({ type: 'detail', markdown: ${JSON.stringify(`Hello from ${title}.`)} }),`,
546
+ ' },',
547
+ '})',
548
+ '',
549
+ ].join('\n'),
550
+ 'tsconfig.json': `${JSON.stringify({
551
+ compilerOptions: { target: 'es2022', module: 'esnext', moduleResolution: 'bundler', jsx: 'react-jsx', strict: true, noEmit: true, skipLibCheck: true, lib: ['es2022', 'dom'] },
552
+ include: ['src'],
553
+ }, null, 2)}\n`,
554
+ '.gitignore': 'node_modules\ndist\n',
555
+ 'README.md': [
556
+ `# ${title}`,
557
+ '',
558
+ 'An Odyn extension.',
559
+ '',
560
+ '```bash',
561
+ 'bun install',
562
+ 'bun run dev # rebuilds on every save; Odyn reloads it',
563
+ 'bun run publish # sends it to the Marketplace for review',
564
+ '```',
565
+ '',
566
+ 'In Odyn, open Marketplace → Extensions → Install from folder and pick this folder.',
567
+ 'Docs: https://www.odyn.build/docs/extensions',
568
+ '',
569
+ ].join('\n'),
570
+ };
571
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * One shared clock that moves every spinning orb forward a few times a second.
3
+ *
4
+ * Left to itself, a CSS animation makes the browser redraw the whole window 60
5
+ * times a second for as long as it runs. While a reply is streaming, that
6
+ * redraw cost about 40% of a processor core in testing — and one 3px orb cost
7
+ * as much as four, because the price is the redraw itself, not the dots.
8
+ *
9
+ * So the orbs' CSS animations are paused as soon as they start, and this clock
10
+ * moves them to the right moment ORB_FRAMES_PER_SECOND times a second. Each
11
+ * style keeps its exact shape and easing; only the number of redraws drops.
12
+ * Between ticks nothing changes, and the browser skips the redraw.
13
+ */
14
+ export declare const ORB_FRAMES_PER_SECOND = 12;
15
+ /** Marks an orb's outer element, so a starting animation can be traced to one. */
16
+ export declare const ORB_ROOT_ATTRIBUTE = "data-orb";
17
+ /** Present on an orb held on its opening frame; the clock leaves those alone. */
18
+ export declare const ORB_PAUSED_ATTRIBUTE = "data-orb-paused";
19
+ /** Moves every clocked animation to where it should be at `now`. */
20
+ export declare function tickOrbClock(now: number): void;
21
+ /**
22
+ * Hands these animations to the shared clock, carrying on from wherever each
23
+ * one already is. Handing over one that is already clocked does nothing.
24
+ */
25
+ export declare function clockOrbAnimations(animations: readonly Animation[], now?: number): void;
26
+ /** Takes these animations off the clock. They stay paused where they are. */
27
+ export declare function releaseOrbAnimations(animations: readonly Animation[]): void;
28
+ /**
29
+ * Called by each orb while it is on screen. The first one starts listening for
30
+ * orb animations; the last one to leave stops. Returns the matching "leave".
31
+ */
32
+ export declare function watchOrbAnimations(): () => void;
33
+ /** How many animations the clock is driving — for tests. */
34
+ export declare function clockedOrbAnimationCount(): number;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * One shared clock that moves every spinning orb forward a few times a second.
3
+ *
4
+ * Left to itself, a CSS animation makes the browser redraw the whole window 60
5
+ * times a second for as long as it runs. While a reply is streaming, that
6
+ * redraw cost about 40% of a processor core in testing — and one 3px orb cost
7
+ * as much as four, because the price is the redraw itself, not the dots.
8
+ *
9
+ * So the orbs' CSS animations are paused as soon as they start, and this clock
10
+ * moves them to the right moment ORB_FRAMES_PER_SECOND times a second. Each
11
+ * style keeps its exact shape and easing; only the number of redraws drops.
12
+ * Between ticks nothing changes, and the browser skips the redraw.
13
+ */
14
+ export const ORB_FRAMES_PER_SECOND = 12;
15
+ const ORB_FRAME_INTERVAL_MS = 1000 / ORB_FRAMES_PER_SECOND;
16
+ /** Marks an orb's outer element, so a starting animation can be traced to one. */
17
+ export const ORB_ROOT_ATTRIBUTE = 'data-orb';
18
+ /** Present on an orb held on its opening frame; the clock leaves those alone. */
19
+ export const ORB_PAUSED_ATTRIBUTE = 'data-orb-paused';
20
+ /** Each clocked animation, with when its own timeline started (`performance.now()` scale). */
21
+ const clockedAnimations = new Map();
22
+ let clockTimer = null;
23
+ let mountedOrbCount = 0;
24
+ function readCurrentTime(animation) {
25
+ const { currentTime } = animation;
26
+ return typeof currentTime === 'number' ? currentTime : 0;
27
+ }
28
+ function stopClockIfIdle() {
29
+ if (clockedAnimations.size > 0 || clockTimer === null)
30
+ return;
31
+ clearInterval(clockTimer);
32
+ clockTimer = null;
33
+ }
34
+ /** Moves every clocked animation to where it should be at `now`. */
35
+ export function tickOrbClock(now) {
36
+ for (const [animation, origin] of clockedAnimations) {
37
+ // The stylesheet removed it (the orb left the page, or reduce-motion was
38
+ // switched on), so stop driving it — setting its time would revive it.
39
+ if (animation.playState === 'idle') {
40
+ clockedAnimations.delete(animation);
41
+ continue;
42
+ }
43
+ animation.currentTime = now - origin;
44
+ }
45
+ stopClockIfIdle();
46
+ }
47
+ function tickWhenVisible() {
48
+ // A hidden window has nothing to show; the next visible tick jumps each
49
+ // animation straight to the right moment.
50
+ if (typeof document !== 'undefined' && document.hidden)
51
+ return;
52
+ tickOrbClock(performance.now());
53
+ }
54
+ /**
55
+ * Hands these animations to the shared clock, carrying on from wherever each
56
+ * one already is. Handing over one that is already clocked does nothing.
57
+ */
58
+ export function clockOrbAnimations(animations, now = performance.now()) {
59
+ for (const animation of animations) {
60
+ if (animation.playState === 'idle' || clockedAnimations.has(animation))
61
+ continue;
62
+ clockedAnimations.set(animation, now - readCurrentTime(animation));
63
+ animation.pause();
64
+ }
65
+ if (clockedAnimations.size > 0)
66
+ clockTimer ??= setInterval(tickWhenVisible, ORB_FRAME_INTERVAL_MS);
67
+ }
68
+ /** Takes these animations off the clock. They stay paused where they are. */
69
+ export function releaseOrbAnimations(animations) {
70
+ for (const animation of animations)
71
+ clockedAnimations.delete(animation);
72
+ stopClockIfIdle();
73
+ }
74
+ /**
75
+ * Some orb animations start after the orb first appears — a card opening, a
76
+ * row scrolling back into view. Catch each one the moment it starts.
77
+ */
78
+ function handleAnimationStart(event) {
79
+ const { target } = event;
80
+ if (!(target instanceof Element))
81
+ return;
82
+ if (target.closest(`[${ORB_ROOT_ATTRIBUTE}]`) === null)
83
+ return;
84
+ if (target.closest(`[${ORB_PAUSED_ATTRIBUTE}]`) !== null)
85
+ return;
86
+ clockOrbAnimations(target
87
+ .getAnimations()
88
+ .filter((animation) => animation instanceof CSSAnimation && animation.animationName === event.animationName));
89
+ }
90
+ /**
91
+ * Called by each orb while it is on screen. The first one starts listening for
92
+ * orb animations; the last one to leave stops. Returns the matching "leave".
93
+ */
94
+ export function watchOrbAnimations() {
95
+ if (typeof document === 'undefined')
96
+ return () => { };
97
+ mountedOrbCount += 1;
98
+ if (mountedOrbCount === 1)
99
+ document.addEventListener('animationstart', handleAnimationStart, true);
100
+ return () => {
101
+ mountedOrbCount -= 1;
102
+ if (mountedOrbCount === 0) {
103
+ document.removeEventListener('animationstart', handleAnimationStart, true);
104
+ }
105
+ };
106
+ }
107
+ /** How many animations the clock is driving — for tests. */
108
+ export function clockedOrbAnimationCount() {
109
+ return clockedAnimations.size;
110
+ }
@@ -1,4 +1,4 @@
1
- import type { CSSProperties, ReactElement } from 'react';
1
+ import { type CSSProperties, type ReactElement } from 'react';
2
2
  import { type OrbVariant } from './orb-types';
3
3
  export interface OrbProps {
4
4
  readonly variant?: OrbVariant;
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useRef } from 'react';
3
4
  import baseStyles from './orb-base.module.css';
4
5
  import helixStyles from './orb-helix.module.css';
5
6
  import latticeStyles from './orb-lattice.module.css';
@@ -8,6 +9,7 @@ import lensPrimaryStyles from './orb-lens-primary.module.css';
8
9
  import lensSecondaryStyles from './orb-lens-secondary.module.css';
9
10
  import morphStyles from './orb-morph.module.css';
10
11
  import ringStyles from './orb-ring.module.css';
12
+ import { clockOrbAnimations, ORB_PAUSED_ATTRIBUTE, ORB_ROOT_ATTRIBUTE, releaseOrbAnimations, watchOrbAnimations, } from './orb-frame-clock';
11
13
  import { globeDots } from './orb-helix-geometry';
12
14
  import { latticeCells } from './orb-lattice-geometry';
13
15
  import { morphDots } from './orb-morph-geometry';
@@ -23,7 +25,23 @@ function lensVariantClass(variant) {
23
25
  }
24
26
  export function Orb({ variant = 'S1', size = DEFAULT_SIZE, label, decorative = false, paused = false, className, style, }) {
25
27
  const text = label ?? `${ORB_TASKS[variant]}…`;
26
- return (_jsx("span", { className: className ? `${baseStyles.root} ${className}` : baseStyles.root, "data-orb-paused": paused ? '' : undefined, style: style, children: _jsx("span", { className: baseStyles.glyph, role: decorative ? undefined : 'img', "aria-label": decorative ? undefined : text, "aria-hidden": decorative ? true : undefined, style: { width: size, height: size, '--orb-k': size / STAGE }, children: isLattice(variant) ? (_jsx("span", { className: latticeStyles.lattice, "data-variant": variant, children: latticeCells(variant).map((cell) => (_jsx("span", { className: latticeStyles.cell, "data-still": cell.still ? '' : undefined, "data-mid": cell.mid ? '' : undefined, style: {
28
+ const rootRef = useRef(null);
29
+ // Hand the running animations to the shared low-rate clock; any that start
30
+ // later are caught as they start. A paused orb is left to the stylesheet,
31
+ // which holds it on its opening frame. A new variant brings new animations,
32
+ // so they are handed over again.
33
+ useEffect(() => {
34
+ const root = rootRef.current;
35
+ if (paused || root === null)
36
+ return;
37
+ const stopWatching = watchOrbAnimations();
38
+ clockOrbAnimations(root.getAnimations({ subtree: true }));
39
+ return () => {
40
+ stopWatching();
41
+ releaseOrbAnimations(root.getAnimations({ subtree: true }));
42
+ };
43
+ }, [paused, variant]);
44
+ return (_jsx("span", { ref: rootRef, className: className ? `${baseStyles.root} ${className}` : baseStyles.root, [ORB_ROOT_ATTRIBUTE]: '', [ORB_PAUSED_ATTRIBUTE]: paused ? '' : undefined, style: style, children: _jsx("span", { className: baseStyles.glyph, role: decorative ? undefined : 'img', "aria-label": decorative ? undefined : text, "aria-hidden": decorative ? true : undefined, style: { width: size, height: size, '--orb-k': size / STAGE }, children: isLattice(variant) ? (_jsx("span", { className: latticeStyles.lattice, "data-variant": variant, children: latticeCells(variant).map((cell) => (_jsx("span", { className: latticeStyles.cell, "data-still": cell.still ? '' : undefined, "data-mid": cell.mid ? '' : undefined, style: {
27
45
  left: cell.left,
28
46
  top: cell.top,
29
47
  animationDelay: `${cell.delay}ms`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odyn-app/api",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "The public building kit for Odyn extensions.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/New-Genre/odyn-extensions",
@@ -24,10 +24,12 @@
24
24
  "types": "./dist/ui/index.d.ts",
25
25
  "default": "./dist/ui/index.js"
26
26
  },
27
- "./theme.css": "./dist/ui/theme.css"
27
+ "./theme.css": "./dist/ui/theme.css",
28
+ "./build": "./cli/build.mjs"
28
29
  },
29
30
  "files": [
30
31
  "dist",
32
+ "cli",
31
33
  "README.md"
32
34
  ],
33
35
  "sideEffects": [
@@ -43,16 +45,21 @@
43
45
  "dependencies": {
44
46
  "class-variance-authority": "^0.7.1",
45
47
  "clsx": "^2.1.1",
46
- "tailwind-merge": "^3.6.0"
48
+ "tailwind-merge": "^3.6.0",
49
+ "@tailwindcss/postcss": "^4.3.3",
50
+ "@vitejs/plugin-react": "^6.0.4",
51
+ "tailwindcss": "^4.3.3",
52
+ "vite": "^8.1.5"
47
53
  },
48
54
  "peerDependencies": {
49
- "react": ">=19",
50
- "tailwindcss": ">=4.3"
55
+ "react": ">=19"
51
56
  },
52
57
  "devDependencies": {
53
58
  "@types/react": "^19.2.17",
54
59
  "react": "^19.2.8",
55
- "tailwindcss": "^4.3.3",
56
60
  "typescript": "^5.9.3"
61
+ },
62
+ "bin": {
63
+ "odyn-extension": "cli/odyn-extension.mjs"
57
64
  }
58
65
  }