@odyn-app/api 0.0.0-stage → 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.
Files changed (42) hide show
  1. package/README.md +35 -2
  2. package/cli/build.mjs +146 -0
  3. package/cli/github.mjs +190 -0
  4. package/cli/odyn-extension.mjs +310 -0
  5. package/dist/index.d.ts +516 -0
  6. package/dist/index.js +571 -0
  7. package/dist/ui/button.d.ts +10 -0
  8. package/dist/ui/button.js +31 -0
  9. package/dist/ui/cn.d.ts +3 -0
  10. package/dist/ui/cn.js +6 -0
  11. package/dist/ui/index.d.ts +10 -0
  12. package/dist/ui/index.js +10 -0
  13. package/dist/ui/orb/orb-base.module.css +21 -0
  14. package/dist/ui/orb/orb-frame-clock.d.ts +34 -0
  15. package/dist/ui/orb/orb-frame-clock.js +110 -0
  16. package/dist/ui/orb/orb-geometry-cache.d.ts +1 -0
  17. package/dist/ui/orb/orb-geometry-cache.js +11 -0
  18. package/dist/ui/orb/orb-helix-geometry.d.ts +7 -0
  19. package/dist/ui/orb/orb-helix-geometry.js +130 -0
  20. package/dist/ui/orb/orb-helix.module.css +155 -0
  21. package/dist/ui/orb/orb-lattice-geometry.d.ts +35 -0
  22. package/dist/ui/orb/orb-lattice-geometry.js +148 -0
  23. package/dist/ui/orb/orb-lattice.module.css +165 -0
  24. package/dist/ui/orb/orb-lens-primary.module.css +195 -0
  25. package/dist/ui/orb/orb-lens-secondary.module.css +143 -0
  26. package/dist/ui/orb/orb-lens.module.css +44 -0
  27. package/dist/ui/orb/orb-morph-geometry.d.ts +7 -0
  28. package/dist/ui/orb/orb-morph-geometry.js +88 -0
  29. package/dist/ui/orb/orb-morph.module.css +62 -0
  30. package/dist/ui/orb/orb-ring-geometry.d.ts +8 -0
  31. package/dist/ui/orb/orb-ring-geometry.js +42 -0
  32. package/dist/ui/orb/orb-ring.module.css +113 -0
  33. package/dist/ui/orb/orb-types.d.ts +23 -0
  34. package/dist/ui/orb/orb-types.js +48 -0
  35. package/dist/ui/orb/orb.d.ts +17 -0
  36. package/dist/ui/orb/orb.js +58 -0
  37. package/dist/ui/setting-row.d.ts +9 -0
  38. package/dist/ui/setting-row.js +9 -0
  39. package/dist/ui/skeleton.d.ts +3 -0
  40. package/dist/ui/skeleton.js +6 -0
  41. package/dist/ui/theme.css +232 -0
  42. package/package.json +63 -4
package/README.md CHANGED
@@ -1,3 +1,36 @@
1
- # Temporary Holding Version
1
+ # @odyn-app/api
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The building kit for [Odyn](https://github.com/New-Genre/odyn-extensions) extensions.
4
+
5
+ Start a new extension, work on it, and publish it:
6
+
7
+ ```bash
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
13
+ ```
14
+
15
+ Your extension's entry file:
16
+
17
+ ```ts
18
+ import { defineExtension } from '@odyn-app/api'
19
+
20
+ defineExtension({
21
+ commands: {
22
+ 'say-hello': async () => ({ type: 'detail', markdown: 'Hello from my extension.' }),
23
+ },
24
+ })
25
+ ```
26
+
27
+ - `odyn-extension`: the command-line tool above — `create`, `dev`, `build`, `check`, `pull <id>` and `publish`.
28
+ - `@odyn-app/api`: `defineExtension`, the `odyn` bridge, the manifest types and the manifest checker.
29
+ - `@odyn-app/api/ui`: Odyn's `Button`, `Skeleton`, `SettingRow`, `Orb` and `cn`, for extension pages.
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.
31
+
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).
35
+
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
+