@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.
- package/README.md +35 -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 +516 -0
- package/dist/index.js +571 -0
- package/dist/ui/button.d.ts +10 -0
- package/dist/ui/button.js +31 -0
- package/dist/ui/cn.d.ts +3 -0
- package/dist/ui/cn.js +6 -0
- package/dist/ui/index.d.ts +10 -0
- package/dist/ui/index.js +10 -0
- package/dist/ui/orb/orb-base.module.css +21 -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-geometry-cache.d.ts +1 -0
- package/dist/ui/orb/orb-geometry-cache.js +11 -0
- package/dist/ui/orb/orb-helix-geometry.d.ts +7 -0
- package/dist/ui/orb/orb-helix-geometry.js +130 -0
- package/dist/ui/orb/orb-helix.module.css +155 -0
- package/dist/ui/orb/orb-lattice-geometry.d.ts +35 -0
- package/dist/ui/orb/orb-lattice-geometry.js +148 -0
- package/dist/ui/orb/orb-lattice.module.css +165 -0
- package/dist/ui/orb/orb-lens-primary.module.css +195 -0
- package/dist/ui/orb/orb-lens-secondary.module.css +143 -0
- package/dist/ui/orb/orb-lens.module.css +44 -0
- package/dist/ui/orb/orb-morph-geometry.d.ts +7 -0
- package/dist/ui/orb/orb-morph-geometry.js +88 -0
- package/dist/ui/orb/orb-morph.module.css +62 -0
- package/dist/ui/orb/orb-ring-geometry.d.ts +8 -0
- package/dist/ui/orb/orb-ring-geometry.js +42 -0
- package/dist/ui/orb/orb-ring.module.css +113 -0
- package/dist/ui/orb/orb-types.d.ts +23 -0
- package/dist/ui/orb/orb-types.js +48 -0
- package/dist/ui/orb/orb.d.ts +17 -0
- package/dist/ui/orb/orb.js +58 -0
- package/dist/ui/setting-row.d.ts +9 -0
- package/dist/ui/setting-row.js +9 -0
- package/dist/ui/skeleton.d.ts +3 -0
- package/dist/ui/skeleton.js +6 -0
- package/dist/ui/theme.css +232 -0
- package/package.json +63 -4
package/README.md
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @odyn-app/api
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
|