@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 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. Prints the AYON URL that loads it: `<server>/?addonDev=<addon>@http://localhost:5173` |
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. Open the URL it prints. The page loads your addon from the dev server instead of the installed
46
- version (also when it isn't installed) and shows a banner. It reloads after every rebuild.
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
- Only users in developer mode (and service users, for agents) can do this, and only from
50
- `localhost`.
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('no API key for the headless browser: set AYON_API_KEY (and AYON_SERVER_URL)');
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 AYON's dev override (?addonDev=)
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 { findConnection, findPackageDir, importFromProject, readAddonProject } from './project.js';
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
- const serveDirectory = (dir, port) => createServer((request, response) => {
41
- response.setHeader('Access-Control-Allow-Origin', '*');
42
- // agents' headless browsers send x-api-key on every request, which makes these preflighted
43
- response.setHeader('Access-Control-Allow-Headers', '*');
44
- response.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
45
- response.setHeader('Access-Control-Allow-Private-Network', 'true');
46
- response.setHeader('Cache-Control', 'no-store');
47
- if (request.method === 'OPTIONS')
48
- return void response.writeHead(204).end();
49
- const urlPath = decodeURIComponent((request.url || '/').split('?')[0]);
50
- const file = path.join(dir, path.normalize(urlPath).replace(/^(\.\.[/\\])+/, ''));
51
- if (!file.startsWith(dir) || !existsSync(file) || !statSync(file).isFile()) {
52
- return void response.writeHead(404).end('not found');
53
- }
54
- response.writeHead(200, {
55
- 'Content-Type': CONTENT_TYPES[path.extname(file)] || 'application/octet-stream',
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
- createReadStream(file).pipe(response);
58
- }).listen(port);
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 && { dir: path.dirname(file), info: JSON.parse(readFileSync(file, 'utf8')) };
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
- const watcher = (await vite.build({
97
- root,
98
- mode: 'development',
99
- build: { watch: {} },
100
- logLevel: 'warn',
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
- const built = findDevInfo();
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
- serveDirectory(built.dir, port);
155
+ const { info } = built;
119
156
  const url = `http://localhost:${port}`;
120
- const open = `${findConnection(root).serverUrl}/?addonDev=${project.name}@${url}`;
121
- print({ url, addon: project.name, remote: built.info.remote, buildId: built.info.buildId, open }, [
122
- `${project.name} ${project.version}: built, serving ${url} (remote "${built.info.remote}")`,
123
- 'Open AYON with this addon from here (developer mode on):',
124
- ` ${open}`,
125
- 'The page reloads after every rebuild. ?addonDev=off goes back to the installed addon.',
126
- ].join('\n'));
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] build on every change and serve it for AYON's dev override:
211
- open AYON with ?addonDev=<addon>@http://localhost:5173
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; in the AYON dev workbench, the local stack and
230
- data/local-api-key.`;
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
- console.error(json ? JSON.stringify({ ok: false, error: String(error) }) : error);
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 { existsSync, readFileSync } from 'fs';
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)}` : 'http://localhost:5000');
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
- const { serverUrl, apiKey } = findConnection(root);
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: apiKey ? { 'x-api-key': apiKey } : {} });
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));
@@ -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, ProjectContextProvider, ProjectFoldersContextProvider, useSettingsPanel, useScopedDetailsPanel, EntityUpdatesProvider, GlobalProvider, PowerpackProvider, MenuProvider, URIProvider, DetailsPanelProvider, PiPProvider, DeleteEntitiesProvider, SettingsPanelProvider } from "@ynput/ayon-frontend-shared/context";
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 picker reads the project's folders, like on a project page
685
- /* @__PURE__ */ jsx(ProjectContextProvider, { projectName, children: /* @__PURE__ */ jsx(ProjectFoldersContextProvider, { projectName, children: /* @__PURE__ */ jsx(
686
- EntityPickerDialog,
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
- isMultiSelect: true,
699
+ multiSelect: true,
700
+ initialSelection: entities,
691
701
  onClose: () => setPicking(false),
692
- onSubmit: (ids) => {
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, selectedEntities: entities },
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: Object.keys(rowSelection).filter((id) => rowSelection[id]).map((id) => ({ id })),
1294
+ selected: ids.map((id) => ({ id })),
1295
+ entities,
1271
1296
  menu: "context"
1272
- }),
1273
- [selection.projectName, sliceType, rowSelection]
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(Splitter, { className: "ayon-shell", stateKey: "ayon-addon-shell", stateStorage: "local", children: [
1561
- /* @__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 }) }) }),
1562
- /* @__PURE__ */ jsxs(SplitterPanel, { size: 78, style: { overflow: "hidden", display: "flex", flexDirection: "column" }, children: [
1563
- /* @__PURE__ */ jsx(StageCaption, { mode, selection }),
1564
- /* @__PURE__ */ jsx(
1565
- ProjectProviders,
1566
- {
1567
- projectName: mode === "live" || selection.slot === "tableColumn" ? selection.projectName : void 0,
1568
- light: mode === "preview",
1569
- children: /* @__PURE__ */ jsx(StageBoundary, { children: /* @__PURE__ */ jsx(
1570
- Stage,
1571
- {
1572
- mode,
1573
- selection,
1574
- onChange: setSelection
1575
- },
1576
- selection.projectName || ""
1577
- ) }, `${selection.projectName}:${selection.slot}:${selection.id}`)
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({ dispatch: standaloneStore.dispatch });
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.1.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": "^0.1.0",
44
+ "@ynput/ayon-addon": "0.2.0",
45
45
  "vite": ">=6",
46
- "@ynput/ayon-frontend-shared": ">=0.7.4",
47
- "@ynput/ayon-react-components": "^1.23.0",
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",