@ynput/ayon-addon-dev 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/AGENTS.md +57 -6
- package/dist/browse.js +2 -2
- package/dist/cli.js +97 -43
- package/dist/devChecks.d.ts +24 -0
- package/dist/devChecks.js +160 -0
- package/dist/project.d.ts +17 -0
- package/dist/project.js +120 -2
- package/dist/rest.js +10 -5
- package/dist/shell/index.js +91 -56
- package/package.json +4 -4
package/AGENTS.md
CHANGED
|
@@ -28,7 +28,7 @@ Run in the addon's frontend folder. Every command takes `--json` for scripts and
|
|
|
28
28
|
| Command | What |
|
|
29
29
|
|---|---|
|
|
30
30
|
| `ayon-addon dev [--port 5174]` | The dev shell (below): the addon in AYON's surfaces, without AYON, with hot reload |
|
|
31
|
-
| `ayon-addon dev --ayon [--port 5173]` | Builds on every change and serves the build with CORS.
|
|
31
|
+
| `ayon-addon dev --ayon [--port 5173] [--open] [--frontend <url>]` | Builds on every change and serves the build with CORS. Checks what AYON's dev override needs on the server, then prints (`--open`: opens) the AYON URL that loads it: `<server>/?addonDev=<addon>@http://localhost:5173` |
|
|
32
32
|
| `ayon-addon slots` | The slots of the installed `@ynput/ayon-addon`: helper, where, when the code loads |
|
|
33
33
|
| `ayon-addon check [--no-types]` | The manifest as AYON reads it (slots, ids, fields, pieces), that each piece's file exists, and `tsc` |
|
|
34
34
|
| `ayon-addon open <slot> <id> [--project P] [--entities type:id,...] [--page overview\|products\|lists] [--run] [--screenshot file] [--save-fixture name]` | One contribution in the dev shell in a headless browser, against the server. Reports error boxes, console errors, toasts, the context it got, the clipboard after `--run` (menu items). Exit code 1 when it failed |
|
|
@@ -41,13 +41,57 @@ chromium`); in the AYON dev workbench the ayon-frontend checkout's copy is used.
|
|
|
41
41
|
|
|
42
42
|
## Developing inside AYON (`dev --ayon`)
|
|
43
43
|
|
|
44
|
-
1. `ayon-addon dev --ayon` in the addon.
|
|
45
|
-
2.
|
|
46
|
-
|
|
44
|
+
1. `ayon-addon dev --ayon --open` in the addon (`yarn dev:ayon` in the template and the time tracker).
|
|
45
|
+
2. It checks the server, builds, and opens the URL it prints (without `--open`, open it yourself).
|
|
46
|
+
The page loads your addon from the dev server instead of the installed version (also when it
|
|
47
|
+
isn't installed) and shows a banner. It reloads after every rebuild.
|
|
47
48
|
3. `?addonDev=off`, or the banner's close button, goes back to the installed addon.
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
- `--frontend http://localhost:3000` puts that page in the URL instead of the server (an
|
|
51
|
+
ayon-frontend dev server in front of it).
|
|
52
|
+
- `--port` is the addon's dev server port (5173 by default; a port in use stops it with an error).
|
|
53
|
+
- With `--json` it prints one object once the first build is done: `url`, `addon`, `remote`,
|
|
54
|
+
`buildId`, `open` (the URL), `ok` (no check failed with `error`) and `checks`
|
|
55
|
+
(`{ id, ok, severity, message, fix }`).
|
|
56
|
+
|
|
57
|
+
What it checks against the server (`AYON_SERVER_URL` / `AYON_API_KEY`, or the workbench's local
|
|
58
|
+
stack and key), each failure with how to fix it:
|
|
59
|
+
|
|
60
|
+
| Check | Fails when |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `server` | `/api/info` doesn't answer 200. Nothing else is checked. |
|
|
63
|
+
| `apiKey` | No key: the user and the installed versions can't be checked (warning). |
|
|
64
|
+
| `user` | The key is rejected (error), or the key's user is neither a service user nor in developer mode (warning: a headless browser signed in with it ignores the override). Your own browser user is not known to the CLI: it needs developer mode. |
|
|
65
|
+
| `addonVersion` | The `versionModule` (`src/addonVersion.ts` unless `ayonAddon({ rest })` names another) and `package.py` name different versions (warning): the frontend calls the endpoints of the former. |
|
|
66
|
+
| `addon` | The addon isn't installed in the version the frontend calls, or that version failed to load on the server. The page still loads the frontend from the dev server, but its endpoint calls (`/api/addons/<name>/<version>/...`) answer 404. An error when the addon has a `server/` folder, else a warning. |
|
|
67
|
+
|
|
68
|
+
The checks never stop the dev server: fix them while it runs.
|
|
69
|
+
|
|
70
|
+
### Why isn't my addon loading?
|
|
71
|
+
|
|
72
|
+
The page always says why in the banner at the bottom left (also in the console). What it says, and
|
|
73
|
+
the fix:
|
|
74
|
+
|
|
75
|
+
- **ignored ... only users in developer mode**: turn on Developer Mode in AYON's header (a user
|
|
76
|
+
who doesn't have the switch needs Developer turned on by an admin in Studio settings > Users),
|
|
77
|
+
then reload. Service users (API keys) always may.
|
|
78
|
+
- **ignored ... is not this machine / is not a URL / use ?addonDev=...**: the parameter must be
|
|
79
|
+
`<addon>@http://localhost:<port>` (or `127.0.0.1`, `[::1]`): a link can't load code from another
|
|
80
|
+
machine.
|
|
81
|
+
- **could not load ... no answer from**: the dev server isn't running on that port (or was
|
|
82
|
+
stopped). The page reloads once it answers.
|
|
83
|
+
- **could not load ... has no /ayon-addon.json**: something else runs on that port (the dev shell,
|
|
84
|
+
`ayon-addon dev` without `--ayon`), or the first build hasn't finished. The page reloads once it
|
|
85
|
+
answers.
|
|
86
|
+
- **could not load ... serves "x", not "y"**: the name in the URL isn't the addon's name in
|
|
87
|
+
`package.py`; use the URL the CLI prints.
|
|
88
|
+
- **its manifest failed to load**: the build is broken or the remote didn't start: see the
|
|
89
|
+
console and the CLI's output; the page reloads after the next build.
|
|
90
|
+
- **The addon loads but its requests answer 404**: the server doesn't have the addon in the
|
|
91
|
+
version the frontend calls. `ayon-addon dev --ayon` reports it (`addon` check); install that
|
|
92
|
+
version.
|
|
93
|
+
- **No banner at all**: the page isn't a frontend with the dev override (an older AYON), or
|
|
94
|
+
`?addonDev=off` cleared it.
|
|
51
95
|
|
|
52
96
|
## Developing without AYON: the dev shell (`dev`, the default)
|
|
53
97
|
|
|
@@ -125,6 +169,13 @@ queries), `generated`, `api`, `versionModule`, `schema` (paths).
|
|
|
125
169
|
workbench the tools find the local stack and its key (`data/local-api-key`) on their own.
|
|
126
170
|
- `AYON_SERVER_URL` points the tools at another server (default `http://localhost:5000`, or the
|
|
127
171
|
workbench's local stack).
|
|
172
|
+
- Both can go in the addon frontend's `.env.local` or `.env` (keep them out of git): every
|
|
173
|
+
command loads them at start, `.env.local` first. Variables already set in the shell win.
|
|
174
|
+
- With no key, a command stops and says where to get one (Settings → Users → Add Service User).
|
|
175
|
+
In a terminal, with no env file yet, it walks through writing `.env`: the server (default
|
|
176
|
+
`http://localhost:5000`), then the key, typed without echo (Enter leaves it for later). A key
|
|
177
|
+
given there lets the command go on; without one it stops after writing the file. The file
|
|
178
|
+
comes from `envFileContents` in `addon-dev/src/project.ts`.
|
|
128
179
|
|
|
129
180
|
## Lint preset
|
|
130
181
|
|
package/dist/browse.js
CHANGED
|
@@ -5,7 +5,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
|
5
5
|
import { createRequire } from 'module';
|
|
6
6
|
import path from 'path';
|
|
7
7
|
import { pathToFileURL } from 'url';
|
|
8
|
-
import { findConnection, importFromProject } from './project.js';
|
|
8
|
+
import { findConnection, importFromProject, keySetupMessage } from './project.js';
|
|
9
9
|
import { STANDALONE_ENV } from './standalone.js';
|
|
10
10
|
// the addon's playwright, else the one next to this package (the ayon-frontend checkout)
|
|
11
11
|
const loadPlaywright = async (root) => {
|
|
@@ -38,7 +38,7 @@ const startServer = async (root) => {
|
|
|
38
38
|
export const browse = async (options) => {
|
|
39
39
|
const mode = options.fixture ? 'preview' : 'live';
|
|
40
40
|
if (mode === 'live' && !options.url && !findConnection(options.root).apiKey) {
|
|
41
|
-
throw new Error(
|
|
41
|
+
throw new Error(keySetupMessage(options.root));
|
|
42
42
|
}
|
|
43
43
|
const server = options.url ? undefined : await startServer(options.root);
|
|
44
44
|
const base = options.url || server.resolvedUrls.local[0];
|
package/dist/cli.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// ayon-addon: dev server and checks for AYON addon frontends. Every command prints JSON with --json.
|
|
3
3
|
// ayon-addon dev [--port 5174] the dev shell: the addon in AYON's surfaces, hot reload
|
|
4
|
-
// ayon-addon dev --ayon [--port 5173] watched build + server for
|
|
4
|
+
// ayon-addon dev --ayon [--port 5173] [--open] [--frontend <url>] watched build + server for
|
|
5
|
+
// AYON's dev override (?addonDev=), checks what it needs on the server
|
|
5
6
|
// ayon-addon slots [--json] the slots of the installed @ynput/ayon-addon
|
|
6
7
|
// ayon-addon check [--json] [--no-types]
|
|
7
8
|
// ayon-addon open <slot> <id> the dev shell in a headless browser (live server)
|
|
8
9
|
// ayon-addon preview <slot> <id> --fixture <name> the same from a fixture, no server
|
|
9
10
|
// ayon-addon generate-rest the addon's server endpoints as RTK Query endpoints
|
|
11
|
+
import { spawn } from 'child_process';
|
|
10
12
|
import { createReadStream, existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
11
13
|
import { createServer } from 'http';
|
|
12
14
|
import path from 'path';
|
|
13
15
|
import { browse, formatBrowse } from './browse.js';
|
|
14
16
|
import { checkAddon, formatCheck } from './check.js';
|
|
15
|
-
import {
|
|
17
|
+
import { checkDevRequirements, formatChecks } from './devChecks.js';
|
|
18
|
+
import { findConnection, findPackageDir, importFromProject, loadProjectEnv, readAddonProject, } from './project.js';
|
|
16
19
|
import { generateRest } from './rest.js';
|
|
17
20
|
import { STANDALONE_ENV } from './standalone.js';
|
|
18
21
|
const argv = process.argv.slice(2);
|
|
@@ -25,6 +28,7 @@ const option = (name, fallback) => {
|
|
|
25
28
|
const json = flag('json');
|
|
26
29
|
const print = (value, text) => console.log(json ? JSON.stringify(value, null, 2) : text);
|
|
27
30
|
const root = process.cwd();
|
|
31
|
+
loadProjectEnv(root);
|
|
28
32
|
const CONTENT_TYPES = {
|
|
29
33
|
'.js': 'text/javascript',
|
|
30
34
|
'.mjs': 'text/javascript',
|
|
@@ -36,26 +40,45 @@ const CONTENT_TYPES = {
|
|
|
36
40
|
'.png': 'image/png',
|
|
37
41
|
'.woff2': 'font/woff2',
|
|
38
42
|
};
|
|
39
|
-
// serves the build with CORS: AYON (any origin, also https) loads the remote from here
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
43
|
+
// serves the build with CORS: AYON (any origin, also https) loads the remote from here. `dir()` is
|
|
44
|
+
// undefined until the first build: 404 until then, which the page shows and waits out.
|
|
45
|
+
const serveDirectory = (dir, port) => new Promise((resolve, reject) => {
|
|
46
|
+
const server = createServer((request, response) => {
|
|
47
|
+
response.setHeader('Access-Control-Allow-Origin', '*');
|
|
48
|
+
// agents' headless browsers send x-api-key on every request, which makes these preflighted
|
|
49
|
+
response.setHeader('Access-Control-Allow-Headers', '*');
|
|
50
|
+
response.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
51
|
+
response.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
52
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
53
|
+
if (request.method === 'OPTIONS')
|
|
54
|
+
return void response.writeHead(204).end();
|
|
55
|
+
const root = dir();
|
|
56
|
+
const urlPath = decodeURIComponent((request.url || '/').split('?')[0]);
|
|
57
|
+
const file = root && path.join(root, path.normalize(urlPath).replace(/^(\.\.[/\\])+/, ''));
|
|
58
|
+
if (!root || !file || !file.startsWith(root) || !existsSync(file) || !statSync(file).isFile()) {
|
|
59
|
+
return void response.writeHead(404).end('not found');
|
|
60
|
+
}
|
|
61
|
+
response.writeHead(200, {
|
|
62
|
+
'Content-Type': CONTENT_TYPES[path.extname(file)] || 'application/octet-stream',
|
|
63
|
+
});
|
|
64
|
+
createReadStream(file).pipe(response);
|
|
56
65
|
});
|
|
57
|
-
|
|
58
|
-
})
|
|
66
|
+
server.once('error', (error) => reject(error.code === 'EADDRINUSE'
|
|
67
|
+
? new Error(`port ${port} is in use (another \`ayon-addon dev\`?): pick another with --port`)
|
|
68
|
+
: error));
|
|
69
|
+
server.listen(port, () => resolve(server));
|
|
70
|
+
});
|
|
71
|
+
// the system's browser
|
|
72
|
+
const openInBrowser = (url) => {
|
|
73
|
+
const [cmd, ...args] = process.platform === 'darwin'
|
|
74
|
+
? ['open', url]
|
|
75
|
+
: process.platform === 'win32'
|
|
76
|
+
? ['cmd', '/c', 'start', '""', url]
|
|
77
|
+
: ['xdg-open', url];
|
|
78
|
+
spawn(cmd, args, { stdio: 'ignore', detached: true })
|
|
79
|
+
.on('error', () => console.error(`could not open a browser: open ${url} yourself`))
|
|
80
|
+
.unref();
|
|
81
|
+
};
|
|
59
82
|
// the build's dev info (dist/modules/<remote>/ayon-addon.json), written by ayonAddon()
|
|
60
83
|
const findDevInfo = () => {
|
|
61
84
|
const modules = path.join(root, 'dist', 'modules');
|
|
@@ -64,7 +87,7 @@ const findDevInfo = () => {
|
|
|
64
87
|
const file = readdirSync(modules)
|
|
65
88
|
.map((name) => path.join(modules, name, 'ayon-addon.json'))
|
|
66
89
|
.find((candidate) => existsSync(candidate));
|
|
67
|
-
return file
|
|
90
|
+
return file ? { dir: path.dirname(file), info: JSON.parse(readFileSync(file, 'utf8')) } : undefined;
|
|
68
91
|
};
|
|
69
92
|
// the Vite dev server with the dev shell (ayonAddon()'s standalone plugin)
|
|
70
93
|
const devStandalone = async () => {
|
|
@@ -90,21 +113,35 @@ const dev = async () => {
|
|
|
90
113
|
return devStandalone();
|
|
91
114
|
const port = Number(option('port', '5173'));
|
|
92
115
|
const project = readAddonProject(root);
|
|
116
|
+
const connection = findConnection(root);
|
|
93
117
|
const vite = await importFromProject(root, 'vite');
|
|
118
|
+
let built;
|
|
119
|
+
// listening before the build: a page opened early waits for it instead of failing to connect
|
|
120
|
+
const server = await serveDirectory(() => built?.dir, port);
|
|
121
|
+
// what the override needs on the server, checked while the first build runs
|
|
122
|
+
const versionModule = (await readRestOptions().catch(() => ({}))).versionModule;
|
|
123
|
+
const checks = checkDevRequirements({ project, connection, versionModule });
|
|
94
124
|
process.env.AYON_ADDON_DEV = '1';
|
|
95
125
|
let served = false;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
126
|
+
let watcher;
|
|
127
|
+
try {
|
|
128
|
+
watcher = (await vite.build({
|
|
129
|
+
root,
|
|
130
|
+
mode: 'development',
|
|
131
|
+
build: { watch: {} },
|
|
132
|
+
logLevel: 'warn',
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
server.close();
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
102
139
|
watcher.on('event', (event) => {
|
|
103
140
|
if (event.code === 'ERROR')
|
|
104
141
|
console.error(event.error);
|
|
105
142
|
if (event.code !== 'END')
|
|
106
143
|
return;
|
|
107
|
-
|
|
144
|
+
built = findDevInfo();
|
|
108
145
|
if (!built) {
|
|
109
146
|
console.error('no ayon-addon.json in dist/modules: is ayonAddon() in vite.config.ts?');
|
|
110
147
|
return;
|
|
@@ -115,15 +152,25 @@ const dev = async () => {
|
|
|
115
152
|
return;
|
|
116
153
|
}
|
|
117
154
|
served = true;
|
|
118
|
-
|
|
155
|
+
const { info } = built;
|
|
119
156
|
const url = `http://localhost:${port}`;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
157
|
+
// the page that loads it: AYON itself, or a frontend dev server in front of it (--frontend)
|
|
158
|
+
const page = (option('frontend') || connection.serverUrl).replace(/\/+$/, '');
|
|
159
|
+
const open = `${page}/?addonDev=${project.name}@${url}`;
|
|
160
|
+
void checks.then((results) => {
|
|
161
|
+
const ok = results.every((check) => check.ok || check.severity !== 'error');
|
|
162
|
+
print({ url, addon: project.name, remote: info.remote, buildId: info.buildId, open, ok, checks: results }, [
|
|
163
|
+
`${project.name} ${project.version}: built, serving ${url} (remote "${info.remote}")`,
|
|
164
|
+
`Checks against ${connection.serverUrl}:`,
|
|
165
|
+
formatChecks(results),
|
|
166
|
+
'Open AYON with this addon from here (your user needs developer mode on):',
|
|
167
|
+
` ${open}`,
|
|
168
|
+
'The page reloads after every rebuild. If it shows the installed addon, its banner says why.',
|
|
169
|
+
'?addonDev=off goes back to the installed addon.',
|
|
170
|
+
].join('\n'));
|
|
171
|
+
if (flag('open'))
|
|
172
|
+
openInBrowser(open);
|
|
173
|
+
});
|
|
127
174
|
});
|
|
128
175
|
};
|
|
129
176
|
const slots = async () => {
|
|
@@ -166,7 +213,7 @@ const generateRestCommand = async () => {
|
|
|
166
213
|
print(result, '');
|
|
167
214
|
};
|
|
168
215
|
// the arguments that aren't flags or flag values
|
|
169
|
-
const BOOLEAN_FLAGS = ['--json', '--run', '--no-types', '--standalone', '--ayon', '--prune', '--hooks'];
|
|
216
|
+
const BOOLEAN_FLAGS = ['--json', '--run', '--no-types', '--standalone', '--ayon', '--prune', '--hooks', '--open'];
|
|
170
217
|
const positional = () => {
|
|
171
218
|
const values = [];
|
|
172
219
|
for (let i = 1; i < argv.length; i++) {
|
|
@@ -207,8 +254,12 @@ const HELP = `ayon-addon: tools for AYON addon frontends (run in the addon's fro
|
|
|
207
254
|
dev [--port 5174] the dev shell: the addon's contributions in AYON's surfaces,
|
|
208
255
|
without AYON, hot reload, a developer panel to pick slot,
|
|
209
256
|
project, entities
|
|
210
|
-
dev --ayon [--port 5173]
|
|
211
|
-
|
|
257
|
+
dev --ayon [--port 5173] [--open] [--frontend <url>]
|
|
258
|
+
build on every change and serve it for AYON's dev override;
|
|
259
|
+
checks the server (reachable, the API key's user, the addon
|
|
260
|
+
installed in the version the frontend calls) and prints the
|
|
261
|
+
URL that loads it, <server or --frontend>/?addonDev=<addon>@
|
|
262
|
+
http://localhost:5173 (--open: opens it)
|
|
212
263
|
slots [--json] the slots of the installed @ynput/ayon-addon
|
|
213
264
|
check [--json] [--no-types] the manifest as AYON reads it, the pieces' files, the types
|
|
214
265
|
open <slot> <id> [--project <name>] [--entities type:id,...] [--page overview|products|lists]
|
|
@@ -226,8 +277,9 @@ const HELP = `ayon-addon: tools for AYON addon frontends (run in the addon's fro
|
|
|
226
277
|
their hooks, or offers to add the hooks of new endpoints
|
|
227
278
|
(--hooks: without asking). Options: ayonAddon({ rest })
|
|
228
279
|
|
|
229
|
-
Sign-in: AYON_SERVER_URL and AYON_API_KEY
|
|
230
|
-
|
|
280
|
+
Sign-in: AYON_SERVER_URL and AYON_API_KEY, from the environment or the addon's .env.local or
|
|
281
|
+
.env (in a terminal, a first run walks you through .env); in the AYON dev workbench, the local
|
|
282
|
+
stack and data/local-api-key.`;
|
|
231
283
|
const commands = {
|
|
232
284
|
dev,
|
|
233
285
|
slots,
|
|
@@ -243,7 +295,9 @@ if (!run) {
|
|
|
243
295
|
}
|
|
244
296
|
else {
|
|
245
297
|
run().catch((error) => {
|
|
246
|
-
|
|
298
|
+
// the message only: a setup problem needs no stack trace (--json keeps everything)
|
|
299
|
+
const message = error instanceof Error ? error.message : error;
|
|
300
|
+
console.error(json ? JSON.stringify({ ok: false, error: String(error) }) : message);
|
|
247
301
|
process.exitCode = 1;
|
|
248
302
|
});
|
|
249
303
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AddonProject, AyonConnection } from './project.js';
|
|
2
|
+
export interface DevCheck {
|
|
3
|
+
id: 'server' | 'apiKey' | 'user' | 'addon' | 'addonVersion';
|
|
4
|
+
ok: boolean;
|
|
5
|
+
/** error: the addon won't work in AYON; warning: it may, or it couldn't be checked */
|
|
6
|
+
severity?: 'error' | 'warning';
|
|
7
|
+
message: string;
|
|
8
|
+
/** how to fix it, when it failed */
|
|
9
|
+
fix?: string;
|
|
10
|
+
}
|
|
11
|
+
interface CheckOptions {
|
|
12
|
+
project: AddonProject;
|
|
13
|
+
connection: AyonConnection;
|
|
14
|
+
/** ayonAddon({ rest: { versionModule } }), relative to the addon's frontend folder */
|
|
15
|
+
versionModule?: string;
|
|
16
|
+
fetch?: typeof globalThis.fetch;
|
|
17
|
+
}
|
|
18
|
+
export declare const readFrontendVersion: (root: string, versionModule?: string) => {
|
|
19
|
+
file: string;
|
|
20
|
+
version: string;
|
|
21
|
+
} | undefined;
|
|
22
|
+
export declare const checkDevRequirements: ({ project, connection, versionModule, fetch, }: CheckOptions) => Promise<DevCheck[]>;
|
|
23
|
+
export declare const formatChecks: (checks: DevCheck[]) => string;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// `ayon-addon dev --ayon`: what AYON's dev override needs that the CLI can check against the server.
|
|
2
|
+
// The page ignores the override (and says why in its banner) when one of these doesn't hold, and an
|
|
3
|
+
// addon whose server version isn't installed loads, but its endpoint calls answer 404.
|
|
4
|
+
import { existsSync, readFileSync } from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
// the version the addon's frontend calls its endpoints with: the default export of its versionModule
|
|
7
|
+
// (src/addonVersion.ts unless configured, see generate-rest)
|
|
8
|
+
export const readFrontendVersion = (root, versionModule = 'src/addonVersion.ts') => {
|
|
9
|
+
const full = path.resolve(root, versionModule);
|
|
10
|
+
if (!existsSync(full))
|
|
11
|
+
return undefined;
|
|
12
|
+
const version = readFileSync(full, 'utf8').match(/export default\s+["']([^"']+)["']/)?.[1];
|
|
13
|
+
return version ? { file: versionModule, version } : undefined;
|
|
14
|
+
};
|
|
15
|
+
// the addon has server code: a missing server version breaks its endpoints, not only its settings
|
|
16
|
+
const hasServerCode = (root) => existsSync(path.resolve(root, '..', 'server'));
|
|
17
|
+
const INSTALL_FIX = (name, version) => `install ${name} ${version} on the server: build it (python create_package.py in the addon) and upload the zip in Studio settings > Addons > Upload Addons` +
|
|
18
|
+
` (AYON dev workbench: scripts/mani.sh run addon-install -p <repo> SRC=<worktree>)`;
|
|
19
|
+
export const checkDevRequirements = async ({ project, connection, versionModule, fetch = globalThis.fetch, }) => {
|
|
20
|
+
const { serverUrl, apiKey, apiKeySource } = connection;
|
|
21
|
+
const checks = [];
|
|
22
|
+
const headers = apiKey ? { 'x-api-key': apiKey } : {};
|
|
23
|
+
const get = async (urlPath) => {
|
|
24
|
+
const controller = new AbortController();
|
|
25
|
+
const timer = setTimeout(() => controller.abort(), 10_000);
|
|
26
|
+
try {
|
|
27
|
+
const response = await fetch(`${serverUrl}${urlPath}`, { headers, signal: controller.signal });
|
|
28
|
+
return { status: response.status, body: response.ok ? await response.json() : undefined };
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
// the server
|
|
35
|
+
try {
|
|
36
|
+
const { status, body } = await get('/api/info');
|
|
37
|
+
if (status !== 200) {
|
|
38
|
+
checks.push({
|
|
39
|
+
id: 'server',
|
|
40
|
+
ok: false,
|
|
41
|
+
severity: 'error',
|
|
42
|
+
message: `AYON at ${serverUrl} answered ${status} to /api/info`,
|
|
43
|
+
fix: 'check the server log; set AYON_SERVER_URL if this is the wrong server',
|
|
44
|
+
});
|
|
45
|
+
return checks;
|
|
46
|
+
}
|
|
47
|
+
const version = body?.version ? ` ${body.version}` : '';
|
|
48
|
+
checks.push({ id: 'server', ok: true, message: `AYON${version} at ${serverUrl}` });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
checks.push({
|
|
52
|
+
id: 'server',
|
|
53
|
+
ok: false,
|
|
54
|
+
severity: 'error',
|
|
55
|
+
message: `AYON at ${serverUrl} doesn't answer`,
|
|
56
|
+
fix: 'start the server, or point AYON_SERVER_URL at a running one',
|
|
57
|
+
});
|
|
58
|
+
return checks;
|
|
59
|
+
}
|
|
60
|
+
if (!apiKey) {
|
|
61
|
+
checks.push({
|
|
62
|
+
id: 'apiKey',
|
|
63
|
+
ok: false,
|
|
64
|
+
severity: 'warning',
|
|
65
|
+
message: "no API key: can't check the user or the installed addon versions",
|
|
66
|
+
fix: "set AYON_API_KEY (a service user's key) to check them",
|
|
67
|
+
});
|
|
68
|
+
return checks;
|
|
69
|
+
}
|
|
70
|
+
// the key's user: what an agent's headless browser signs in as
|
|
71
|
+
const me = await get('/api/users/me').catch(() => ({ status: 0, body: undefined }));
|
|
72
|
+
if (me.status !== 200) {
|
|
73
|
+
checks.push({
|
|
74
|
+
id: 'user',
|
|
75
|
+
ok: false,
|
|
76
|
+
severity: 'error',
|
|
77
|
+
message: `the API key from ${apiKeySource} was rejected (${me.status || 'no answer'})`,
|
|
78
|
+
fix: "set AYON_API_KEY to a valid service user's key",
|
|
79
|
+
});
|
|
80
|
+
return checks;
|
|
81
|
+
}
|
|
82
|
+
const user = me.body;
|
|
83
|
+
if (user.data?.isService || user.attrib?.developerMode) {
|
|
84
|
+
checks.push({
|
|
85
|
+
id: 'user',
|
|
86
|
+
ok: true,
|
|
87
|
+
message: `${user.name} (the key's user) may use the override: ${user.data?.isService ? 'a service user' : 'developer mode is on'}. In a browser, your own user needs developer mode`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
checks.push({
|
|
92
|
+
id: 'user',
|
|
93
|
+
ok: false,
|
|
94
|
+
severity: 'warning',
|
|
95
|
+
message: `${user.name} (the key's user) is not in developer mode: a page signed in with this key ignores the override`,
|
|
96
|
+
fix: "turn on developer mode for that user, or use a service user's key",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
// the addon's server side, in the version the frontend calls
|
|
100
|
+
const frontendVersion = readFrontendVersion(project.root, versionModule);
|
|
101
|
+
if (frontendVersion && frontendVersion.version !== project.version) {
|
|
102
|
+
checks.push({
|
|
103
|
+
id: 'addonVersion',
|
|
104
|
+
ok: false,
|
|
105
|
+
severity: 'warning',
|
|
106
|
+
message: `${frontendVersion.file} says ${frontendVersion.version}, package.py says ${project.version}: the frontend calls the endpoints of ${frontendVersion.version}`,
|
|
107
|
+
fix: `set both to the same version (packaging writes ${frontendVersion.file} from package.py)`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
const version = frontendVersion?.version ?? project.version;
|
|
111
|
+
const addons = await get('/api/addons').catch(() => ({ status: 0, body: undefined }));
|
|
112
|
+
const addon = addons.body?.addons?.find((item) => item.name === project.name);
|
|
113
|
+
const severity = hasServerCode(project.root) ? 'error' : 'warning';
|
|
114
|
+
const effect = `the page loads its frontend from here anyway, but calls to /api/addons/${project.name}/${version}/... answer 404`;
|
|
115
|
+
if (addons.status !== 200) {
|
|
116
|
+
checks.push({
|
|
117
|
+
id: 'addon',
|
|
118
|
+
ok: false,
|
|
119
|
+
severity: 'warning',
|
|
120
|
+
message: `couldn't list the server's addons (${addons.status || 'no answer'})`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
else if (!addon) {
|
|
124
|
+
checks.push({
|
|
125
|
+
id: 'addon',
|
|
126
|
+
ok: false,
|
|
127
|
+
severity,
|
|
128
|
+
message: `${project.name} is not installed on the server: ${effect}`,
|
|
129
|
+
fix: INSTALL_FIX(project.name, version),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
else if (!addon.versions[version]) {
|
|
133
|
+
checks.push({
|
|
134
|
+
id: 'addon',
|
|
135
|
+
ok: false,
|
|
136
|
+
severity,
|
|
137
|
+
message: `${project.name} ${version} is not installed (the server has ${Object.keys(addon.versions).join(', ') || 'none'}): ${effect}`,
|
|
138
|
+
fix: INSTALL_FIX(project.name, version),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
else if (addon.versions[version].isBroken) {
|
|
142
|
+
checks.push({
|
|
143
|
+
id: 'addon',
|
|
144
|
+
ok: false,
|
|
145
|
+
severity: 'error',
|
|
146
|
+
message: `${project.name} ${version} is installed but failed to load on the server: its endpoints don't exist`,
|
|
147
|
+
fix: 'see the server log for the error in its server code',
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
checks.push({ id: 'addon', ok: true, message: `${project.name} ${version} is installed on the server` });
|
|
152
|
+
}
|
|
153
|
+
return checks;
|
|
154
|
+
};
|
|
155
|
+
export const formatChecks = (checks) => checks
|
|
156
|
+
.flatMap((check) => [
|
|
157
|
+
` ${(check.ok ? 'ok' : check.severity === 'error' ? 'FAIL' : 'warn').padEnd(5)} ${check.message}`,
|
|
158
|
+
...(check.fix ? [` fix: ${check.fix}`] : []),
|
|
159
|
+
])
|
|
160
|
+
.join('\n');
|
package/dist/project.d.ts
CHANGED
|
@@ -13,11 +13,28 @@ export interface AyonConnection {
|
|
|
13
13
|
/** where the key came from, for messages */
|
|
14
14
|
apiKeySource?: string;
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Loads the addon's `.env.local` and `.env` (AYON_SERVER_URL, AYON_API_KEY, …) into process.env,
|
|
18
|
+
* so no command needs an `--env-file` or a shell export.
|
|
19
|
+
*/
|
|
20
|
+
export declare const loadProjectEnv: (root?: string) => void;
|
|
21
|
+
export declare const DEFAULT_SERVER_URL = "http://localhost:5000";
|
|
16
22
|
/**
|
|
17
23
|
* AYON_SERVER_URL and AYON_API_KEY win; inside the AYON dev workbench the local stack and its
|
|
18
24
|
* key (data/local-api-key) are the defaults.
|
|
19
25
|
*/
|
|
20
26
|
export declare const findConnection: (root?: string) => AyonConnection;
|
|
27
|
+
export declare const envFileContents: (serverUrl: string, apiKey?: string) => string;
|
|
28
|
+
/** The server address as typed (without trailing slashes), or undefined when it isn't http(s) */
|
|
29
|
+
export declare const parseServerUrl: (answer: string) => string | undefined;
|
|
30
|
+
/** What to do when no API key is set: which file to put it in, and where to get the key */
|
|
31
|
+
export declare const keySetupMessage: (root: string) => string;
|
|
32
|
+
/**
|
|
33
|
+
* Returns the API key, or stops the command when there is none. In a terminal with no env file
|
|
34
|
+
* yet, it walks through writing one: the server (default http://localhost:5000), then the key,
|
|
35
|
+
* which can be left empty to add later. A key given there lets the command go on.
|
|
36
|
+
*/
|
|
37
|
+
export declare const requireApiKey: (root: string, apiKey: string | undefined, interactive: boolean) => Promise<string>;
|
|
21
38
|
/**
|
|
22
39
|
* Imports a package the way the addon resolves it, not from where this file lives: a linked
|
|
23
40
|
* @ynput/ayon-addon-dev would otherwise use the ayon-frontend checkout's copies.
|
package/dist/project.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// What the tools need to know about the addon they run in: its name and version (package.py),
|
|
2
2
|
// where AYON runs, and how to sign in to it.
|
|
3
|
-
import {
|
|
3
|
+
import { spawnSync } from 'child_process';
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
4
5
|
import { createRequire } from 'module';
|
|
5
6
|
import path from 'path';
|
|
7
|
+
import { createInterface } from 'readline/promises';
|
|
8
|
+
import { Writable } from 'stream';
|
|
6
9
|
import { pathToFileURL } from 'url';
|
|
7
10
|
const pyString = (source, key) => source.match(new RegExp(`^${key}\\s*=\\s*["']([^"']+)["']`, 'm'))?.[1];
|
|
8
11
|
export const readAddonProject = (root = process.cwd()) => {
|
|
@@ -32,6 +35,20 @@ const workbenchServerPort = (workbench) => {
|
|
|
32
35
|
const port = existsSync(env) && readFileSync(env, 'utf8').match(/^SERVER_PORT=(\d+)/m)?.[1];
|
|
33
36
|
return port || '5100';
|
|
34
37
|
};
|
|
38
|
+
// the addon's env files, in order: the first one that sets a variable wins, and the shell wins over both
|
|
39
|
+
const ENV_FILES = ['.env.local', '.env'];
|
|
40
|
+
/**
|
|
41
|
+
* Loads the addon's `.env.local` and `.env` (AYON_SERVER_URL, AYON_API_KEY, …) into process.env,
|
|
42
|
+
* so no command needs an `--env-file` or a shell export.
|
|
43
|
+
*/
|
|
44
|
+
export const loadProjectEnv = (root = process.cwd()) => {
|
|
45
|
+
for (const name of ENV_FILES) {
|
|
46
|
+
const file = path.join(root, name);
|
|
47
|
+
if (existsSync(file))
|
|
48
|
+
process.loadEnvFile(file);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
export const DEFAULT_SERVER_URL = 'http://localhost:5000';
|
|
35
52
|
/**
|
|
36
53
|
* AYON_SERVER_URL and AYON_API_KEY win; inside the AYON dev workbench the local stack and its
|
|
37
54
|
* key (data/local-api-key) are the defaults.
|
|
@@ -40,7 +57,7 @@ export const findConnection = (root = process.cwd()) => {
|
|
|
40
57
|
const workbench = findWorkbench(root);
|
|
41
58
|
const serverUrl = process.env.AYON_SERVER_URL ||
|
|
42
59
|
process.env.SERVER_URL ||
|
|
43
|
-
(workbench ? `http://localhost:${workbenchServerPort(workbench)}` :
|
|
60
|
+
(workbench ? `http://localhost:${workbenchServerPort(workbench)}` : DEFAULT_SERVER_URL);
|
|
44
61
|
if (process.env.AYON_API_KEY) {
|
|
45
62
|
return { serverUrl, apiKey: process.env.AYON_API_KEY, apiKeySource: 'AYON_API_KEY' };
|
|
46
63
|
}
|
|
@@ -51,6 +68,107 @@ export const findConnection = (root = process.cwd()) => {
|
|
|
51
68
|
}
|
|
52
69
|
return { serverUrl };
|
|
53
70
|
};
|
|
71
|
+
// the .env that requireApiKey writes: the server, and the key when the user gave one
|
|
72
|
+
export const envFileContents = (serverUrl, apiKey = '') => `# ayon-addon: the AYON server and an API key for this addon's tools.
|
|
73
|
+
# Every ayon-addon command reads this file. A variable set in your shell wins over it, and
|
|
74
|
+
# .env.local (same format) wins over this file. Keep both out of git.
|
|
75
|
+
|
|
76
|
+
# The AYON server
|
|
77
|
+
AYON_SERVER_URL=${serverUrl}
|
|
78
|
+
|
|
79
|
+
# A service user's key: Settings → Users → Add Service User → Generate new key.
|
|
80
|
+
# AYON shows the key once, so copy it here.
|
|
81
|
+
AYON_API_KEY=${apiKey}
|
|
82
|
+
`;
|
|
83
|
+
/** The server address as typed (without trailing slashes), or undefined when it isn't http(s) */
|
|
84
|
+
export const parseServerUrl = (answer) => {
|
|
85
|
+
const address = answer.trim().replace(/\/+$/, '');
|
|
86
|
+
try {
|
|
87
|
+
const { protocol } = new URL(address);
|
|
88
|
+
return protocol === 'http:' || protocol === 'https:' ? address : undefined;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
// The setup's questions in one line reader that stays open, so answers typed ahead are not lost
|
|
95
|
+
// between questions. A secret is typed without echo: the reader's output is muted while it's read.
|
|
96
|
+
const openTerminal = () => {
|
|
97
|
+
let muted = false;
|
|
98
|
+
const output = new Writable({
|
|
99
|
+
write: (chunk, _encoding, done) => {
|
|
100
|
+
if (!muted)
|
|
101
|
+
process.stdout.write(chunk);
|
|
102
|
+
done();
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
const rl = createInterface({ input: process.stdin, output, terminal: true });
|
|
106
|
+
return {
|
|
107
|
+
ask: async (question, secret = false) => {
|
|
108
|
+
if (secret) {
|
|
109
|
+
process.stdout.write(question);
|
|
110
|
+
muted = true;
|
|
111
|
+
}
|
|
112
|
+
const answer = await rl.question(secret ? '' : question);
|
|
113
|
+
if (secret) {
|
|
114
|
+
muted = false;
|
|
115
|
+
process.stdout.write('\n');
|
|
116
|
+
}
|
|
117
|
+
return answer.trim();
|
|
118
|
+
},
|
|
119
|
+
close: () => rl.close(),
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
/** What to do when no API key is set: which file to put it in, and where to get the key */
|
|
123
|
+
export const keySetupMessage = (root) => {
|
|
124
|
+
const files = ENV_FILES.map((name) => path.join(root, name)).filter((file) => existsSync(file));
|
|
125
|
+
const key = "Set AYON_API_KEY to a service user's key: Settings → Users → Add Service User → Generate new key (AYON shows it once).";
|
|
126
|
+
const file = path.join(root, '.env');
|
|
127
|
+
return files.length
|
|
128
|
+
? `no API key: AYON_API_KEY is not set in ${files.join(' or ')} or in the shell. ${key}`
|
|
129
|
+
: `no API key: AYON_API_KEY is not set in the shell or in ${file}. ${key} Set AYON_SERVER_URL too if the server isn't ${DEFAULT_SERVER_URL}. Or run this command in a terminal and ayon-addon asks for them and writes ${file}.`;
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* Returns the API key, or stops the command when there is none. In a terminal with no env file
|
|
133
|
+
* yet, it walks through writing one: the server (default http://localhost:5000), then the key,
|
|
134
|
+
* which can be left empty to add later. A key given there lets the command go on.
|
|
135
|
+
*/
|
|
136
|
+
export const requireApiKey = async (root, apiKey, interactive) => {
|
|
137
|
+
if (apiKey)
|
|
138
|
+
return apiKey;
|
|
139
|
+
const file = path.join(root, '.env');
|
|
140
|
+
const hasEnvFile = ENV_FILES.some((name) => existsSync(path.join(root, name)));
|
|
141
|
+
if (!interactive || hasEnvFile)
|
|
142
|
+
throw new Error(keySetupMessage(root));
|
|
143
|
+
const terminal = openTerminal();
|
|
144
|
+
try {
|
|
145
|
+
if (/^n/i.test(await terminal.ask(`no API key yet. Set up ${file}? [Y/n] `))) {
|
|
146
|
+
throw new Error(keySetupMessage(root));
|
|
147
|
+
}
|
|
148
|
+
let serverUrl;
|
|
149
|
+
while (!serverUrl) {
|
|
150
|
+
const answer = await terminal.ask(`AYON server [${DEFAULT_SERVER_URL}]: `);
|
|
151
|
+
serverUrl = parseServerUrl(answer || DEFAULT_SERVER_URL);
|
|
152
|
+
if (!serverUrl)
|
|
153
|
+
console.log('start the address with http:// or https://');
|
|
154
|
+
}
|
|
155
|
+
const key = await terminal.ask('AYON API key (Settings → Users → Add Service User → Generate new key), or Enter to add it later: ', true);
|
|
156
|
+
writeFileSync(file, envFileContents(serverUrl, key));
|
|
157
|
+
// git check-ignore: 1 means git doesn't ignore the file (0 ignored, 128 not a repo)
|
|
158
|
+
const ignored = spawnSync('git', ['check-ignore', '-q', file], { cwd: root }).status !== 1;
|
|
159
|
+
const warning = ignored ? '' : '. It is not git-ignored: add .env to .gitignore before you commit';
|
|
160
|
+
if (!key) {
|
|
161
|
+
throw new Error(`created ${file} with ${serverUrl}: add AYON_API_KEY there, then run the command again${warning}`);
|
|
162
|
+
}
|
|
163
|
+
console.log(`created ${file}${warning}`);
|
|
164
|
+
process.env.AYON_SERVER_URL = serverUrl;
|
|
165
|
+
process.env.AYON_API_KEY = key;
|
|
166
|
+
return key;
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
terminal.close();
|
|
170
|
+
}
|
|
171
|
+
};
|
|
54
172
|
/**
|
|
55
173
|
* Imports a package the way the addon resolves it, not from where this file lives: a linked
|
|
56
174
|
* @ynput/ayon-addon-dev would otherwise use the ayon-frontend checkout's copies.
|
package/dist/rest.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
10
10
|
import path from 'path';
|
|
11
11
|
import { createInterface } from 'readline/promises';
|
|
12
|
-
import { findConnection, importFromProject, readAddonProject } from './project.js';
|
|
12
|
+
import { findConnection, importFromProject, readAddonProject, requireApiKey, } from './project.js';
|
|
13
13
|
const camel = (name) => name.replace(/[^A-Za-z0-9]+(.)?/g, (_, next) => (next ? next.toUpperCase() : ''));
|
|
14
14
|
const capitalize = (name) => name.charAt(0).toUpperCase() + name.slice(1);
|
|
15
15
|
const posixRelative = (from, to) => {
|
|
@@ -17,10 +17,15 @@ const posixRelative = (from, to) => {
|
|
|
17
17
|
return relative.startsWith('.') ? relative : `./${relative}`;
|
|
18
18
|
};
|
|
19
19
|
export const hookName = ({ name, type }) => `use${capitalize(name)}${type === 'query' ? 'Query' : 'Mutation'}`;
|
|
20
|
-
const downloadSchema = async (root, addon) => {
|
|
21
|
-
|
|
20
|
+
const downloadSchema = async (root, addon, interactive) => {
|
|
21
|
+
// after the key: the setup may have chosen the server too
|
|
22
|
+
const apiKey = await requireApiKey(root, findConnection(root).apiKey, interactive);
|
|
23
|
+
const { serverUrl } = findConnection(root);
|
|
22
24
|
const url = `${serverUrl}/api/addons/${addon.name}/${addon.version}/openapi.json`;
|
|
23
|
-
const response = await fetch(url, { headers:
|
|
25
|
+
const response = await fetch(url, { headers: { 'x-api-key': apiKey } });
|
|
26
|
+
if (response.status === 401 || response.status === 403) {
|
|
27
|
+
throw new Error(`${url} answered ${response.status}: the API key was refused, check AYON_API_KEY in the shell or in the .env files of this folder`);
|
|
28
|
+
}
|
|
24
29
|
if (!response.ok) {
|
|
25
30
|
throw new Error(`${url} answered ${response.status}: is ${addon.name} ${addon.version} installed there?`);
|
|
26
31
|
}
|
|
@@ -89,7 +94,7 @@ export const generateRest = async (options) => {
|
|
|
89
94
|
let schema = resolve(options.schemaFile || rest.schema || 'gen/openapi.json');
|
|
90
95
|
let url;
|
|
91
96
|
if (!options.schemaFile) {
|
|
92
|
-
const download = await downloadSchema(root, addon);
|
|
97
|
+
const download = await downloadSchema(root, addon, options.interactive);
|
|
93
98
|
url = download.url;
|
|
94
99
|
mkdirSync(path.dirname(schema), { recursive: true });
|
|
95
100
|
writeFileSync(schema, JSON.stringify(patchSchema(download.schema, addon.name), null, 2));
|
package/dist/shell/index.js
CHANGED
|
@@ -2,13 +2,13 @@ import { jsx, jsxs, Fragment } from "react/jsx-runtime";
|
|
|
2
2
|
import { useCallback, createContext, useContext, useState, useMemo, useEffect, useSyncExternalStore, Component, StrictMode } from "react";
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
4
|
import { provideHost, isKnownSlot, getRegistry, contributionTitle, getHost, registerManifest, setManifestsLoading } from "@ynput/ayon-addon/host";
|
|
5
|
-
import { useGetProjectFoldersQuery, useGetCurrentUserQuery, useListProjectsQuery, useGetEntitiesDetailsPanelQuery, useGetProjectQuery, useGetUsersAssigneeQuery, createStandaloneStore } from "@ynput/ayon-frontend-shared/api";
|
|
6
|
-
import { SlicerHost, ProjectPageLayoutHost, JsonViewer, AddonButton, useAddonCustomizeSections, useAddonTableShortcuts, SettingsPanel, AddonDialog, useStudioPageSlotContext, useProjectPageSlotContext, SplitterStyles, LoginPage } from "@ynput/ayon-frontend-shared/components";
|
|
7
|
-
import { useGlobalContext, usePowerpack, useMenuContext,
|
|
5
|
+
import { useGetProjectFoldersQuery, useGetCurrentUserQuery, usersApi, useListProjectsQuery, useGetEntitiesDetailsPanelQuery, useGetProjectQuery, useGetUsersAssigneeQuery, createStandaloneStore } from "@ynput/ayon-frontend-shared/api";
|
|
6
|
+
import { AddonMoreMenu, AddonEntityPicker, SlicerHost, ProjectPageLayoutHost, JsonViewer, AddonButton, useAddonCustomizeSections, useAddonTableShortcuts, shortcutEntities, SettingsPanel, AddonDialog, useStudioPageSlotContext, useProjectPageSlotContext, SplitterStyles, LoginPage, AddonServices } from "@ynput/ayon-frontend-shared/components";
|
|
7
|
+
import { useGlobalContext, usePowerpack, useMenuContext, useSettingsPanel, useScopedDetailsPanel, EntityUpdatesProvider, GlobalProvider, PowerpackProvider, MenuProvider, URIProvider, DetailsPanelProvider, PiPProvider, DeleteEntitiesProvider, SettingsPanelProvider, ProjectContextProvider, ProjectFoldersContextProvider } from "@ynput/ayon-frontend-shared/context";
|
|
8
8
|
import { buildProjectsTableData, ProjectsTable, PROJECTS_LIST_MENU_ID, parseProjectFolderRowId } from "@ynput/ayon-frontend-shared/ProjectsTable";
|
|
9
9
|
import { getProjectDisplayName, clearAccessToken, storeAccessToken } from "@ynput/ayon-frontend-shared/util";
|
|
10
10
|
import { Button, Icon, Dropdown, Dialog, InputText } from "@ynput/ayon-react-components";
|
|
11
|
-
import { useContributions, prefetchPiece, runContribution, ContributionView } from "@ynput/ayon-addon";
|
|
11
|
+
import { useContributions, prefetchPiece, runContribution, EntityPicker, ContributionView } from "@ynput/ayon-addon";
|
|
12
12
|
import { useCreateContextMenu, ContextMenuProvider, GlobalContextMenu } from "@ynput/ayon-frontend-shared/ContextMenu";
|
|
13
13
|
import { useDispatch, Provider } from "react-redux";
|
|
14
14
|
import { BrowserRouter, useNavigate, useSearchParams, useParams, useLocation } from "react-router-dom";
|
|
@@ -16,7 +16,6 @@ import { ToastContainer } from "react-toastify";
|
|
|
16
16
|
import { Splitter, SplitterPanel } from "primereact/splitter";
|
|
17
17
|
import { useAddonTableColumns, useAddonEntityMenuItems, AddonColumnDataScope, ProjectDataProvider } from "@ynput/ayon-frontend-shared/ProjectTreeTable";
|
|
18
18
|
import { SLOT_DOCS } from "@ynput/ayon-addon/check";
|
|
19
|
-
import { EntityPickerDialog } from "@ynput/ayon-frontend-shared/EntityPickerDialog";
|
|
20
19
|
import { DetailsPanel } from "@ynput/ayon-frontend-shared/DetailsPanel";
|
|
21
20
|
import { NewEntityProvider } from "@ynput/ayon-frontend-shared/NewEntity";
|
|
22
21
|
import { SlicerProvider, useSlicerContext } from "@ynput/ayon-frontend-shared/Slicer";
|
|
@@ -63,23 +62,26 @@ const useShellMenu = (slot, makeContext, applies = () => true) => {
|
|
|
63
62
|
) : null;
|
|
64
63
|
return { openContextMenu, dropdown, itemsFor };
|
|
65
64
|
};
|
|
65
|
+
const toAddonUser = (user2) => {
|
|
66
|
+
if (!user2?.name) return void 0;
|
|
67
|
+
const data = user2.data || {};
|
|
68
|
+
const isAdmin = !!data.isAdmin || !!data.isService;
|
|
69
|
+
return {
|
|
70
|
+
name: user2.name,
|
|
71
|
+
fullName: user2.attrib?.fullName || void 0,
|
|
72
|
+
email: user2.attrib?.email || void 0,
|
|
73
|
+
isAdmin,
|
|
74
|
+
isManager: isAdmin || !!data.isManager,
|
|
75
|
+
isGuest: !!data.isGuest,
|
|
76
|
+
isService: !!data.isService,
|
|
77
|
+
isDeveloper: !!user2.attrib?.developerMode || !!data.isService
|
|
78
|
+
};
|
|
79
|
+
};
|
|
66
80
|
const useUser = () => {
|
|
67
81
|
const { data: user2 } = useGetCurrentUserQuery();
|
|
68
|
-
return useMemo(() =>
|
|
69
|
-
if (!user2?.name) return void 0;
|
|
70
|
-
const data = user2.data || {};
|
|
71
|
-
const isAdmin = !!data.isAdmin || !!data.isService;
|
|
72
|
-
return {
|
|
73
|
-
name: user2.name,
|
|
74
|
-
fullName: user2.attrib?.fullName || void 0,
|
|
75
|
-
email: user2.attrib?.email || void 0,
|
|
76
|
-
isAdmin,
|
|
77
|
-
isManager: isAdmin || !!data.isManager,
|
|
78
|
-
isGuest: !!data.isGuest,
|
|
79
|
-
isDeveloper: !!user2.attrib?.developerMode || !!data.isService
|
|
80
|
-
};
|
|
81
|
-
}, [user2]);
|
|
82
|
+
return useMemo(() => toAddonUser(user2), [user2]);
|
|
82
83
|
};
|
|
84
|
+
const getShellUser = (state) => toAddonUser(usersApi.endpoints.getCurrentUser.select()(state).data);
|
|
83
85
|
const StudioSelectionContext = createContext(null);
|
|
84
86
|
const StudioPageLayout = ({ sidebar, children }) => /* @__PURE__ */ jsxs("div", { style: { display: "flex", width: "100%", height: "100%", minHeight: 0 }, children: [
|
|
85
87
|
sidebar && /* @__PURE__ */ jsx(
|
|
@@ -158,7 +160,9 @@ const provideShellHost = () => provideHost({
|
|
|
158
160
|
ProjectPageLayout: ProjectPageLayoutHost,
|
|
159
161
|
Slicer: SlicerHost,
|
|
160
162
|
StudioPageLayout,
|
|
161
|
-
ProjectList
|
|
163
|
+
ProjectList,
|
|
164
|
+
EntityPicker: AddonEntityPicker,
|
|
165
|
+
MoreMenu: AddonMoreMenu
|
|
162
166
|
}
|
|
163
167
|
});
|
|
164
168
|
const StudioSelectionProvider = ({
|
|
@@ -357,6 +361,11 @@ const SLOT_AREAS = [
|
|
|
357
361
|
icon: "web_asset",
|
|
358
362
|
sections: [{ slots: [{ slot: "header.item", label: "Items" }] }]
|
|
359
363
|
},
|
|
364
|
+
{
|
|
365
|
+
label: "Background (no UI)",
|
|
366
|
+
icon: "memory",
|
|
367
|
+
sections: [{ slots: [{ slot: "app.service", label: "Services" }] }]
|
|
368
|
+
},
|
|
360
369
|
{
|
|
361
370
|
label: "Studio",
|
|
362
371
|
icon: "home",
|
|
@@ -406,7 +415,7 @@ const slotPath = (slot) => {
|
|
|
406
415
|
return ["Other", slot];
|
|
407
416
|
};
|
|
408
417
|
const slotIcon = (slot) => SLOT_AREAS.find((area) => area.sections.some((s) => s.slots.some((x) => x.slot === slot)))?.icon || "extension";
|
|
409
|
-
const NO_PROJECT = ["studio.page", "header.item", "projectList.menuItem"];
|
|
418
|
+
const NO_PROJECT = ["studio.page", "header.item", "projectList.menuItem", "app.service"];
|
|
410
419
|
const PROJECTS = ["studio.page", "projectList.menuItem"];
|
|
411
420
|
const ENTITIES = [
|
|
412
421
|
"detailsPanel.tab",
|
|
@@ -681,21 +690,21 @@ const EntitiesPicker = ({ selection, onChange }) => {
|
|
|
681
690
|
}
|
|
682
691
|
)
|
|
683
692
|
] }, entity.id)),
|
|
684
|
-
picking && // the
|
|
685
|
-
/* @__PURE__ */ jsx(
|
|
686
|
-
|
|
693
|
+
picking && // the one addons get, as the shell's host provides it
|
|
694
|
+
/* @__PURE__ */ jsx(
|
|
695
|
+
EntityPicker,
|
|
687
696
|
{
|
|
688
697
|
projectName,
|
|
689
698
|
entityType,
|
|
690
|
-
|
|
699
|
+
multiSelect: true,
|
|
700
|
+
initialSelection: entities,
|
|
691
701
|
onClose: () => setPicking(false),
|
|
692
|
-
onSubmit: (
|
|
702
|
+
onSubmit: (picked) => {
|
|
693
703
|
setPicking(false);
|
|
694
|
-
const picked = ids.map((id) => ({ id, entityType, projectName }));
|
|
695
704
|
onChange({ ...selection, entityType, entities: picked });
|
|
696
705
|
}
|
|
697
706
|
}
|
|
698
|
-
)
|
|
707
|
+
)
|
|
699
708
|
] });
|
|
700
709
|
};
|
|
701
710
|
const TablePage = ({ selection, onChange }) => /* @__PURE__ */ jsx(Field, { label: "Table", children: /* @__PURE__ */ jsx(
|
|
@@ -941,6 +950,7 @@ const ButtonItem = ({
|
|
|
941
950
|
}
|
|
942
951
|
);
|
|
943
952
|
};
|
|
953
|
+
const ServiceNote = () => /* @__PURE__ */ jsx("div", { style: { padding: 16, color: "var(--md-sys-color-outline)" }, "data-shell-surface": "service", children: "A service has no UI. It is mounted once for the whole shell, as in AYON, and keeps running while you pick other contributions; a crash shows as a toast. Its context is the shell's project." });
|
|
944
954
|
const ShellTable = ({ page, entities, selected, onSelect }) => {
|
|
945
955
|
const { columns: addonColumns } = useAddonTableColumns(page);
|
|
946
956
|
const columns = useMemo(
|
|
@@ -1088,6 +1098,7 @@ const PreviewStage = ({
|
|
|
1088
1098
|
const item = contribution;
|
|
1089
1099
|
return /* @__PURE__ */ jsx("div", { style: { padding: 16 }, children: /* @__PURE__ */ jsx(Button, { icon: item.icon, label: item.label, "data-shell-action": "run", onClick: run }) });
|
|
1090
1100
|
}
|
|
1101
|
+
if (slot === "app.service") return /* @__PURE__ */ jsx(ServiceNote, {});
|
|
1091
1102
|
if (slot === "tableColumn") {
|
|
1092
1103
|
const entities = context.entities || [];
|
|
1093
1104
|
return /* @__PURE__ */ jsx(
|
|
@@ -1199,6 +1210,11 @@ const StudioPageStage = ({ contribution, selection, onChange }) => {
|
|
|
1199
1210
|
contribution.key
|
|
1200
1211
|
);
|
|
1201
1212
|
};
|
|
1213
|
+
const ServiceStage = ({ contribution, selection }) => {
|
|
1214
|
+
const context = useMemo(() => ({ projectName: selection.projectName ?? null }), [selection.projectName]);
|
|
1215
|
+
useReport(contribution, context, true);
|
|
1216
|
+
return /* @__PURE__ */ jsx(ServiceNote, {});
|
|
1217
|
+
};
|
|
1202
1218
|
const HeaderStage = ({ contribution, selection }) => {
|
|
1203
1219
|
const { contributions } = useContributions("header.item");
|
|
1204
1220
|
const [context, setContext] = useState();
|
|
@@ -1235,7 +1251,7 @@ const DetailsPanelStage = ({ contribution, selection }) => {
|
|
|
1235
1251
|
if (isTab) setTab(contribution.key);
|
|
1236
1252
|
}, [contribution.key, isTab]);
|
|
1237
1253
|
const context = useMemo(
|
|
1238
|
-
() => isTab ? { projectName, entityType, entities, scope: DETAILS_SCOPE } : { projectName, entityType,
|
|
1254
|
+
() => isTab ? { projectName, entityType, entities, scope: DETAILS_SCOPE } : { projectName, entityType, entities },
|
|
1239
1255
|
[isTab, projectName, entityType, entities]
|
|
1240
1256
|
);
|
|
1241
1257
|
useReport(contribution, context, !!project && entities.length > 0, !isTab);
|
|
@@ -1263,15 +1279,23 @@ const DetailsPanelStage = ({ contribution, selection }) => {
|
|
|
1263
1279
|
};
|
|
1264
1280
|
const SlicerBody = ({ contribution, selection }) => {
|
|
1265
1281
|
const { rowSelection, sliceType } = useSlicerContext();
|
|
1266
|
-
const context = useMemo(
|
|
1267
|
-
() =>
|
|
1282
|
+
const context = useMemo(() => {
|
|
1283
|
+
const ids = Object.keys(rowSelection).filter((id) => rowSelection[id]);
|
|
1284
|
+
const entities = sliceType === "hierarchy" ? ids.map(
|
|
1285
|
+
(id) => selection.entities.find((entity) => entity.id === id) ?? {
|
|
1286
|
+
id,
|
|
1287
|
+
entityType: "folder",
|
|
1288
|
+
projectName: selection.projectName
|
|
1289
|
+
}
|
|
1290
|
+
) : [];
|
|
1291
|
+
return {
|
|
1268
1292
|
projectName: selection.projectName,
|
|
1269
1293
|
sliceType,
|
|
1270
|
-
selected:
|
|
1294
|
+
selected: ids.map((id) => ({ id })),
|
|
1295
|
+
entities,
|
|
1271
1296
|
menu: "context"
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
);
|
|
1297
|
+
};
|
|
1298
|
+
}, [selection.projectName, selection.entities, sliceType, rowSelection]);
|
|
1275
1299
|
useReport(contribution, context, true, true);
|
|
1276
1300
|
return (
|
|
1277
1301
|
// positioned: the slicer's Section fills its container (a splitter panel in AYON)
|
|
@@ -1324,6 +1348,7 @@ const TableStage = ({ contribution, selection }) => {
|
|
|
1324
1348
|
table: page,
|
|
1325
1349
|
projectName,
|
|
1326
1350
|
rows,
|
|
1351
|
+
entities: shortcutEntities(page, projectName, rows),
|
|
1327
1352
|
focused: { rowId: rows[0].id, columnId: "name" },
|
|
1328
1353
|
cells: rows.map((row) => ({ rowId: row.id, columnId: "name" }))
|
|
1329
1354
|
};
|
|
@@ -1441,6 +1466,8 @@ const Stage = ({
|
|
|
1441
1466
|
case "projectTable.toolbarItem":
|
|
1442
1467
|
case "projectTable.customizeSection":
|
|
1443
1468
|
return /* @__PURE__ */ jsx(TableStage, { ...props });
|
|
1469
|
+
case "app.service":
|
|
1470
|
+
return /* @__PURE__ */ jsx(ServiceStage, { ...props });
|
|
1444
1471
|
default:
|
|
1445
1472
|
return /* @__PURE__ */ jsxs(Hint, { children: [
|
|
1446
1473
|
"The shell has no stage for ",
|
|
@@ -1557,33 +1584,41 @@ const Workbench = ({ options, mode }) => {
|
|
|
1557
1584
|
const [selection, setSelection] = useState(readSelection);
|
|
1558
1585
|
useEffect(() => writeSelection(selection), [selection]);
|
|
1559
1586
|
useEffect(() => setStatus({ selection, mode }), [selection, mode]);
|
|
1560
|
-
return /* @__PURE__ */ jsxs(
|
|
1561
|
-
/* @__PURE__ */ jsx(
|
|
1562
|
-
/* @__PURE__ */ jsxs(
|
|
1563
|
-
/* @__PURE__ */ jsx(
|
|
1564
|
-
/* @__PURE__ */
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
{
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1587
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1588
|
+
/* @__PURE__ */ jsx(AddonServices, { projectName: selection.projectName ?? null }),
|
|
1589
|
+
/* @__PURE__ */ jsxs(Splitter, { className: "ayon-shell", stateKey: "ayon-addon-shell", stateStorage: "local", children: [
|
|
1590
|
+
/* @__PURE__ */ jsx(SplitterPanel, { size: 22, minSize: 12, style: { overflow: "hidden" }, children: /* @__PURE__ */ jsx(StageBoundary, { what: "developer panel", children: /* @__PURE__ */ jsx(DevPanel, { options, mode, selection, onChange: setSelection }) }) }),
|
|
1591
|
+
/* @__PURE__ */ jsxs(SplitterPanel, { size: 78, style: { overflow: "hidden", display: "flex", flexDirection: "column" }, children: [
|
|
1592
|
+
/* @__PURE__ */ jsx(StageCaption, { mode, selection }),
|
|
1593
|
+
/* @__PURE__ */ jsx(
|
|
1594
|
+
ProjectProviders,
|
|
1595
|
+
{
|
|
1596
|
+
projectName: mode === "live" || selection.slot === "tableColumn" ? selection.projectName : void 0,
|
|
1597
|
+
light: mode === "preview",
|
|
1598
|
+
children: /* @__PURE__ */ jsx(StageBoundary, { children: /* @__PURE__ */ jsx(
|
|
1599
|
+
Stage,
|
|
1600
|
+
{
|
|
1601
|
+
mode,
|
|
1602
|
+
selection,
|
|
1603
|
+
onChange: setSelection
|
|
1604
|
+
},
|
|
1605
|
+
selection.projectName || ""
|
|
1606
|
+
) }, `${selection.projectName}:${selection.slot}:${selection.id}`)
|
|
1607
|
+
}
|
|
1608
|
+
)
|
|
1609
|
+
] })
|
|
1580
1610
|
] })
|
|
1581
1611
|
] });
|
|
1582
1612
|
};
|
|
1583
1613
|
const Shell = ({ options, mode }) => {
|
|
1584
1614
|
const store = useMemo(() => {
|
|
1585
1615
|
const standaloneStore = createStandaloneStore();
|
|
1586
|
-
provideHost({
|
|
1616
|
+
provideHost({
|
|
1617
|
+
dispatch: standaloneStore.dispatch,
|
|
1618
|
+
getUser: () => getShellUser(standaloneStore.getState()),
|
|
1619
|
+
// navigateToEntity: the shell has none of AYON's pages, so the link opens in AYON
|
|
1620
|
+
navigate: (to) => window.open(new URL(to, options.serverUrl).href, "_blank", "noopener")
|
|
1621
|
+
});
|
|
1587
1622
|
return standaloneStore;
|
|
1588
1623
|
}, []);
|
|
1589
1624
|
return /* @__PURE__ */ jsxs(Provider, { store, children: [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ynput/ayon-addon-dev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Dev tooling for AYON addon frontends: Vite plugin, dev server, CLI, lint preset",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -41,10 +41,10 @@
|
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
43
|
"@module-federation/vite": ">=1.14",
|
|
44
|
-
"@ynput/ayon-addon": "
|
|
44
|
+
"@ynput/ayon-addon": "0.2.0",
|
|
45
45
|
"vite": ">=6",
|
|
46
|
-
"@ynput/ayon-frontend-shared": ">=0.7.
|
|
47
|
-
"@ynput/ayon-react-components": "^1.
|
|
46
|
+
"@ynput/ayon-frontend-shared": ">=0.7.6",
|
|
47
|
+
"@ynput/ayon-react-components": "^1.25.0",
|
|
48
48
|
"primereact": ">=9",
|
|
49
49
|
"react": ">=18",
|
|
50
50
|
"react-dom": ">=18",
|