@ynput/ayon-addon-dev 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,139 @@
1
+ # @ynput/ayon-addon-dev
2
+
3
+ Tooling for AYON addon frontends. The API itself (slots, `useSlot`, the manifest) is
4
+ `@ynput/ayon-addon`: read `node_modules/@ynput/ayon-addon/AGENTS.md` first.
5
+
6
+ ## Vite: `ayonAddon()`
7
+
8
+ ```ts
9
+ // vite.config.ts
10
+ import react from '@vitejs/plugin-react'
11
+ import { ayonAddon } from '@ynput/ayon-addon-dev/vite'
12
+
13
+ export default defineConfig({ plugins: [react(), ayonAddon({ name: 'my_addon' })] })
14
+ ```
15
+
16
+ - `name` is the federation remote: the key in the server's `frontend_modules`
17
+ (`{"my_addon": ["manifest"]}`), and the build goes to `dist/modules/<name>`.
18
+ - It exposes `./manifest` (`src/manifest.ts`, or `manifest`), plus `exposes` if the addon keeps
19
+ keyword modules for older AYON versions.
20
+ - It shares everything AYON provides (React, the store, the router, toasts, every
21
+ `@ynput/ayon-frontend-shared` entry) as singletons, and `@ynput/ayon-addon` with `import: false`:
22
+ the addon never bundles a copy of it.
23
+
24
+ ## CLI: `ayon-addon`
25
+
26
+ Run in the addon's frontend folder. Every command takes `--json` for scripts and agents.
27
+
28
+ | Command | What |
29
+ |---|---|
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` |
32
+ | `ayon-addon slots` | The slots of the installed `@ynput/ayon-addon`: helper, where, when the code loads |
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
+ | `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 |
35
+ | `ayon-addon preview <slot> <id> --fixture name [--run] [--prune] [--screenshot file]` | The same from `dev/fixtures/<name>.json`, without a server. `--prune` drops the answers it didn't use |
36
+ | `ayon-addon generate-rest [--schema file] [--hooks]` | The addon's server endpoints as RTK Query endpoints in AYON's api (below) |
37
+
38
+ `open` and `preview` start the dev shell themselves, or use a running one with `--url
39
+ http://localhost:5174`. They need playwright (`yarn add -D playwright`, `npx playwright install
40
+ chromium`); in the AYON dev workbench the ayon-frontend checkout's copy is used.
41
+
42
+ ## Developing inside AYON (`dev --ayon`)
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.
47
+ 3. `?addonDev=off`, or the banner's close button, goes back to the installed addon.
48
+
49
+ Only users in developer mode (and service users, for agents) can do this, and only from
50
+ `localhost`.
51
+
52
+ ## Developing without AYON: the dev shell (`dev`, the default)
53
+
54
+ `ayon-addon dev` serves the addon with Vite's dev server (hot reload) inside a dev shell instead
55
+ of AYON:
56
+
57
+ - **Real AYON surfaces** from `@ynput/ayon-frontend-shared`: the details panel, the slicer, the
58
+ Customize panel, and the addon columns' cells and `fetchValues`, in AYON's providers (store, api,
59
+ contexts). AYON's project list table, with only the addon's items in its menus (AYON's own,
60
+ such as open, pin and folders, need the app). Plain stand-ins for what lives in the AYON app: the header and the
61
+ table around the columns (its rows are the picked entities, its right-click menu has the
62
+ `entityMenuItem` items, and once clicked it takes the `table.shortcut` keys). Pages render alone, without AYON's page tabs.
63
+ - **Developer panel**, in two resizable halves that scroll on their own: the manifest's
64
+ contributions grouped by the area of AYON they show in (app header, studio, project with its
65
+ slicer and tables, details panel), then by slot, with the ones AYON would skip and why; below,
66
+ the context of the one on stage, the only place it is changed: a project switcher, the studio's
67
+ project selection (also what a studio page's project list shows), an entity picker (AYON's) for
68
+ the slots about entities, the table page, *Run* for menu items, a few lines of the context the
69
+ contribution gets (click for all of it), and the preview fixtures.
70
+ - Above the stage, a caption names the contribution, where AYON shows it and its slot.
71
+ - The selection is in the URL
72
+ (`?slot=…&contribution=…&ctxProject=…&ctxProjects=a,b&ctxEntities=task:<id>,…`).
73
+ - Requests to `/api`, `/graphql`, `/ws` go through the dev server to the AYON server, with the API
74
+ key when there is one (no login page); without a key the page shows AYON's login.
75
+ - It needs a `@ynput/ayon-frontend-shared` build with the addon SDK (until one is released:
76
+ `yarn copy-shared <ayon-frontend>` after `yarn build:shared` there).
77
+
78
+ ### Preview: fixtures instead of a server
79
+
80
+ `?preview=<name>` (or `ayon-addon preview`) renders one contribution with the context and server
81
+ answers of `dev/fixtures/<name>.json`, and no server:
82
+
83
+ ```json
84
+ {
85
+ "slot": "detailsPanel.tab",
86
+ "id": "comments",
87
+ "context": { "projectName": "demo", "entityType": "task", "entities": [{ "id": "…", "entityType": "task", "projectName": "demo" }], "scope": "addon-dev" },
88
+ "responses": { "graphql GetComments": { "status": 200, "body": { "data": { … } } } }
89
+ }
90
+ ```
91
+
92
+ - Answers are keyed `GET /api/<path>?<query>` or `graphql <OperationName>` (the variables don't
93
+ count). A request without an answer gets a 404 and fails the preview (`not in the fixture`).
94
+ - The user, the site info, the project list and the cloud status are answered by the shell (an
95
+ admin "preview" user): fixtures leave them out.
96
+ - Record one from a live server: *Save* in the developer panel, or `ayon-addon open … --save-fixture
97
+ <name>`; then `ayon-addon preview … --fixture <name> --prune`. A recording holds the server's real
98
+ data: check it before committing it (project names, users, comment text).
99
+
100
+ ## Server endpoints: `ayon-addon generate-rest`
101
+
102
+ Addons that add their own server endpoints generate the frontend side from the addon's OpenAPI
103
+ schema (needs `@rtk-query/codegen-openapi` as a dev dependency):
104
+
105
+ 1. Downloads `/api/addons/<name>/<version>/openapi.json` (name and version from `package.py`) from
106
+ a server with that version installed, to `gen/openapi.json` (gitignore it). AYON's own
107
+ `/openapi.json` has no addon routes. `--schema <file>` uses a saved schema instead.
108
+ 2. Generates `src/api/generated/<name>.ts`: the endpoints injected into AYON's api (one cache with
109
+ AYON's), without hooks. Operation ids are prefixed with the addon name, so endpoints can't
110
+ clash with AYON's or another addon's (`timetrackerStartSession`). URLs use the default export
111
+ of `src/addonVersion.ts`, so they call the running version.
112
+ 3. **Never edit the generated file.** Tags, `transformResponse` and optimistic updates go in
113
+ `src/api/<name>.ts`, which enhances the endpoints and exports their hooks. The first run creates
114
+ it; later runs list endpoints whose hooks it doesn't export and, in a terminal, offer to add
115
+ them in a block of their own (`--hooks`: without asking). Hooks of removed endpoints are only
116
+ reported.
117
+
118
+ Options in `ayonAddon({ rest: { ... } })`: `queries` (POST endpoints that only read, generated as
119
+ queries), `generated`, `api`, `versionModule`, `schema` (paths).
120
+
121
+ ## Signing in
122
+
123
+ - People: the AYON login.
124
+ - Agents and CI: `AYON_API_KEY` (a service user's key), sent as `x-api-key`. In the AYON dev
125
+ workbench the tools find the local stack and its key (`data/local-api-key`) on their own.
126
+ - `AYON_SERVER_URL` points the tools at another server (default `http://localhost:5000`, or the
127
+ workbench's local stack).
128
+
129
+ ## Lint preset
130
+
131
+ ```js
132
+ // eslint.config.js
133
+ import { ayonAddonLint } from '@ynput/ayon-addon-dev/eslint'
134
+ export default defineConfig([..., ...ayonAddonLint])
135
+ ```
136
+
137
+ Flags imports that bypass AYON's copies: `react-router-dom`, `react-router` and `react-toastify`
138
+ (use `@ynput/ayon-addon`), `@ynput/ayon-addon/host`, and a manifest importing
139
+ `@ynput/ayon-addon` instead of `@ynput/ayon-addon/manifest`.
package/LICENSE ADDED
@@ -0,0 +1,105 @@
1
+ # Functional Source License, Version 1.1, ALv2 Future License
2
+
3
+ ## Abbreviation
4
+
5
+ FSL-1.1-ALv2
6
+
7
+ ## Notice
8
+
9
+ Copyright 2025 Ynput s.r.o.
10
+
11
+ ## Terms and Conditions
12
+
13
+ ### Licensor ("We")
14
+
15
+ The party offering the Software under these Terms and Conditions.
16
+
17
+ ### The Software
18
+
19
+ The "Software" is each version of the software that we make available under
20
+ these Terms and Conditions, as indicated by our inclusion of these Terms and
21
+ Conditions with the Software.
22
+
23
+ ### License Grant
24
+
25
+ Subject to your compliance with this License Grant and the Patents,
26
+ Redistribution and Trademark clauses below, we hereby grant you the right to
27
+ use, copy, modify, create derivative works, publicly perform, publicly display
28
+ and redistribute the Software for any Permitted Purpose identified below.
29
+
30
+ ### Permitted Purpose
31
+
32
+ A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
33
+ means making the Software available to others in a commercial product or
34
+ service that:
35
+
36
+ 1. substitutes for the Software;
37
+
38
+ 2. substitutes for any other product or service we offer using the Software
39
+ that exists as of the date we make the Software available; or
40
+
41
+ 3. offers the same or substantially similar functionality as the Software.
42
+
43
+ Permitted Purposes specifically include using the Software:
44
+
45
+ 1. for your internal use and access;
46
+
47
+ 2. for non-commercial education;
48
+
49
+ 3. for non-commercial research; and
50
+
51
+ 4. in connection with professional services that you provide to a licensee
52
+ using the Software in accordance with these Terms and Conditions.
53
+
54
+ ### Patents
55
+
56
+ To the extent your use for a Permitted Purpose would necessarily infringe our
57
+ patents, the license grant above includes a license under our patents. If you
58
+ make a claim against any party that the Software infringes or contributes to
59
+ the infringement of any patent, then your patent license to the Software ends
60
+ immediately.
61
+
62
+ ### Redistribution
63
+
64
+ The Terms and Conditions apply to all copies, modifications and derivatives of
65
+ the Software.
66
+
67
+ If you redistribute any copies, modifications or derivatives of the Software,
68
+ you must include a copy of or a link to these Terms and Conditions and not
69
+ remove any copyright notices provided in or with the Software.
70
+
71
+ ### Disclaimer
72
+
73
+ THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
74
+ IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
75
+ PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
76
+
77
+ IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
78
+ SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
79
+ EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
80
+
81
+ ### Trademarks
82
+
83
+ Except for displaying the License Details and identifying us as the origin of
84
+ the Software, you have no right under these Terms and Conditions to use our
85
+ trademarks, trade names, service marks or product names.
86
+
87
+ ## Grant of Future License
88
+
89
+ We hereby irrevocably grant you an additional license to use the Software under
90
+ the Apache License, Version 2.0 that is effective on the second anniversary of
91
+ the date we make the Software available. On or after that date, you may use the
92
+ Software under the Apache License, Version 2.0, in which case the following
93
+ will apply:
94
+
95
+ Licensed under the Apache License, Version 2.0 (the "License"); you may not use
96
+ this file except in compliance with the License.
97
+
98
+ You may obtain a copy of the License at
99
+
100
+ http://www.apache.org/licenses/LICENSE-2.0
101
+
102
+ Unless required by applicable law or agreed to in writing, software distributed
103
+ under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
104
+ CONDITIONS OF ANY KIND, either express or implied. See the License for the
105
+ specific language governing permissions and limitations under the License.
@@ -0,0 +1,59 @@
1
+ export interface BrowseOptions {
2
+ root: string;
3
+ slot: string;
4
+ id: string;
5
+ /** preview mode: the fixture's name (dev/fixtures/<name>.json) */
6
+ fixture?: string;
7
+ project?: string;
8
+ /** `type:id,type:id` */
9
+ entities?: string;
10
+ page?: string;
11
+ /** a running `ayon-addon dev` */
12
+ url?: string;
13
+ screenshot?: string;
14
+ /** run the menu item (`run` piece) and report what it did */
15
+ run?: boolean;
16
+ /** live mode: save what the contribution loaded as dev/fixtures/<name>.json */
17
+ saveFixture?: string;
18
+ /** preview: keep only the fixture answers the page used */
19
+ prune?: boolean;
20
+ timeout?: number;
21
+ }
22
+ export interface BrowseResult {
23
+ ok: boolean;
24
+ mode: 'live' | 'preview';
25
+ url: string;
26
+ slot: string;
27
+ id: string;
28
+ /** the slot context the contribution got (functions and elements left out) */
29
+ context?: Record<string, unknown>;
30
+ /** contributions that crashed or failed to load (AYON's error boxes) */
31
+ contributionErrors: string[];
32
+ /** console errors and uncaught exceptions */
33
+ errors: string[];
34
+ warnings: string[];
35
+ toasts: string[];
36
+ /** with --run: what the item put on the clipboard */
37
+ clipboard?: string;
38
+ /** preview: requests the fixture has no answer for */
39
+ unmatched?: string[];
40
+ screenshot?: string;
41
+ savedFixture?: string;
42
+ /** with --prune: the answers removed from the fixture */
43
+ pruned?: number;
44
+ }
45
+ export declare const browse: (options: BrowseOptions) => Promise<BrowseResult>;
46
+ export declare const formatBrowse: (result: BrowseResult) => string;
47
+ declare global {
48
+ interface Window {
49
+ __AYON_ADDON_SHELL__?: {
50
+ ready: boolean;
51
+ unmatched: string[];
52
+ used: string[];
53
+ fixture: () => {
54
+ context: Record<string, unknown>;
55
+ } | undefined;
56
+ run: () => Promise<void>;
57
+ };
58
+ }
59
+ }
package/dist/browse.js ADDED
@@ -0,0 +1,156 @@
1
+ // `ayon-addon open | preview`: the dev shell in a headless browser, for agents and scripts.
2
+ // Starts the standalone dev server unless --url points at a running one, waits until the
3
+ // contribution rendered, and reports what happened (errors, toasts, the context it got).
4
+ import { mkdirSync, readFileSync, writeFileSync } from 'fs';
5
+ import { createRequire } from 'module';
6
+ import path from 'path';
7
+ import { pathToFileURL } from 'url';
8
+ import { findConnection, importFromProject } from './project.js';
9
+ import { STANDALONE_ENV } from './standalone.js';
10
+ // the addon's playwright, else the one next to this package (the ayon-frontend checkout)
11
+ const loadPlaywright = async (root) => {
12
+ // a CommonJS package: its API is the default export
13
+ const api = (module) => module.default ?? module;
14
+ try {
15
+ return api(await importFromProject(root, 'playwright'));
16
+ }
17
+ catch {
18
+ try {
19
+ const require = createRequire(import.meta.url);
20
+ return api(await import(pathToFileURL(require.resolve('playwright')).href));
21
+ }
22
+ catch {
23
+ throw new Error('playwright is not installed: yarn add -D playwright && npx playwright install chromium');
24
+ }
25
+ }
26
+ };
27
+ const startServer = async (root) => {
28
+ const vite = await importFromProject(root, 'vite');
29
+ process.env[STANDALONE_ENV] = '1';
30
+ const server = await vite.createServer({
31
+ root,
32
+ logLevel: 'error',
33
+ server: { port: 5180, strictPort: false },
34
+ });
35
+ await server.listen();
36
+ return server;
37
+ };
38
+ export const browse = async (options) => {
39
+ const mode = options.fixture ? 'preview' : 'live';
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)');
42
+ }
43
+ const server = options.url ? undefined : await startServer(options.root);
44
+ const base = options.url || server.resolvedUrls.local[0];
45
+ // the shell's URL parameters (shell/src/params.ts)
46
+ const params = new URLSearchParams({ slot: options.slot, contribution: options.id });
47
+ if (options.fixture)
48
+ params.set('preview', options.fixture);
49
+ if (options.project)
50
+ params.set('ctxProject', options.project);
51
+ if (options.entities)
52
+ params.set('ctxEntities', options.entities);
53
+ if (options.page)
54
+ params.set('ctxPage', options.page);
55
+ const url = `${base.replace(/\/$/, '')}/?${params}`;
56
+ const { chromium } = await loadPlaywright(options.root);
57
+ const browser = await chromium.launch();
58
+ const result = {
59
+ ok: false,
60
+ mode,
61
+ url,
62
+ slot: options.slot,
63
+ id: options.id,
64
+ contributionErrors: [],
65
+ errors: [],
66
+ warnings: [],
67
+ toasts: [],
68
+ };
69
+ let ready = false;
70
+ try {
71
+ const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
72
+ await context.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: base });
73
+ const page = await context.newPage();
74
+ page.on('console', (message) => {
75
+ const text = message.text();
76
+ // React's development warnings are console errors, but not failures
77
+ if (message.type() === 'error' && !text.startsWith('Warning:'))
78
+ result.errors.push(text);
79
+ else if (message.type() === 'error' || message.type() === 'warning')
80
+ result.warnings.push(text);
81
+ });
82
+ page.on('pageerror', (error) => result.errors.push(error.stack || error.message));
83
+ await page.goto(url);
84
+ const timeout = options.timeout ?? 30000;
85
+ try {
86
+ await page.waitForFunction(() => window.__AYON_ADDON_SHELL__?.ready === true, undefined, { timeout });
87
+ }
88
+ catch {
89
+ const text = await page.locator('body').innerText();
90
+ result.errors.push(`the stage didn't get ready in ${timeout} ms: ${text.slice(0, 300)}`);
91
+ }
92
+ // the contribution's own requests and lazy pieces
93
+ await page.waitForLoadState('networkidle').catch(() => undefined);
94
+ await page.waitForTimeout(1000);
95
+ ready = await page.evaluate(() => window.__AYON_ADDON_SHELL__?.ready === true);
96
+ if (options.run && ready) {
97
+ await page.evaluate(() => window.__AYON_ADDON_SHELL__.run());
98
+ await page.waitForTimeout(1000);
99
+ result.clipboard = await page.evaluate(() => navigator.clipboard.readText()).catch(() => undefined);
100
+ }
101
+ const status = await page.evaluate(() => {
102
+ const shell = window.__AYON_ADDON_SHELL__;
103
+ return shell && { fixture: shell.fixture(), unmatched: shell.unmatched, used: shell.used };
104
+ });
105
+ result.context = status?.fixture?.context;
106
+ if (mode === 'preview')
107
+ result.unmatched = status?.unmatched;
108
+ // without the box's icon name
109
+ result.contributionErrors = (await page.locator('.ayon-addon-error').allInnerTexts()).map((text) => text.replace(/^error\s+/, ''));
110
+ result.toasts = await page.locator('.Toastify__toast').allInnerTexts();
111
+ if (options.prune && options.fixture && status && !status.unmatched.length) {
112
+ const file = path.join(options.root, 'dev', 'fixtures', `${options.fixture}.json`);
113
+ const fixture = JSON.parse(readFileSync(file, 'utf8'));
114
+ const keys = Object.keys(fixture.responses);
115
+ fixture.responses = Object.fromEntries(keys.filter((key) => status.used.includes(key)).map((key) => [key, fixture.responses[key]]));
116
+ writeFileSync(file, `${JSON.stringify(fixture, null, 2)}\n`);
117
+ result.pruned = keys.length - Object.keys(fixture.responses).length;
118
+ }
119
+ if (options.saveFixture && status?.fixture) {
120
+ const file = path.join(options.root, 'dev', 'fixtures', `${options.saveFixture}.json`);
121
+ mkdirSync(path.dirname(file), { recursive: true });
122
+ writeFileSync(file, `${JSON.stringify(status.fixture, null, 2)}\n`);
123
+ result.savedFixture = path.relative(options.root, file);
124
+ }
125
+ if (options.screenshot) {
126
+ const file = path.resolve(options.root, options.screenshot);
127
+ mkdirSync(path.dirname(file), { recursive: true });
128
+ await page.screenshot({ path: file });
129
+ result.screenshot = file;
130
+ }
131
+ }
132
+ finally {
133
+ await browser.close();
134
+ await server?.close();
135
+ }
136
+ // a failed contribution logs an "[addon <name>]" error (ContributionView, runContribution)
137
+ const addonErrors = result.errors.filter((error) => error.startsWith('[addon '));
138
+ result.ok =
139
+ ready &&
140
+ !!result.context &&
141
+ result.contributionErrors.length === 0 &&
142
+ addonErrors.length === 0 &&
143
+ !(result.unmatched?.length);
144
+ return result;
145
+ };
146
+ export const formatBrowse = (result) => [
147
+ `${result.ok ? 'ok' : 'FAILED'}: ${result.slot} ${result.id} (${result.mode}) ${result.url}`,
148
+ ...result.contributionErrors.map((error) => ` error box: ${error}`),
149
+ ...result.errors.map((error) => ` console error: ${error.split('\n')[0]}`),
150
+ ...result.toasts.map((toast) => ` toast: ${toast}`),
151
+ ...(result.clipboard ? [` clipboard: ${result.clipboard}`] : []),
152
+ ...(result.unmatched || []).map((key) => ` not in the fixture: ${key}`),
153
+ ...(result.savedFixture ? [` saved ${result.savedFixture}`] : []),
154
+ ...(result.pruned !== undefined ? [` pruned ${result.pruned} unused answers from the fixture`] : []),
155
+ ...(result.screenshot ? [` screenshot ${result.screenshot}`] : []),
156
+ ].join('\n');
@@ -0,0 +1,30 @@
1
+ export interface CheckResult {
2
+ ok: boolean;
3
+ addon: {
4
+ name: string;
5
+ version: string;
6
+ };
7
+ manifest: string;
8
+ sdkVersion?: string;
9
+ contributions: {
10
+ slot: string;
11
+ id: string;
12
+ key: string;
13
+ pieces: Record<string, string | null>;
14
+ }[];
15
+ problems: {
16
+ slot?: string;
17
+ id?: string;
18
+ reason: string;
19
+ }[];
20
+ types?: {
21
+ ok: boolean;
22
+ errors: string[];
23
+ };
24
+ }
25
+ export declare const checkAddon: ({ root, manifest, types, }?: {
26
+ root?: string;
27
+ manifest?: string;
28
+ types?: boolean;
29
+ }) => Promise<CheckResult>;
30
+ export declare const formatCheck: (result: CheckResult) => string;
package/dist/check.js ADDED
@@ -0,0 +1,117 @@
1
+ // `ayon-addon check`: the manifest the way AYON reads it (slots, ids, fields, pieces), that every
2
+ // piece's file exists, and the addon's types.
3
+ import { spawnSync } from 'child_process';
4
+ import { existsSync, mkdirSync, writeFileSync } from 'fs';
5
+ import { createRequire } from 'module';
6
+ import path from 'path';
7
+ import { pathToFileURL } from 'url';
8
+ import { importFromProject, installedVersion, readAddonProject } from './project.js';
9
+ const EXTENSIONS = ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx', '/index.js'];
10
+ // a piece is `() => import('./File')`: find the file it imports
11
+ const pieceFile = (root, manifestDir, piece) => {
12
+ if (typeof piece !== 'function')
13
+ return undefined;
14
+ const specifier = String(piece).match(/import\(\s*["'`]([^"'`]+)["'`]\s*\)/)?.[1];
15
+ if (!specifier)
16
+ return null;
17
+ const base = specifier.startsWith('@/')
18
+ ? path.join(root, 'src', specifier.slice(2))
19
+ : path.resolve(manifestDir, specifier);
20
+ const found = EXTENSIONS.map((ext) => base + ext).find((file) => existsSync(file));
21
+ return found ? path.relative(root, found) : `missing: ${specifier}`;
22
+ };
23
+ // bundles the manifest without its pieces (dynamic imports stay imports) and imports it
24
+ const loadManifest = async (root, manifestPath) => {
25
+ const vite = await importFromProject(root, 'vite');
26
+ const output = await vite.build({
27
+ root,
28
+ configFile: false,
29
+ logLevel: 'silent',
30
+ resolve: { alias: { '@': path.join(root, 'src') } },
31
+ plugins: [
32
+ {
33
+ name: 'ayon-addon:check',
34
+ resolveDynamicImport: (specifier) => typeof specifier === 'string' ? { id: specifier, external: true } : null,
35
+ },
36
+ ],
37
+ build: { ssr: manifestPath, write: false, minify: false, rollupOptions: { output: { format: 'es' } } },
38
+ });
39
+ const outputs = (Array.isArray(output) ? output : [output]);
40
+ const entry = outputs.flatMap((o) => o.output).find((chunk) => chunk.type === 'chunk' && chunk.isEntry);
41
+ if (!entry?.code)
42
+ throw new Error(`could not build ${manifestPath}`);
43
+ const dir = path.join(root, 'node_modules', '.ayon-addon');
44
+ mkdirSync(dir, { recursive: true });
45
+ const file = path.join(dir, 'manifest-check.mjs');
46
+ writeFileSync(file, entry.code);
47
+ const module = await import(`${pathToFileURL(file).href}?t=${Date.now()}`);
48
+ return module.default;
49
+ };
50
+ const typeCheck = (root) => {
51
+ const require = createRequire(path.join(root, 'package.json'));
52
+ const tsc = require.resolve('typescript/bin/tsc');
53
+ const result = spawnSync(process.execPath, [tsc, '--noEmit', '-p', path.join(root, 'tsconfig.json')], {
54
+ cwd: root,
55
+ encoding: 'utf8',
56
+ });
57
+ const errors = `${result.stdout}${result.stderr}`
58
+ .split('\n')
59
+ .filter((line) => / error TS\d+/.test(line));
60
+ return { ok: result.status === 0, errors };
61
+ };
62
+ export const checkAddon = async ({ root = process.cwd(), manifest = 'src/manifest.ts', types = true, } = {}) => {
63
+ const project = readAddonProject(root);
64
+ const manifestPath = path.resolve(root, manifest);
65
+ const result = {
66
+ ok: true,
67
+ addon: { name: project.name, version: project.version },
68
+ manifest,
69
+ contributions: [],
70
+ problems: [],
71
+ };
72
+ const sdk = await importFromProject(root, '@ynput/ayon-addon/check');
73
+ result.sdkVersion = installedVersion(root, '@ynput/ayon-addon');
74
+ let loaded;
75
+ try {
76
+ loaded = await loadManifest(root, manifestPath);
77
+ }
78
+ catch (error) {
79
+ result.problems.push({ reason: `the manifest failed to load: ${error.message}` });
80
+ }
81
+ if (loaded !== undefined) {
82
+ const { contributions, skipped } = sdk.checkManifest({ name: project.name, version: project.version, remote: 'check' }, loaded);
83
+ result.problems.push(...skipped.map(({ slot, id, reason }) => ({ slot, id, reason })));
84
+ for (const contribution of contributions) {
85
+ const definition = sdk.SLOTS[contribution.slot];
86
+ const pieceNames = [...definition.pieces, ...(definition.optionalPieces || [])];
87
+ const pieces = {};
88
+ for (const name of pieceNames) {
89
+ const file = pieceFile(root, path.dirname(manifestPath), contribution[name]);
90
+ if (file === undefined)
91
+ continue;
92
+ pieces[name] = file;
93
+ if (file?.startsWith('missing:')) {
94
+ result.problems.push({ slot: contribution.slot, id: contribution.id, reason: `${name}: ${file}` });
95
+ }
96
+ }
97
+ result.contributions.push({ slot: contribution.slot, id: contribution.id, key: contribution.key, pieces });
98
+ }
99
+ }
100
+ if (types)
101
+ result.types = typeCheck(root);
102
+ result.ok = !result.problems.length && result.types?.ok !== false;
103
+ return result;
104
+ };
105
+ export const formatCheck = (result) => {
106
+ const lines = [
107
+ `${result.addon.name} ${result.addon.version}, ${result.manifest} (@ynput/ayon-addon ${result.sdkVersion})`,
108
+ ...result.contributions.map((c) => ` ok ${c.slot.padEnd(30)} ${c.id.padEnd(24)} ${Object.values(c.pieces).filter(Boolean).join(', ')}`),
109
+ ...result.problems.map((p) => ` !! ${(p.slot || '').padEnd(30)} ${(p.id || '').padEnd(24)} ${p.reason}`),
110
+ ];
111
+ if (result.types) {
112
+ lines.push(result.types.ok ? ' ok types' : ` !! types: ${result.types.errors.length} errors`);
113
+ lines.push(...result.types.errors.slice(0, 20).map((e) => ` ${e}`));
114
+ }
115
+ lines.push(result.ok ? 'check passed' : 'check failed');
116
+ return lines.join('\n');
117
+ };
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};