@odyn-app/api 0.1.0 → 0.2.0
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 +13 -2
- package/cli/build.mjs +146 -0
- package/cli/github.mjs +190 -0
- package/cli/odyn-extension.mjs +310 -0
- package/dist/index.d.ts +27 -3
- package/dist/index.js +84 -0
- package/dist/ui/orb/orb-frame-clock.d.ts +34 -0
- package/dist/ui/orb/orb-frame-clock.js +110 -0
- package/dist/ui/orb/orb.d.ts +1 -1
- package/dist/ui/orb/orb.js +19 -1
- package/package.json +13 -6
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
|
-
|
|
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.
|
|
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,190 @@
|
|
|
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. Empty until the
|
|
21
|
+
* app exists; until then `gh auth login` or `GITHUB_TOKEN` is used.
|
|
22
|
+
*/
|
|
23
|
+
const ODYN_GITHUB_CLIENT_ID = ''
|
|
24
|
+
const GITHUB_CLIENT_ID = process.env.ODYN_GITHUB_CLIENT_ID?.trim() || ODYN_GITHUB_CLIENT_ID
|
|
25
|
+
const TOKEN_FILE = join(homedir(), '.config', 'odyn-extension', 'github.json')
|
|
26
|
+
const FORK_READY_ATTEMPTS = 20
|
|
27
|
+
const FORK_READY_DELAY_MS = 3000
|
|
28
|
+
|
|
29
|
+
const sleep = (ms) => new Promise((done) => setTimeout(done, ms))
|
|
30
|
+
|
|
31
|
+
/** Opens a web page in the user's browser. Best effort. */
|
|
32
|
+
function openInBrowser(url) {
|
|
33
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
|
|
34
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
|
|
35
|
+
try {
|
|
36
|
+
spawn(command, args, { stdio: 'ignore', detached: true }).unref()
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.warn(`Couldn't open the browser (${error instanceof Error ? error.message : String(error)}). Open ${url} yourself.`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readSavedToken() {
|
|
43
|
+
if (!existsSync(TOKEN_FILE)) return null
|
|
44
|
+
try {
|
|
45
|
+
const saved = JSON.parse(readFileSync(TOKEN_FILE, 'utf8'))
|
|
46
|
+
return typeof saved.token === 'string' && saved.token !== '' ? saved.token : null
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.warn(`Ignoring an unreadable saved sign-in: ${error instanceof Error ? error.message : String(error)}`)
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function saveToken(token) {
|
|
54
|
+
mkdirSync(dirname(TOKEN_FILE), { recursive: true })
|
|
55
|
+
writeFileSync(TOKEN_FILE, `${JSON.stringify({ token })}\n`, { mode: 0o600 })
|
|
56
|
+
chmodSync(TOKEN_FILE, 0o600)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The GitHub CLI's token, when it is installed and signed in. */
|
|
60
|
+
function ghCliToken() {
|
|
61
|
+
try {
|
|
62
|
+
const token = execFileSync('gh', ['auth', 'token'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
63
|
+
return token === '' ? null : token
|
|
64
|
+
} catch {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Signs in through the browser: shows a short code, the user approves it on github.com. */
|
|
70
|
+
async function deviceSignIn() {
|
|
71
|
+
if (GITHUB_CLIENT_ID === '') {
|
|
72
|
+
throw new Error('Sign in to GitHub first: install the GitHub CLI and run `gh auth login`, or set GITHUB_TOKEN.')
|
|
73
|
+
}
|
|
74
|
+
const start = await fetch('https://github.com/login/device/code', {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
77
|
+
body: JSON.stringify({ client_id: GITHUB_CLIENT_ID, scope: 'public_repo' }),
|
|
78
|
+
})
|
|
79
|
+
const code = await start.json()
|
|
80
|
+
if (!start.ok || typeof code.device_code !== 'string') {
|
|
81
|
+
throw new Error(`GitHub sign-in couldn't start: ${code.error_description ?? code.error ?? start.status}`)
|
|
82
|
+
}
|
|
83
|
+
console.log(`\nSign in to GitHub: enter the code ${code.user_code} at ${code.verification_uri}`)
|
|
84
|
+
openInBrowser(code.verification_uri)
|
|
85
|
+
let interval = (code.interval ?? 5) * 1000
|
|
86
|
+
const deadline = Date.now() + (code.expires_in ?? 900) * 1000
|
|
87
|
+
while (Date.now() < deadline) {
|
|
88
|
+
await sleep(interval)
|
|
89
|
+
const poll = await fetch('https://github.com/login/oauth/access_token', {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { accept: 'application/json', 'content-type': 'application/json' },
|
|
92
|
+
body: JSON.stringify({ client_id: GITHUB_CLIENT_ID, device_code: code.device_code, grant_type: 'urn:ietf:params:oauth:grant-type:device_code' }),
|
|
93
|
+
})
|
|
94
|
+
const result = await poll.json()
|
|
95
|
+
if (typeof result.access_token === 'string') return result.access_token
|
|
96
|
+
if (result.error === 'slow_down') interval += 5000
|
|
97
|
+
else if (result.error !== 'authorization_pending') throw new Error(`GitHub sign-in failed: ${result.error_description ?? result.error}`)
|
|
98
|
+
}
|
|
99
|
+
throw new Error('GitHub sign-in timed out. Run the command again.')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A GitHub token: `GITHUB_TOKEN`, then a saved sign-in, then the GitHub CLI,
|
|
104
|
+
* and finally a fresh sign-in through the browser.
|
|
105
|
+
*/
|
|
106
|
+
export async function getToken() {
|
|
107
|
+
const fromEnv = process.env.GITHUB_TOKEN?.trim()
|
|
108
|
+
if (fromEnv) return fromEnv
|
|
109
|
+
const saved = readSavedToken()
|
|
110
|
+
if (saved) return saved
|
|
111
|
+
const fromCli = ghCliToken()
|
|
112
|
+
if (fromCli) return fromCli
|
|
113
|
+
const fresh = await deviceSignIn()
|
|
114
|
+
saveToken(fresh)
|
|
115
|
+
return fresh
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Forgets the saved sign-in, e.g. after GitHub rejects it. */
|
|
119
|
+
export function forgetToken() {
|
|
120
|
+
if (existsSync(TOKEN_FILE)) writeFileSync(TOKEN_FILE, '{}\n', { mode: 0o600 })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A small GitHub API client. Throws a readable error on any failure. */
|
|
124
|
+
export function githubClient(token) {
|
|
125
|
+
return async function request(method, path, body) {
|
|
126
|
+
const response = await fetch(`${API}${path}`, {
|
|
127
|
+
method,
|
|
128
|
+
headers: {
|
|
129
|
+
accept: 'application/vnd.github+json',
|
|
130
|
+
'x-github-api-version': '2022-11-28',
|
|
131
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
132
|
+
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
133
|
+
},
|
|
134
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
135
|
+
})
|
|
136
|
+
if (response.status === 204) return null
|
|
137
|
+
const text = await response.text()
|
|
138
|
+
const data = text === '' ? null : JSON.parse(text)
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
const error = new Error(`GitHub said ${response.status} for ${method} ${path}: ${data?.message ?? text}`)
|
|
141
|
+
error.status = response.status
|
|
142
|
+
throw error
|
|
143
|
+
}
|
|
144
|
+
return data
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Every file in the public repo's extension folder, as `{ path, sha }` relative to it. */
|
|
149
|
+
export async function upstreamFiles(request, extensionId) {
|
|
150
|
+
const branch = await request('GET', `/repos/${UPSTREAM_OWNER}/${UPSTREAM_REPO}/branches/${UPSTREAM_BRANCH}`)
|
|
151
|
+
const tree = await request('GET', `/repos/${UPSTREAM_OWNER}/${UPSTREAM_REPO}/git/trees/${branch.commit.sha}?recursive=1`)
|
|
152
|
+
const prefix = `extensions/${extensionId}/`
|
|
153
|
+
const files = tree.tree
|
|
154
|
+
.filter((entry) => entry.type === 'blob' && entry.path.startsWith(prefix))
|
|
155
|
+
.map((entry) => ({ path: entry.path.slice(prefix.length), sha: entry.sha }))
|
|
156
|
+
return { commitSha: branch.commit.sha, treeSha: branch.commit.commit.tree.sha, files }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Makes sure the user has their own copy of the public repo, up to date. Returns its owner. */
|
|
160
|
+
export async function ensureFork(request, login) {
|
|
161
|
+
try {
|
|
162
|
+
await request('GET', `/repos/${login}/${UPSTREAM_REPO}`)
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (error.status !== 404) throw error
|
|
165
|
+
console.log(`Making your copy of ${UPSTREAM_OWNER}/${UPSTREAM_REPO} on GitHub…`)
|
|
166
|
+
await request('POST', `/repos/${UPSTREAM_OWNER}/${UPSTREAM_REPO}/forks`, { default_branch_only: true })
|
|
167
|
+
for (let attempt = 0; attempt < FORK_READY_ATTEMPTS; attempt += 1) {
|
|
168
|
+
await sleep(FORK_READY_DELAY_MS)
|
|
169
|
+
try {
|
|
170
|
+
await request('GET', `/repos/${login}/${UPSTREAM_REPO}/branches/${UPSTREAM_BRANCH}`)
|
|
171
|
+
return login
|
|
172
|
+
} catch (pollError) {
|
|
173
|
+
if (pollError.status !== 404 && pollError.status !== 409) throw pollError
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
throw new Error('GitHub is still making your copy of the repo. Try again in a minute.')
|
|
177
|
+
}
|
|
178
|
+
await request('POST', `/repos/${login}/${UPSTREAM_REPO}/merge-upstream`, { branch: UPSTREAM_BRANCH }).catch((error) => {
|
|
179
|
+
console.warn(`Couldn't bring your copy up to date first (${error.message}). Carrying on.`)
|
|
180
|
+
})
|
|
181
|
+
return login
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Downloads one file of the public repo's main branch. */
|
|
185
|
+
export async function downloadUpstreamFile(path) {
|
|
186
|
+
const url = `https://raw.githubusercontent.com/${UPSTREAM_OWNER}/${UPSTREAM_REPO}/${UPSTREAM_BRANCH}/${path.split('/').map(encodeURIComponent).join('/')}`
|
|
187
|
+
const response = await fetch(url)
|
|
188
|
+
if (!response.ok) throw new Error(`Couldn't download ${path} (${response.status}).`)
|
|
189
|
+
return Buffer.from(await response.arrayBuffer())
|
|
190
|
+
}
|
|
@@ -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
|
-
*
|
|
266
|
-
*
|
|
267
|
-
* need a command, page,
|
|
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
|
+
}
|
package/dist/ui/orb/orb.d.ts
CHANGED
package/dist/ui/orb/orb.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
}
|