diff --git a/packages/create-youtrack-app/README.md b/packages/create-youtrack-app/README.md index ccfedd16..c699034e 100644 --- a/packages/create-youtrack-app/README.md +++ b/packages/create-youtrack-app/README.md @@ -13,7 +13,22 @@ To learn more about app development for YouTrack, please refer to our [Developer 1. Create an empty directory for your app. 2. Run `npm create @jetbrains/youtrack-app`. -3. Follow the prompts in the generator. +3. Follow the prompts in the generator. If you choose JavaScript, the initial project contains app metadata and build tooling only. Add rules, settings, entity extensions, widgets, and handlers when you need them. + +For non-interactive app creation, pass the metadata as flags. The default type is `ts`, and dependencies are installed after scaffolding: + +```bash +npx @jetbrains/create-youtrack-app \ + app init \ + --name my-youtrack-app \ + --title "My YouTrack App" \ + --description "Internal YouTrack app" \ + --vendor "My Company" \ + --vendor-url "https://example.com" +``` + +Use `--type ts` to create a TypeScript app with Enhanced DX, or `--type js` for the basic JavaScript app. +For a TypeScript app without the sample widget, add `--backend-only`. ## Adding Features to a Generated App @@ -26,28 +41,53 @@ After you have generated an app, you may want to add more features. Add new feat | Add another [widget](https://www.jetbrains.com/help/youtrack/devportal-apps/apps-widgets.html) | `npx @jetbrains/create-youtrack-app widget add` | | Declare an [extension property](https://www.jetbrains.com/help/youtrack/devportal-apps/apps-extension-properties.html) | `npx @jetbrains/create-youtrack-app extension-property add` | | Add an [HTTP handler](https://www.jetbrains.com/help/youtrack/devportal-apps/apps-reference-http-handlers.html) | `npx @jetbrains/create-youtrack-app http-handler add` | +| Add a typed HTTP endpoint (TypeScript Enhanced DX only) | `npx @jetbrains/create-youtrack-app endpoint add` | +| Add a classic workflow rule | `npx @jetbrains/create-youtrack-app rule add --type onChange --name notify-on-change` | | View a list of available commands | `npx @jetbrains/create-youtrack-app --help` | +## App Skill Commands + +The skill gives supported AI coding agents YouTrack app development guidance. +It is installed from the copy included with the CLI package. + +| Command | Description | +| --- | --- | +| `npx @jetbrains/create-youtrack-app skill install` | Detects supported agents and lets you choose agents plus global or project installation. | +| `npx @jetbrains/create-youtrack-app skill status` | Shows global and project installation status. | + +Supported agents are Claude Code, Codex CLI and Junie. Global installs use symlinks in the agent home config. Project installs use hard copies under the current directory. If the included skill is unavailable, installation fails with an explicit error. + +## Classic Workflow Rules + +**Syntax:** `npx @jetbrains/create-youtrack-app rule add --type --name ` +- ``: `onChange`, `onSchedule`, `action`, `stateMachine`, or `sla` +- ``: lowercase dashed filename stem, for example `notify-on-change` +- JavaScript apps create `src/.js`, beside handlers and shared helpers. +- TypeScript Enhanced DX apps create `src/workflows/.ts`. + +This command only scaffolds the classic workflow source file and does not update `manifest.json`. + +Generated JavaScript apps use one build command. `npm run build` packages a backend-only app when `manifest.json` has no widgets, and runs the full widget build after widgets are added. + ### Enhanced DX: NestJS-Style Code Generation Apps created with **Enhanced DX (TypeScript)** include a simplified, NestJS-inspired code generation workflow: #### Quick Commands -Generated Enhanced DX apps include `npm run generate` (or `npm run g` for short), with support for smart positional arguments: +Generated Enhanced DX apps include `npm run generate` (or `npm run g` for short), using the same entity/action command shape: **HTTP Handlers:** ```bash -npm run g -- handler global/health # GET handler (default) -npm run g -- handler project/users --method POST # Override method -npm run g -- h issue/comments --method POST --permissions read-issue,update-issue +npm run g -- http-handler add --scope global --path health # GET handler (default) +npm run g -- http-handler add --scope project --path users --method POST # Override method ``` **Extension Properties:** ```bash -npm run g -- property Issue.customStatus # string type (default) -npm run g -- property Comment.rating --type integer # Override type -npm run g -- p Issue.tags --type string --set # Multi-value property +npm run g -- extension-property add --entity Issue --name customStatus # string type (default) +npm run g -- extension-property add --entity Project --name rating --type integer # Override type +npm run g -- extension-property add --entity Issue --name tags --type string --set # Multi-value property ``` **App Settings:** @@ -55,7 +95,6 @@ npm run g -- p Issue.tags --type string --set # Multi-value property npm run g -- settings init --title "..." --description "..." # Create settings schema npm run g -- settings init # Interactive mode npm run g -- settings add # Add property (interactive) -npm run g -- s init --title "My Settings" --description "..." # Short alias ``` **Interactive Menu:** @@ -65,27 +104,31 @@ npm run g # Shows a menu for choosin #### Syntax Reference -**HTTP Handler:** `npm run g -- handler / [--method METHOD] [--permissions PERMS]` +**HTTP Handler:** `npm run g -- http-handler add --scope [--path ] [--method METHOD] [--permissions PERMS]` - ``: `global`, `project`, `issue`, `article`, or `user` - ``: Route path (can be nested with `/`) - `--method`: `GET`, `POST`, `PUT`, `DELETE` (default: `GET`) - `--permissions`: Comma-separated permissions (optional) -- **Aliases:** `handler`, `h` -**Extension Property:** `npm run g -- property . [--type TYPE] [--set]` +**Typed Endpoint:** `npx @jetbrains/create-youtrack-app endpoint add [--scope ] [--path ] [--method METHOD] [--request-type TYPE] [--response-type TYPE] [--controller NAME]` +- TypeScript Enhanced DX apps only; omit the options for interactive prompts. +- ``: `global`, `issue`, `project`, or `custom` +- ``: Route path below the selected scope +- `--method`: `GET`, `POST`, `PUT`, `DELETE` (default: `GET`) +- `--request-type` and `--response-type`: Type names or `never` (default: `never`) +- `--controller`: Existing exported controller name; omit to generate an inline handler + +**Extension Property:** `npm run g -- extension-property add --entity --name [--type TYPE] [--set]` - ``: `Issue`, `User`, `Project`, or `Article` - ``: Property name (valid identifier) - `--type`: `string`, `integer`, `float`, `boolean`, `Issue`, `User`, `Project`, or `Article` (default: `string`) - `--set`: Makes it multi-value (optional) -- **Aliases:** `property`, `prop`, `p` **App Settings:** `npm run g -- settings init [--title TITLE] [--description DESC]` - `init`: Initialize settings schema - With args: `--title` and `--description` create the schema directly (useful for tests) - Without args: interactive prompts for the title and description - `add`: Adds a new property to an existing settings schema (interactive only) -- **Aliases:** `settings`, `setting`, `s` - ### Contributing diff --git a/packages/create-youtrack-app/_templates/endpoint/add/index.js b/packages/create-youtrack-app/_templates/endpoint/add/index.js index 6aefdd4c..d2d81778 100644 --- a/packages/create-youtrack-app/_templates/endpoint/add/index.js +++ b/packages/create-youtrack-app/_templates/endpoint/add/index.js @@ -1,62 +1,86 @@ const { validateNotEmpty } = require("../../utils"); module.exports = { - prompt: ({ prompter }) => { - return prompter - .prompt([ - { - type: 'select', - name: 'pathPrefix', - message: 'Which endpoint scope do you want to use?', - choices: [ - { name: 'global', message: 'global - Global endpoint' }, - { name: 'issue', message: 'issue - Issue-specific endpoint' }, - { name: 'project', message: 'project - Project-specific endpoint' }, - { name: 'custom', message: 'custom - Enter a custom path' } - ] - }, - { - type: 'input', - name: 'pathSuffix', - message: ({ pathPrefix }) => pathPrefix === 'custom' - ? 'What path should this endpoint use? (relative to router/, for example, integration/trigger)' - : `What path should this endpoint use after ${pathPrefix}/? (for example, testSteps)`, - validate: validateNotEmpty - }, - { - type: 'select', - name: 'method', - message: 'Which HTTP method should this endpoint use?', - choices: ['GET', 'POST', 'PUT', 'DELETE'] - }, - { + prompt: async ({ prompter, args }) => { + const scopeChoices = ['global', 'issue', 'project', 'custom']; + const methodChoices = ['GET', 'POST', 'PUT', 'DELETE']; + const hasEndpointFlags = ['scope', 'path', 'method', 'request-type', 'response-type', 'controller'] + .some(flag => Object.hasOwn(args, flag)); + + const pathPrefix = args.scope || (await prompter.prompt({ + type: 'select', + name: 'pathPrefix', + message: 'Which endpoint scope do you want to use?', + choices: [ + { name: 'global', message: 'global - Global endpoint' }, + { name: 'issue', message: 'issue - Issue-specific endpoint' }, + { name: 'project', message: 'project - Project-specific endpoint' }, + { name: 'custom', message: 'custom - Enter a custom path' } + ] + })).pathPrefix; + + if (!scopeChoices.includes(String(pathPrefix))) { + throw new Error(`Invalid endpoint scope: ${pathPrefix}`); + } + + const pathSuffix = args.path || (await prompter.prompt({ + type: 'input', + name: 'pathSuffix', + message: pathPrefix === 'custom' + ? 'What path should this endpoint use? (relative to router/, for example, integration/trigger)' + : `What path should this endpoint use after ${pathPrefix}/? (for example, testSteps)`, + validate: validateNotEmpty + })).pathSuffix; + + const method = String(args.method || (await prompter.prompt({ + type: 'select', + name: 'method', + message: 'Which HTTP method should this endpoint use?', + choices: methodChoices + })).method).toUpperCase(); + if (!methodChoices.includes(method)) { + throw new Error(`Invalid endpoint method: ${method}`); + } + + let reqType = args['request-type']; + if (reqType === undefined) { + reqType = hasEndpointFlags + ? 'never' + : (await prompter.prompt({ type: 'input', name: 'reqType', message: 'What request type should this endpoint use? (for example, MyReqDto or never)', initial: 'never' - }, - { + })).reqType; + } + + let resType = args['response-type']; + if (resType === undefined) { + resType = hasEndpointFlags + ? 'never' + : (await prompter.prompt({ type: 'input', name: 'resType', message: 'What response type should this endpoint use? (for example, MyResDto or never)', initial: 'never' - }, - { - type: 'input', - name: 'controller', - message: 'Which controller function should this endpoint call? Leave empty to generate the handler directly in this file.' - } - ]) - .then(({ pathPrefix, pathSuffix, method, reqType, resType, controller }) => { - const path = pathPrefix === 'custom' ? pathSuffix : `${pathPrefix}/${pathSuffix}`; - const folderPath = path.replace(/^\//, ''); // strip leading slash - return { - folderPath, - method: method.toUpperCase(), - reqType, - resType, - controller - }; - }); + })).resType; + } + + const controller = args.controller !== undefined + ? args.controller + : hasEndpointFlags ? '' : (await prompter.prompt({ + type: 'input', + name: 'controller', + message: 'Which controller function should this endpoint call? Leave empty to generate the handler directly in this file.' + })).controller; + + const endpointPath = pathPrefix === 'custom' ? pathSuffix : `${pathPrefix}/${pathSuffix}`; + return { + folderPath: endpointPath.replace(/^\//, ''), + method, + reqType: String(reqType || 'never'), + resType: String(resType || 'never'), + controller: String(controller || '') + }; } }; diff --git a/packages/create-youtrack-app/_templates/http-handler/add/enhanced-dx/handler.ts.t b/packages/create-youtrack-app/_templates/http-handler/add/enhanced-dx/handler.ts.t deleted file mode 100644 index b5ec9314..00000000 --- a/packages/create-youtrack-app/_templates/http-handler/add/enhanced-dx/handler.ts.t +++ /dev/null @@ -1,64 +0,0 @@ -to: "<%= (() => { const clean = String(routePath || '').split('/').filter(Boolean).join('/'); const p = 'src/backend/router/' + ytScope + (clean ? '/' + clean : '') + '/' + method + '.ts'; return p; })() %>" ---- -<% - const toPascal = (s) => s.split(/[\/_-]+/).filter(Boolean).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(''); - const segments = (routePath || '').split('/').filter(Boolean); - const base = toPascal([ytScope].concat(segments).join('/')) || 'Root'; - const reqType = base + method + 'Req'; - const resType = base + method + 'Res'; - const ctxType = (method === 'GET' || method === 'HEAD') ? 'CtxGet' - : method === 'DELETE' ? 'CtxDelete' - : (method === 'PUT' || method === 'PATCH') ? 'CtxPut' - : 'CtxPost'; - const perms = (permissions || '').split(',').map(s => s.trim()).filter(Boolean); -%> -<% if (perms.length) { %>import { withPermissions } from '@jetbrains/youtrack-apps-tools/dx/runtime'; -<% } %> - -/** - * @zod-to-schema - */ -export type <%= reqType %> = { -<% if (method === 'GET' || method === 'DELETE') { %> - // Query parameters - // projectId will route calls via project scope when present - projectId?: string; - // Add your query params here - message?: string; -<% } else { %> - // JSON body payload - // projectId will route calls via project scope when present - projectId?: string; - // Add your body fields here - message?: string; -<% } %> -}; - -/** - * @zod-to-schema - */ -export type <%= resType %> = { - ok: boolean; - message: string; - timestamp: number; -}; - -function handle(ctx: <%- (method === 'GET' || method === 'HEAD' || method === 'DELETE') ? `${ctxType}<${resType}, ${reqType}>` : `${ctxType}<${reqType}, ${resType}>` %>): void { -<% if (method === 'GET' || method === 'DELETE') { %> - const msg = ctx.request.getParameter('message') || 'Hello from <%= ytScope %>/<%= (routePath || "") %> <%= method %>!'; -<% } else { %> - const body = ctx.request.json() as <%= reqType %>; - const msg = body.message || 'Hello from <%= ytScope %>/<%= (routePath || "") %> <%= method %>!'; -<% } %> - const response: <%= resType %> = { - ok: true, - message: msg, - timestamp: Date.now() - }; - - ctx.response.json(response); -} - -export default <%- perms.length ? `withPermissions(handle, [${perms.map(p => `'${p}'`).join(', ')}])` : 'handle' %>; - -export type Handle = typeof handle; diff --git a/packages/create-youtrack-app/_templates/http-handler/add/index.js b/packages/create-youtrack-app/_templates/http-handler/add/index.js index 6263f50f..a8ae4d79 100644 --- a/packages/create-youtrack-app/_templates/http-handler/add/index.js +++ b/packages/create-youtrack-app/_templates/http-handler/add/index.js @@ -11,41 +11,89 @@ if (isEnhancedDX) { return; } -module.exports = [ - { - type: "input", - name: "handlerName", - initial: 'http-handler', - validate: validateNotEmpty, - message: "What do you want to name this HTTP handler?", - }, - { - type: "input", - name: "path", - initial: 'my-path', - validate: validateNotEmpty, - message: "What path should this handler respond to?", - }, - { - type: "select", - name: "method", - initial: 'GET', - message: "Which HTTP method should this handler respond to?", - choices: ['GET', 'POST', 'PUT', 'DELETE'] - }, - { - type: "select", - name: "handlerScope", - message: "Do you want this HTTP handler to be globally available or scoped to an entity?", - choices: ['global', 'user', 'issue', 'article', 'project'] - }, - { - type: "multiselect", - name: "permissions", - message: "Do you want to limit access to this handler based on permissions? Leave empty to make it available to everyone.", - choices: PERMISSIONS.map(({ key, description }) => ({ - message: `"${key}": ${description}`, - name: key, - })) +function parsePermissions(value) { + if (Array.isArray(value)) { + return value; } -]; + + if (value === undefined) { + return undefined; + } + + return String(value).split(',').map(permission => permission.trim()).filter(Boolean); +} + +module.exports = { + prompt: async ({ prompter, args }) => { + const answers = {}; + + if (args.handlerName) { + answers.handlerName = String(args.handlerName); + } else { + const response = await prompter.prompt({ + type: "input", + name: "handlerName", + initial: 'http-handler', + validate: validateNotEmpty, + message: "What do you want to name this HTTP handler?", + }); + answers.handlerName = response.handlerName; + } + + if (args.path) { + answers.path = String(args.path); + } else { + const response = await prompter.prompt({ + type: "input", + name: "path", + initial: 'my-path', + validate: validateNotEmpty, + message: "What path should this handler respond to?", + }); + answers.path = response.path; + } + + if (args.method) { + answers.method = String(args.method).toUpperCase(); + } else { + const response = await prompter.prompt({ + type: "select", + name: "method", + initial: 'GET', + message: "Which HTTP method should this handler respond to?", + choices: ['GET', 'POST', 'PUT', 'DELETE'] + }); + answers.method = response.method; + } + + if (args.handlerScope) { + answers.handlerScope = String(args.handlerScope); + } else { + const response = await prompter.prompt({ + type: "select", + name: "handlerScope", + message: "Do you want this HTTP handler to be globally available or scoped to an entity?", + choices: ['global', 'user', 'issue', 'article', 'project'] + }); + answers.handlerScope = response.handlerScope; + } + + const permissions = parsePermissions(args.permissions); + if (permissions !== undefined) { + answers.permissions = permissions; + } else { + const response = await prompter.prompt({ + type: "multiselect", + name: "permissions", + message: "Do you want to limit access to this handler based on permissions? Leave empty to make it available to everyone.", + choices: PERMISSIONS.map(({ key, description }) => ({ + message: `"${key}": ${description}`, + name: key, + })) + }); + answers.permissions = response.permissions; + } + + return answers; + }, +}; diff --git a/packages/create-youtrack-app/_templates/init/enhanced-dx/AGENTS.md.t b/packages/create-youtrack-app/_templates/init/enhanced-dx/AGENTS.md.t deleted file mode 100644 index 69f2a9f3..00000000 --- a/packages/create-youtrack-app/_templates/init/enhanced-dx/AGENTS.md.t +++ /dev/null @@ -1,278 +0,0 @@ ---- -to: AGENTS.md ---- -# AGENTS.md — <%= title %> - -AI agent context for working in this YouTrack app. - ---- - -## What This Project Is - -A **YouTrack app** built with the Enhanced DX template. It runs inside a live YouTrack instance — there is no local sandbox. Changes are deployed by uploading a built artifact to YouTrack via `npm run upload-local` (or automatically in watch mode). - -The codebase has two distinct halves that are built separately: - -- **Backend** (`src/backend/`) — TypeScript HTTP handlers that run inside YouTrack's workflow engine. They have direct access to YouTrack entities (Issue, Project, User, etc.). -- **Frontend** (`src/widgets/`) — React widgets rendered inside YouTrack pages. They call the backend via a generated type-safe API client. - ---- - -## Critical Build Constraint - -**Backend must build before frontend.** The backend build generates `src/api/api.d.ts` and `src/api/api.zod.ts`, which the frontend imports. These files do not exist in source control. - -```bash -npm run build:backend # generates src/api/api.d.ts + src/api/api.zod.ts -npm run build:frontend # uses those types — will fail if run first -``` - -**Never edit files in `src/api/` by hand** — they are overwritten on every backend build: -- `src/api/api.d.ts` — generated route types -- `src/api/api.zod.ts` — generated Zod schemas -- `src/api/app.d.ts` — generated app settings types -- `src/api/extended-entities.d.ts` — generated extension property types - ---- - -## File-Based Routing - -Backend routes follow a strict file convention: - -``` -src/backend/router/{scope}/{path}/{METHOD}.ts -``` - -- `{scope}` must be exactly: `global`, `project`, `issue`, `article`, or `user` -- `{path}` can be nested: `users/profile`, `settings/advanced` -- `{METHOD}` must be uppercase: `GET.ts`, `POST.ts`, `PUT.ts`, or `DELETE.ts` - -### Required Handler Shape - -Every handler file must have this exact structure — all four exports are required: - -```typescript -/** - * @zod-to-schema - */ -export type MyReq = { - projectId: string; -}; - -/** - * @zod-to-schema - */ -export type MyRes = { - name: string; - count?: number; -}; - -export default function handle(ctx: CtxGet): void { - ctx.response.json({ - name: ctx.project.name, - count: 0, - }); -} - -export type Handle = typeof handle; -``` - -**Rules that cannot be skipped:** -- `/** @zod-to-schema */` on every `*Req` and `*Res` type — without it, the type is excluded from the API client and Zod schemas -- `export type Handle = typeof handle` at the end — without it, the API client generator cannot read the handler's types -- Only types suffixed `Req` / `Res` are surfaced to the frontend API client - -### Context Types - -```typescript -CtxGet -CtxPost -CtxPut -CtxDelete -``` - -Always available on `ctx`: -- `ctx.currentUser` — the YouTrack user making the request -- `ctx.settings` — app settings configured by the admin -- `ctx.request.query` — query parameters (GET/DELETE) -- `ctx.request.json()` — parsed body (POST/PUT) -- `ctx.response.json(data)` / `ctx.response.text(str)` — send response - -Available only when the scope matches the third generic: -- `ctx.issue` — when scope is `"issue"` -- `ctx.project` — when scope is `"project"` -- `ctx.article` — when scope is `"article"` -- `ctx.user` — when scope is `"user"` -- `ctx.globalStorage` — when scope is `"global"` - -**Scope must match the directory.** A handler at `src/backend/router/issue/.../GET.ts` must use `CtxGet<..., ..., "issue">` to get a typed `ctx.issue`. - -### Adding a New Route - -Preferred: use the built-in generator: - -```bash -npm run g -- handler / # GET by default -npm run g -- handler project/users --method POST -npm run g -- h issue/notes --method POST --permissions READ_ISSUE -``` - -Or create the file manually following the shape above, then rebuild to regenerate types: - -```bash -npm run build:backend -``` - ---- - -## Extension Properties - -Custom fields on YouTrack entities are declared in `src/entity-extensions.json`. After editing, rebuild to regenerate types: - -```bash -npm run g -- property Issue.myField # string, single-value -npm run g -- property Issue.tags --type string --set # multi-value -npm run build:backend -``` - -Access in handlers: `ctx.issue.extensionProperties.myField` (type-safe after rebuild). - ---- - -## App Settings - -Admin-configured values are declared in `src/settings.json`. After editing, rebuild to regenerate types: - -```bash -npm run build:backend -``` - -Access in handlers: `ctx.settings.myKey`. - -### Initialise settings (first time only) - -```bash -npm run g -- settings init --title "My App Settings" --description "Admin configuration" -``` - -### Add a property (non-interactive, all options available as flags) - -```bash -# Minimal — name and type are the only required flags -npm run g -- settings add --name apiKey --type string - -# With metadata and scope -npm run g -- settings add --name baseUrl --type string \ - --title "Base URL" --description "API base URL" \ - --scope global --required - -# String constraints -npm run g -- settings add --name slug --type string \ - --min-length 3 --max-length 50 --format slug - -# Enum -npm run g -- settings add --name status --type string \ - --enum "active,inactive,pending" - -# Integer / number constraints -npm run g -- settings add --name port --type integer \ - --min 1 --max 65535 --scope global - -# Exclusive bounds and step -npm run g -- settings add --name ratio --type number \ - --exclusive-min 0 --exclusive-max 1 --multiple-of 0.01 - -# Object / array entity references -npm run g -- settings add --name linkedIssue --type object --entity Issue -npm run g -- settings add --name reviewers --type array --entity User - -# Read-only with a fixed constant -npm run g -- settings add --name env --type string --readonly --const production - -# Write-only (e.g. secrets) -npm run g -- settings add --name secretKey --type string --write-only -``` - -**All `--type` values:** `string` `integer` `number` `boolean` `object` `array` - -**All `--scope` values:** `global` `project` *(omit or use `none` for no scope)* - -**All `--entity` values (object/array only):** `Issue` `User` `Project` `UserGroup` `Article` - ---- - -## Frontend API Client - -Widgets call the backend through the generated client: - -```typescript -import { createApi } from "@/api"; - -const host = await YTApp.register(); -const api = createApi(host); - -// Calls match the router file path exactly -const result = await api.project.settings.GET({ projectId: 'ABC' }); -const echo = await api.global.echo.POST({ message: 'hello' }); -const details = await api.issue.details.GET({ issueId: 'DEMO-1' }); -``` - -If a route is missing from `api`, rebuild the backend — the types are stale. - ---- - -## Development Workflow - -### Recommended: watch mode - -```bash -npm run watch -``` - -Watches both backend and frontend, auto-uploads to YouTrack on every successful rebuild. No hot reload — refresh the YouTrack page after upload. Frontend is built in development mode so Zod validation is active. - -### Faster frontend iteration: dev mode with HMR - -```bash -npm run dev -``` - -Same as watch, but also starts a Vite dev server on `:9000`. Frontend changes appear instantly without a page reload. Use this when iterating on UI. - -### Manual build + upload - -```bash -npm run build # full production build -npm run upload-local # upload using .env credentials -# or both at once: -npm run update -# or combination of everything: -npm run dev -``` - -### Environment - -Requires `.env` in project root: - -``` -YOUTRACK_HOST=https://your-youtrack.example.com -YOUTRACK_TOKEN=perm-your-permanent-token -``` - -Get a token (only developer can generate a token, ask if not provided) Instruction: YouTrack → Profile → Account Security → New token. - ---- - -## What Not to Do - -- Do not edit any file in `src/api/` — they are generated and will be overwritten -- Do not build the frontend without first building the backend (or having fresh generated types) -- Do not add a fourth scope variant to a single handler file — one file = one method -- Do not use relative paths to import from `src/api/` — use the `@/api` alias - ---- - -## Logs - -- **Frontend** — browser DevTools console -- **Backend** — YouTrack → Administration → Apps → [This App] → Technical Details → Open in editor / Download logs; use `console.log/warn/error` in handlers diff --git a/packages/create-youtrack-app/_templates/init/enhanced-dx/README.md.t b/packages/create-youtrack-app/_templates/init/enhanced-dx/README.md.t index 11006c99..a4549638 100644 --- a/packages/create-youtrack-app/_templates/init/enhanced-dx/README.md.t +++ b/packages/create-youtrack-app/_templates/init/enhanced-dx/README.md.t @@ -93,8 +93,8 @@ src/ ### Scaffolding -- `npm run g -- handler /` - Generate a new HTTP handler (e.g. `npm run g -- handler project/settings`) -- `npm run g -- property ` - Generate a new entity extension property (e.g. `npm run g -- property Issue.myField`) +- `npm run g -- http-handler add --scope --path ` - Generate a new HTTP handler (e.g. `npm run g -- http-handler add --scope project --path settings`) +- `npm run g -- extension-property add --entity --name ` - Generate a new entity extension property (e.g. `npm run g -- extension-property add --entity Issue --name myField`) - `npm run g -- settings add --name --type ` - Add an app settings field ### Maintenance diff --git a/packages/create-youtrack-app/_templates/init/enhanced-dx/manifest.json.t b/packages/create-youtrack-app/_templates/init/enhanced-dx/manifest.json.t index 1b0ccdc4..57d2a0e3 100644 --- a/packages/create-youtrack-app/_templates/init/enhanced-dx/manifest.json.t +++ b/packages/create-youtrack-app/_templates/init/enhanced-dx/manifest.json.t @@ -10,7 +10,7 @@ to: manifest.json "name": "<%= vendor || 'Your Company' %>", "url": "<%= vendorUrl || 'https://example.com' %>" }, - "icon": "icon.svg", + "icon": "icon.svg"<% if (backendOnly !== 'true') { %>, "widgets": [ { "key": "enhanced-dx", @@ -20,5 +20,5 @@ to: manifest.json "iconPath": "enhanced-dx/widget-icon.svg", "description": "<%= description %>" } - ] + ]<% } %> } diff --git a/packages/create-youtrack-app/_templates/init/enhanced-dx/package.json.t b/packages/create-youtrack-app/_templates/init/enhanced-dx/package.json.t index 075b2aa3..8120061a 100644 --- a/packages/create-youtrack-app/_templates/init/enhanced-dx/package.json.t +++ b/packages/create-youtrack-app/_templates/init/enhanced-dx/package.json.t @@ -8,19 +8,40 @@ to: package.json "type": "module", "enhancedDX": "true", "scripts": { +<% if (backendOnly === 'true') { -%> + "build:backend": "vite -c vite.config.backend.ts build", + "copy:static": "cp manifest.json dist/manifest.json && cp -R public/. dist/", + "build": "npm run clean && npm run build:backend && npm run lint && npm run copy:static && youtrack-app app validate", + "build:nolint": "npm run clean && npm run build:backend && npm run copy:static && youtrack-app app validate", + "clean": "rm -f src/api/api.d.ts src/api/api.zod.ts", + "lint": "eslint --report-unused-disable-directives --max-warnings 0", + "lint:fix": "eslint --fix", + "test": "echo 'no tests'", + "pack": "rm -rf <%= appName %>.zip && npx --yes bestzip <%= appName %>.zip dist/*", + "upload": "youtrack-app app upload", + "upload-local": "set -a && source .env && set +a && youtrack-app app upload --host $YOUTRACK_HOST --token $YOUTRACK_TOKEN", + "update": "npm run build && npm run upload-local", + "prepare:watch": "npm run clean && vite -c vite.config.backend.ts build --mode development && npm run copy:static", + "watch:backend": "vite -c vite.config.backend.ts build --watch --mode development", + "watch:coordinator": "youtrack-upload-coordinator --watch .build-state.json", + "watch": "npm run prepare:watch && rm -f .backend-changed .build-state.json && (AUTOUPLOAD=true npm run watch:backend & npm run watch:coordinator)", + "dev": "npm run watch", + "generate": "npx @jetbrains/create-youtrack-app", + "g": "npm run generate --" +<% } else { -%> "build:frontend": "vite build", "build:backend": "vite -c vite.config.backend.ts build", - "build": "npm run clean && npm run build:backend && npm run lint && npm run build:frontend && youtrack-app validate dist", - "build:nolint": "npm run clean && npm run build:backend && npm run build:frontend && youtrack-app validate dist", + "build": "npm run clean && npm run build:backend && npm run lint && npm run build:frontend && youtrack-app app validate", + "build:nolint": "npm run clean && npm run build:backend && npm run build:frontend && youtrack-app app validate", "clean": "rm -f src/api/api.d.ts src/api/api.zod.ts", "preview": "vite preview", "lint": "eslint --report-unused-disable-directives --max-warnings 0", "lint:fix": "eslint --fix", "test": "echo 'no tests'", "pack": "rm -rf <%= appName %>.zip && npx --yes bestzip <%= appName %>.zip dist/*", - "upload": "youtrack-app upload dist", + "upload": "youtrack-app app upload", - "upload-local": "set -a && source .env && set +a && youtrack-app upload dist --host $YOUTRACK_HOST --token $YOUTRACK_TOKEN", + "upload-local": "set -a && source .env && set +a && youtrack-app app upload --host $YOUTRACK_HOST --token $YOUTRACK_TOKEN", "update": "npm run build && npm run upload-local", "prepare:watch": "npm run clean && rm -rf dist/widgets/assets && vite -c vite.config.backend.ts build --mode development", "watch:backend": "vite -c vite.config.backend.ts build --watch --mode development", @@ -36,6 +57,7 @@ to: package.json "generate": "npx @jetbrains/create-youtrack-app", "g": "npm run generate --" +<% } -%> }, "dependencies": { "@jetbrains/ring-ui-built": "^7.0.8", diff --git a/packages/create-youtrack-app/_templates/init/enhanced-dx/src/backend/workflows/apply-template.ts.t b/packages/create-youtrack-app/_templates/init/enhanced-dx/src/backend/workflows/apply-template.ts.t index 669de851..872052b6 100644 --- a/packages/create-youtrack-app/_templates/init/enhanced-dx/src/backend/workflows/apply-template.ts.t +++ b/packages/create-youtrack-app/_templates/init/enhanced-dx/src/backend/workflows/apply-template.ts.t @@ -1,8 +1,8 @@ --- -to: src/backend/workflows/apply-template.ts +to: src/workflows/apply-template.ts --- import { Issue } from '@jetbrains/youtrack-scripting-api/entities'; -import { requirements } from '../requirements'; +import { requirements } from '../backend/requirements'; export const rule = Issue.action({ title: 'Apply template', diff --git a/packages/create-youtrack-app/_templates/init/enhanced-dx/src/backend/workflows/issue-state.ts.t b/packages/create-youtrack-app/_templates/init/enhanced-dx/src/backend/workflows/issue-state.ts.t index dca9d0c8..e3839144 100644 --- a/packages/create-youtrack-app/_templates/init/enhanced-dx/src/backend/workflows/issue-state.ts.t +++ b/packages/create-youtrack-app/_templates/init/enhanced-dx/src/backend/workflows/issue-state.ts.t @@ -1,8 +1,8 @@ --- -to: src/backend/workflows/issue-state.ts +to: src/workflows/issue-state.ts --- import { Issue } from '@jetbrains/youtrack-scripting-api/entities'; -import { requirements } from '../requirements'; +import { requirements } from '../backend/requirements'; export const rule = Issue.stateMachine({ title: 'Issue state machine', diff --git a/packages/create-youtrack-app/_templates/init/enhanced-dx/src/backend/workflows/notify-on-change.ts.t b/packages/create-youtrack-app/_templates/init/enhanced-dx/src/backend/workflows/notify-on-change.ts.t index 7421380c..b3378e70 100644 --- a/packages/create-youtrack-app/_templates/init/enhanced-dx/src/backend/workflows/notify-on-change.ts.t +++ b/packages/create-youtrack-app/_templates/init/enhanced-dx/src/backend/workflows/notify-on-change.ts.t @@ -1,8 +1,8 @@ --- -to: src/backend/workflows/notify-on-change.ts +to: src/workflows/notify-on-change.ts --- import { Issue } from '@jetbrains/youtrack-scripting-api/entities'; -import { requirements } from '../requirements'; +import { requirements } from '../backend/requirements'; export const rule = Issue.onChange({ title: 'Notify on change', diff --git a/packages/create-youtrack-app/_templates/init/enhanced-dx/src/backend/workflows/weekly-digest.ts.t b/packages/create-youtrack-app/_templates/init/enhanced-dx/src/backend/workflows/weekly-digest.ts.t index cc1d4564..8530219d 100644 --- a/packages/create-youtrack-app/_templates/init/enhanced-dx/src/backend/workflows/weekly-digest.ts.t +++ b/packages/create-youtrack-app/_templates/init/enhanced-dx/src/backend/workflows/weekly-digest.ts.t @@ -1,8 +1,8 @@ --- -to: src/backend/workflows/weekly-digest.ts +to: src/workflows/weekly-digest.ts --- import { Issue } from '@jetbrains/youtrack-scripting-api/entities'; -import { requirements } from '../requirements'; +import { requirements } from '../backend/requirements'; export const rule = Issue.onSchedule({ title: 'Weekly digest', diff --git a/packages/create-youtrack-app/_templates/init/enhanced-dx/src/widgets/enhanced-dx/app.css.t b/packages/create-youtrack-app/_templates/init/enhanced-dx/src/widgets/enhanced-dx/app.css.t index a78144ba..d27471e3 100644 --- a/packages/create-youtrack-app/_templates/init/enhanced-dx/src/widgets/enhanced-dx/app.css.t +++ b/packages/create-youtrack-app/_templates/init/enhanced-dx/src/widgets/enhanced-dx/app.css.t @@ -1,5 +1,5 @@ --- -to: src/widgets/enhanced-dx/app.css +to: "<%= backendOnly === 'true' ? '' : 'src/widgets/enhanced-dx/app.css' %>" --- .widget { display: flex; diff --git a/packages/create-youtrack-app/_templates/init/enhanced-dx/src/widgets/enhanced-dx/app.tsx.t b/packages/create-youtrack-app/_templates/init/enhanced-dx/src/widgets/enhanced-dx/app.tsx.t index 8f936920..d9f6a3c9 100644 --- a/packages/create-youtrack-app/_templates/init/enhanced-dx/src/widgets/enhanced-dx/app.tsx.t +++ b/packages/create-youtrack-app/_templates/init/enhanced-dx/src/widgets/enhanced-dx/app.tsx.t @@ -1,6 +1,10 @@ --- -to: src/widgets/enhanced-dx/app.tsx +to: "<%= backendOnly === 'true' ? '' : 'src/widgets/enhanced-dx/app.tsx' %>" --- +// Sample widget shipped with the scaffold. If this app is backend-only (workflows, +// HTTP handlers, rules — no UI), delete the whole src/widgets/enhanced-dx/ folder and +// its manifest.json "widgets[]" entry, or re-scaffold with `--backend-only`. Leave this +// untouched if the app is meant to render a UI in YouTrack. import React, {memo, useCallback, useState, useEffect} from 'react'; import Button from '@jetbrains/ring-ui-built/components/button/button'; import {Input, Size} from '@jetbrains/ring-ui-built/components/input/input'; diff --git a/packages/create-youtrack-app/_templates/init/enhanced-dx/src/widgets/enhanced-dx/index.html.t b/packages/create-youtrack-app/_templates/init/enhanced-dx/src/widgets/enhanced-dx/index.html.t index c0062052..1f84264e 100644 --- a/packages/create-youtrack-app/_templates/init/enhanced-dx/src/widgets/enhanced-dx/index.html.t +++ b/packages/create-youtrack-app/_templates/init/enhanced-dx/src/widgets/enhanced-dx/index.html.t @@ -1,7 +1,9 @@ --- -to: src/widgets/enhanced-dx/index.html +to: "<%= backendOnly === 'true' ? '' : 'src/widgets/enhanced-dx/index.html' %>" --- + diff --git a/packages/create-youtrack-app/_templates/init/enhanced-dx/src/widgets/enhanced-dx/index.tsx.t b/packages/create-youtrack-app/_templates/init/enhanced-dx/src/widgets/enhanced-dx/index.tsx.t index 601bfb1f..15efe2dc 100644 --- a/packages/create-youtrack-app/_templates/init/enhanced-dx/src/widgets/enhanced-dx/index.tsx.t +++ b/packages/create-youtrack-app/_templates/init/enhanced-dx/src/widgets/enhanced-dx/index.tsx.t @@ -1,5 +1,5 @@ --- -to: src/widgets/enhanced-dx/index.tsx +to: "<%= backendOnly === 'true' ? '' : 'src/widgets/enhanced-dx/index.tsx' %>" --- import React from 'react'; import ReactDOM from 'react-dom/client'; diff --git a/packages/create-youtrack-app/_templates/init/enhanced-dx/src/widgets/enhanced-dx/widget-icon.svg.t b/packages/create-youtrack-app/_templates/init/enhanced-dx/src/widgets/enhanced-dx/widget-icon.svg.t index 2326cd6b..89e7b311 100644 --- a/packages/create-youtrack-app/_templates/init/enhanced-dx/src/widgets/enhanced-dx/widget-icon.svg.t +++ b/packages/create-youtrack-app/_templates/init/enhanced-dx/src/widgets/enhanced-dx/widget-icon.svg.t @@ -1,4 +1,4 @@ --- -to: src/widgets/enhanced-dx/widget-icon.svg +to: "<%= backendOnly === 'true' ? '' : 'src/widgets/enhanced-dx/widget-icon.svg' %>" --- diff --git a/packages/create-youtrack-app/_templates/init/enhanced-dx/vite.config.backend.ts.t b/packages/create-youtrack-app/_templates/init/enhanced-dx/vite.config.backend.ts.t index cdda791b..4ea2f28b 100644 --- a/packages/create-youtrack-app/_templates/init/enhanced-dx/vite.config.backend.ts.t +++ b/packages/create-youtrack-app/_templates/init/enhanced-dx/vite.config.backend.ts.t @@ -21,7 +21,7 @@ export default defineConfig({ youtrackRouter(), youtrackExtensionProperties(), youtrackBackendBundles([ - { src: 'src/backend/workflows' }, + { src: 'src/workflows' }, { src: 'src/backend/ai-tools' }, { src: 'src/backend/sla' }, ]), diff --git a/packages/create-youtrack-app/_templates/init/enhanced-dx/vite.config.ts.t b/packages/create-youtrack-app/_templates/init/enhanced-dx/vite.config.ts.t index 8e1210ea..c4f7cfa9 100644 --- a/packages/create-youtrack-app/_templates/init/enhanced-dx/vite.config.ts.t +++ b/packages/create-youtrack-app/_templates/init/enhanced-dx/vite.config.ts.t @@ -1,5 +1,5 @@ --- -to: vite.config.ts +to: "<%= backendOnly === 'true' ? '' : 'vite.config.ts' %>" --- import { resolve, dirname } from "node:path"; import fs from 'node:fs'; diff --git a/packages/create-youtrack-app/_templates/init/vite-app/manifest.json.t b/packages/create-youtrack-app/_templates/init/vite-app/manifest.json.t index 9296245c..73fec841 100644 --- a/packages/create-youtrack-app/_templates/init/vite-app/manifest.json.t +++ b/packages/create-youtrack-app/_templates/init/vite-app/manifest.json.t @@ -10,7 +10,5 @@ "name": "<%= vendor %>", "url": "<%= vendorUrl %>" }, - "icon": "icon.svg", - "widgets": [ - ] + "icon": "icon.svg" } diff --git a/packages/create-youtrack-app/_templates/init/vite-app/package.json.t b/packages/create-youtrack-app/_templates/init/vite-app/package.json.t index c144e5bf..c4773951 100644 --- a/packages/create-youtrack-app/_templates/init/vite-app/package.json.t +++ b/packages/create-youtrack-app/_templates/init/vite-app/package.json.t @@ -8,11 +8,12 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -p tsconfig.app.json && vite build && youtrack-app validate dist", + "copy:dist": "rm -rf dist && mkdir -p dist && cp src/*.* dist/ && cp manifest.json dist/ && cp public/*.* dist/", + "build": "tsc -p tsconfig.app.json && if ls src/widgets/*/index.html >/dev/null 2>&1; then vite build; else npm run copy:dist; fi && youtrack-app app validate", "lint": "eslint --report-unused-disable-directives --max-warnings 0", "test": "echo 'no tests'", "pack": "rm -rf <%= appName %>.zip && cd dist/ && bestzip ../<%= appName %>.zip *", - "upload": "youtrack-app upload dist" + "upload": "youtrack-app app upload" }, "dependencies": { "@jetbrains/ring-ui-built": "^7.0.8", diff --git a/packages/create-youtrack-app/_templates/rule/add/index.js b/packages/create-youtrack-app/_templates/rule/add/index.js new file mode 100644 index 00000000..3a858c58 --- /dev/null +++ b/packages/create-youtrack-app/_templates/rule/add/index.js @@ -0,0 +1,55 @@ +const path = require('node:path'); +const fs = require('node:fs'); +const { + VALID_RULE_TYPES, + renderRuleTemplate, + resolveRuleTarget, + validateRuleName, + validateRuleType, +} = require('../../../utils/rule-scaffold'); + +module.exports = { + prompt: async ({ prompter, args }) => { + const answers = {}; + + if (args.type) { + answers.ruleType = String(args.type); + } else { + const response = await prompter.prompt({ + type: 'select', + name: 'ruleType', + message: 'Which workflow rule type do you want to create?', + choices: VALID_RULE_TYPES.map(ruleType => ({ name: ruleType, message: ruleType })), + }); + answers.ruleType = response.ruleType; + } + + if (args.name) { + answers.name = String(args.name); + } else { + const response = await prompter.prompt({ + type: 'input', + name: 'name', + message: 'What is the rule file name?', + initial: 'notify-on-change', + }); + answers.name = response.name; + } + + validateRuleType(answers.ruleType); + validateRuleName(answers.name); + + const isEnhancedDX = args.enhanced === true || args.enhanced === 'true'; + const targetCwd = path.resolve(process.cwd(), args.cwd || '.'); + const target = resolveRuleTarget(targetCwd, answers.name, isEnhancedDX); + if (fs.existsSync(target.absolutePath)) { + throw new Error(`Workflow rule already exists at ${target.relativePath}`); + } + + return { + ...answers, + isEnhancedDX, + content: renderRuleTemplate(answers.ruleType, isEnhancedDX), + }; + }, +}; diff --git a/packages/create-youtrack-app/_templates/rule/add/rule.js.t b/packages/create-youtrack-app/_templates/rule/add/rule.js.t new file mode 100644 index 00000000..45f84fe7 --- /dev/null +++ b/packages/create-youtrack-app/_templates/rule/add/rule.js.t @@ -0,0 +1,5 @@ +--- +to: "<%= isEnhancedDX ? 'src/workflows' : 'src' %>/<%= name %>.<%= isEnhancedDX ? 'ts' : 'js' %>" +unless_exists: true +--- +<%- content %> diff --git a/packages/create-youtrack-app/_templates/widget/add/inject-manifest.js b/packages/create-youtrack-app/_templates/widget/add/inject-manifest.js index d6ae594e..cb1048ed 100644 --- a/packages/create-youtrack-app/_templates/widget/add/inject-manifest.js +++ b/packages/create-youtrack-app/_templates/widget/add/inject-manifest.js @@ -1,8 +1,6 @@ const fs = require("node:fs"); const path = require("node:path"); -console.log('inject manifest') - function injectWidget(newWidget, cwd) { const fileName = "manifest.json"; @@ -11,7 +9,11 @@ function injectWidget(newWidget, cwd) { const manifest = JSON.parse(fs.readFileSync(filePath)); - if (manifest.widgets && manifest.widgets.some(w => w.key === newWidget.key)) { + if (!Array.isArray(manifest.widgets)) { + manifest.widgets = []; + } + + if (manifest.widgets.some(w => w.key === newWidget.key)) { throw new Error(`Widget with key "${newWidget.key}" already exists in manifest.json`); } diff --git a/packages/create-youtrack-app/help.js b/packages/create-youtrack-app/help.js index fa64ab0b..12de6fa1 100644 --- a/packages/create-youtrack-app/help.js +++ b/packages/create-youtrack-app/help.js @@ -1,76 +1,207 @@ const { styleText } = require("node:util"); - +const createApp = 'npx @jetbrains/create-youtrack-app'; +const command = value => styleText("magenta", value); +const heading = value => styleText("bold", value); +const code = value => styleText("cyan", value); + +const extensionPoints = [ + 'ADMINISTRATION_MENU_ITEM', + 'ARTICLE_ABOVE_ACTIVITY_STREAM', + 'ARTICLE_OPTIONS_MENU_ITEM', + 'DASHBOARD_WIDGET', + 'HELPDESK_CHANNEL', + 'ISSUE_ABOVE_ACTIVITY_STREAM', + 'ISSUE_BELOW_SUMMARY', + 'ISSUE_FIELD_PANEL_FIRST', + 'ISSUE_FIELD_PANEL_LAST', + 'ISSUE_OPTIONS_MENU_ITEM', + 'MAIN_MENU_ITEM', + 'MARKDOWN', + 'PROJECT_SETTINGS', + 'USER_CARD', + 'USER_PROFILE_SETTINGS', +].join(', '); console.log(` -To generate a new app, run the following command +${heading('Create YouTrack App')} + +Scaffold a YouTrack app or add features to the current app. + +Usage: + ${command(`${createApp} [options]`)} + +Common: + ${command('--cwd ')} Run from another directory. + ${command('--help, -h')} Show help. + ${command('--version')} Print the CLI version. + Names: app/rule/widget keys use ${code('[a-z][a-z0-9-]*')}; extension + properties use ${code('[A-Za-z_][A-Za-z0-9_]*')}; settings keys reject whitespace. + + +${heading('App Initialization')} + + ${command(`${createApp} app init [options]`)} + + Creates a new app. Missing values are prompted in an interactive terminal. + Project type is selected here only; feature commands infer the existing app. + + Options: + ${command('--name ')} App package name. + ${command('--type ')} js | ts. Default: ts. + js = basic JavaScript app. + ts = TypeScript app with Enhanced DX. + ${command('--title ')} Manifest title. Default: title-cased --name. + ${command('--description ')} Manifest description. Default: derived from --type. + ${command('--vendor ')} Manifest vendor name. Default: VendorName. + ${command('--vendor-url ')} Manifest vendor URL. Default: https://vendor.com. + ${command('--backend-only')} For --type ts, omit the sample widget. + ${command('--no-install')} Skip dependency install. + + +${heading('Backend and Workflows')} + + ${command(`${createApp} rule add --type --name `)} + + Adds a workflow rule. + + Args: + ${command('--type ')} onChange | onSchedule | action | stateMachine | sla. + ${command('--name ')} Rule filename stem. + + Output: JS apps write ${code('src/.js')}; TS apps write ${code('src/workflows/.ts')}. + + + ${command(`${createApp} http-handler add [options]`)} + + Adds an HTTP handler. Omit --scope and --path to open the interactive flow. + + Args: + ${command('--scope ')} global | project | issue | article | user. + ${command('--path ')} Route path below the selected scope. Empty means the scope root. + ${command('--method ')} GET | POST | PUT | DELETE. Default: GET. + ${command('--permissions ')} Permission keys, comma-separated. + ${command('--handler ')} JS apps only: handler file stem. Default: backend. + + JS usage: ${command('http-handler add --scope --path --handler ')} writes + ${code('src/.js')}; omit ${command('--handler')} to update ${code('src/backend.js')}. + TS output: + ${code('src/backend/router///.ts')}. + + +${heading('App Persistence')} + + ${command(`${createApp} settings init [options]`)} + + Creates ${code('src/settings.json')} when absent. Missing values are prompted + interactively. + + Options: + ${command('--title ')} Settings schema title. + ${command('--description ')} Settings schema description. + + + ${command(`${createApp} settings add --name --type [options]`)} + + Adds one property to ${code('src/settings.json')}. + + Options: + ${command('--name ')} Property key. + ${command('--type ')} string | integer | number | boolean | object | array. + ${command('--title ')} Property title. + ${command('--description ')} Property description. + ${command('--scope ')} global | project | none. Default: none. + ${command('--entity ')} Issue | User | Project | UserGroup | Article; only object/array. + ${command('--required')} Add to required[]. + ${command('--readonly')} Mark read-only. + ${command('--const ')} Constant value for read-only property. + ${command('--min-length ')} String minimum length. + ${command('--max-length ')} String maximum length. + ${command('--format ')} String format, for example secret, date, date-time, email, uri. + ${command('--enum ')} String allowed values. + ${command('--min ')} Number/integer inclusive minimum. + ${command('--max ')} Number/integer inclusive maximum. + ${command('--exclusive-min ')} Number/integer exclusive minimum. + ${command('--exclusive-max ')} Number/integer exclusive maximum. + ${command('--multiple-of ')} Number/integer multiple. + + + ${command(`${createApp} extension-property add [options]`)} + + Updates ${code('src/entity-extensions.json')}. Omit --entity and --name to open + the interactive flow. + + Args: + ${command('--entity ')} Issue | User | Project | Article. + ${command('--name ')} Extension property key. + ${command('--type ')} string | integer | float | boolean | Issue | User | Project | Article. + ${command('--set')} Multi-value property. + + +${heading('Widgets')} + + ${command(`${createApp} widget add --key --extension-point [options]`)} + + Adds a widget and manifest entry. Omit widget flags to open the interactive flow. + + Options: + ${command('--key ')} Widget key. + ${command('--extension-point

')} ${extensionPoints} + ${command('--name ')} Display name. Default: title-cased --key. + ${command('--description ')} Widget description. + ${command('--permissions ')} Permission keys, comma-separated. + ${command('--width ')} Expected width in pixels. + ${command('--height ')} Expected height in pixels. -=== -${styleText("magenta", 'npm init @jetbrains/youtrack-app')} -=== + Output: ${code('src/widgets//')} plus manifest widget entry. -... and follow the prompts. ${styleText("bold", 'Enhanced DX (experimental) features are described below.')} -After you have generated an app, you may want to add more features. Add new features quickly with one of these commands: +${heading('App Lifecycle')} -* ${styleText("magenta", 'npx @jetbrains/create-youtrack-app --help')} to view a list of available commands -* ${styleText("magenta", 'npx @jetbrains/create-youtrack-app settings init')} to add a declaration for the app settings (${styleText("underline", 'https://www.jetbrains.com/help/youtrack/devportal-apps/app-settings.html')}) -* ${styleText("magenta", 'npx @jetbrains/create-youtrack-app settings add')} to add one or more properties to the setting schema created using the command listed above -* ${styleText("magenta", 'npx @jetbrains/create-youtrack-app widget add')} to add another widget (${styleText("underline", 'https://www.jetbrains.com/help/youtrack/devportal-apps/apps-widgets.html')}) -* ${styleText("magenta", 'npx @jetbrains/create-youtrack-app extension-property add')} to declare an extension property (${styleText("underline", 'https://www.jetbrains.com/help/youtrack/devportal-apps/apps-extension-properties.html')}) -* ${styleText("magenta", 'npx @jetbrains/create-youtrack-app http-handler add')} to add an HTTP handler (${styleText("underline", 'https://www.jetbrains.com/help/youtrack/devportal-apps/apps-reference-http-handlers.html')}) -* ${styleText("magenta", 'npx @jetbrains/create-youtrack-app endpoint add')} to generate a router endpoint + Generated package scripts: + ${command('npm run build')} Build and validate dist. + ${command('npm run upload -- --host --token [--open]')} + Upload dist. -${styleText("bold", 'Enhanced DX (experimental)')} +${heading('Enhanced DX')} -${styleText("bold", 'Usage:')} - - Choose "TypeScript (Enhanced DX with file-based routing)" when prompted. A sample ${styleText("cyan", 'MAIN_MENU_ITEM')} widget with backend endpoints will be added automatically. - - Run ${styleText("magenta", 'npm run dev')} to rebuild and update the app continuously. + Enhanced DX is available only for TypeScript apps selected with + ${command('--type ts')} during app initialization. They add file-based routing, + generated API types, typed widget client, dev Zod validation, watch upload, + and optional frontend hot reload. -${styleText("bold", 'Code Generation:')} -Inside an Enhanced DX app, use ${styleText("magenta", 'npm run generate')} (or ${styleText("magenta", 'npm run g')}) to add features: + Generated package scripts: + ${command('npm run dev')} Start the Enhanced DX dev workflow. + ${command('npm run g -- ')} Run this generator in the app. -${styleText("bold", 'Widgets:')} - ${styleText("magenta", 'npm run g -- widget --key my-panel --extension-point ISSUE_BELOW_SUMMARY')} - ${styleText("magenta", 'npm run g -- widget --key admin-page --extension-point MAIN_MENU_ITEM --name "Admin Page"')} - ${styleText("dim", '# Creates src/widgets// and injects an entry into manifest.json')} - ${styleText("dim", '# Extension points: MAIN_MENU_ITEM, DASHBOARD_WIDGET, ISSUE_BELOW_SUMMARY, PROJECT_SETTINGS, ...')} + ${command(`${createApp} endpoint add`)} -${styleText("bold", 'HTTP Handlers:')} - ${styleText("magenta", 'npm run g -- handler global/health')} ${styleText("dim", '# GET handler (default)')} - ${styleText("magenta", 'npm run g -- handler project/users --method POST')} ${styleText("dim", '# POST handler')} - ${styleText("magenta", 'npm run g -- h issue/comments --method POST --permissions read-issue,update-issue')} + Interactive typed endpoint generator for TypeScript apps with Enhanced DX. + Omit the options to answer prompts interactively, or provide them for non-interactive generation. -${styleText("bold", 'Extension Properties:')} - ${styleText("magenta", 'npm run g -- property Issue.customStatus')} ${styleText("dim", '# string type (default)')} - ${styleText("magenta", 'npm run g -- property Comment.rating --type integer')} - ${styleText("magenta", 'npm run g -- p Issue.tags --type string --set')} ${styleText("dim", '# multi-value property')} + Values: + ${command('--scope ')} global | issue | project | custom. + ${command('--path ')} Path below the selected scope. + ${command('--method ')} GET | POST | PUT | DELETE. + ${command('--request-type ')} Request type name or never. Default: never. + ${command('--response-type ')} Response type name or never. Default: never. + ${command('--controller ')} Existing exported function in + src/backend/controllers/..controller.ts. + Omit to generate an inline handler. -${styleText("bold", 'App Settings:')} - ${styleText("magenta", 'npm run g -- settings init --title "..." --description "..."')} ${styleText("dim", '# Create settings schema')} - ${styleText("magenta", 'npm run g -- settings init')} ${styleText("dim", '# Interactive mode')} - ${styleText("magenta", 'npm run g -- settings add')} ${styleText("dim", '# Add property (interactive)')} - ${styleText("magenta", 'npm run g -- s init --title "..." --description "..."')} ${styleText("dim", '# Short alias')} + Output: ${code('src/backend/router//.ts')}; backend builds generate + ${code('src/api/api.d.ts')} and ${code('src/api/api.zod.ts')}. -${styleText("bold", 'Interactive Menu:')} - ${styleText("magenta", 'npm run g')} ${styleText("dim", '# Shows a menu for choosing what to generate')} -${styleText("bold", 'Features:')} -• ${styleText("bold", 'File-based Routing:')} Create endpoints by adding files in ${styleText("cyan", 'src/backend/router/SCOPE/NAME/METHOD.ts')} - - e.g. ${styleText("cyan", 'src/backend/router/project/demo/GET.ts')} for a GET request +${heading('Agent Skill')} -• ${styleText("bold", 'TypeScript Backend:')} TypeScript support with automatic type generation - - Use ${styleText("magenta", '@zod-to-schema')} annotation for the endpoint Request and Response types (see the sample endpoints) - - Annotated types are used to generate schemas (${styleText("cyan", 'api.zod.ts')}) and type definitions (${styleText("cyan", 'api.d.ts')}) in the ${styleText("cyan", 'src/api/')} folder + ${command(`${createApp} skill install [options]`)} + ${command(`${createApp} skill status [options]`)} -• ${styleText("bold", 'Client:')} Custom TS endpoints are accessible via type-safe API client with autocompletion and type checking - - ${styleText("magenta", 'import { createApi } from "@/api";')} - - ${styleText("magenta", 'const host = await YTApp.register(); const api = createApi(host);')} - - ${styleText("magenta", 'const result = await api.project.demo.GET({ projectId: "ABC", message: "hello" });')} + Installs or reports the included YouTrack Apps skill. -• ${styleText("bold", 'Zod Validation:')} Runtime validation in development mode. Use ${styleText("magenta", 'npm run dev')} + Options: + ${command('--agent ')} claude | codex | junie | all. Default: all. + ${command('--scope ')} global | project | all. install default: global. -• ${styleText("bold", 'Vite-powered:')} Custom plugins handle routing and type generation - - api plugin: ${styleText("bold", 'vite-plugin-youtrack-api-generator.ts')} - - router plugin: ${styleText("bold", 'vite-plugin-youtrack-router.ts')} `); diff --git a/packages/create-youtrack-app/index.js b/packages/create-youtrack-app/index.js index fe0fced7..311e799f 100755 --- a/packages/create-youtrack-app/index.js +++ b/packages/create-youtrack-app/index.js @@ -8,10 +8,194 @@ const Logger = require("hygen/dist/logger"); const path = require("node:path"); const fs = require('node:fs'); const defaultTemplates = path.join(__dirname, "_templates"); -const argv = process.argv.slice(2); +const publicArgv = process.argv.slice(2); +const publicRoute = routePublicCommand(publicArgv); +const argv = publicRoute.argv; +// Keep positional arguments from routed argv, but restore global options that +// routing intentionally removes. In particular, --cwd must be read from the +// original invocation regardless of where it appears. const args = require("minimist")(argv); +const originalArgs = require("minimist")(publicArgv); +if (originalArgs.cwd !== undefined) { + args.cwd = originalArgs.cwd; +} const cwd = path.resolve(process.cwd(), args.cwd || "."); const { trimPathSegments } = require('./utils/sanitize'); +const { + formatInstallResults, + formatStatusResults, + getSkillStatus, + installSkill, + runSystemAgentScan, +} = require('./utils/agent-skill'); +const { + resolveRuleTarget, + validateRuleName, + validateRuleType, +} = require('./utils/rule-scaffold'); + +function routePublicCommand(rawArgv) { + const parsed = require('minimist')(rawArgv); + if (parsed.help || parsed.h) { + return { argv: rawArgv, meta: 'help' }; + } + if (parsed.version) { + return { argv: rawArgv, meta: 'version' }; + } + + if (parsed._.length === 0) { + const unknownBareFlag = Object.keys(parsed).find(key => !['_', 'cwd'].includes(key)); + return unknownBareFlag + ? { argv: rawArgv, error: 'Expected command syntax: create-youtrack-app [options]' } + : { argv: rawArgv }; + } + + if (parsed._.length !== 2) { + return { argv: rawArgv, error: 'Expected command syntax: create-youtrack-app [options]' }; + } + + const key = `${parsed._[0]}:${parsed._[1]}`; + const commandFlags = { + 'app:init': ['name', 'type', 'title', 'description', 'vendor', 'vendor-url', 'backend-only', 'install'], + 'rule:add': ['type', 'name'], + 'http-handler:add': ['scope', 'path', 'method', 'permissions', 'handler'], + 'settings:init': ['title', 'description'], + 'settings:add': ['name', 'type', 'title', 'description', 'scope', 'entity', 'required', 'readonly', 'const', 'min-length', 'max-length', 'format', 'enum', 'min', 'max', 'exclusive-min', 'exclusive-max', 'multiple-of'], + 'extension-property:add': ['entity', 'name', 'type', 'set'], + 'widget:add': ['key', 'extension-point', 'name', 'description', 'permissions', 'width', 'height'], + // TypeScript Enhanced DX command. With no flags it remains interactive; + // endpoint flags are forwarded when an agent wants non-interactive output. + 'endpoint:add': ['scope', 'path', 'method', 'request-type', 'response-type', 'controller'], + 'skill:install': ['agent', 'scope'], + 'skill:status': ['agent', 'scope'], + }; + const allowed = commandFlags[key]; + if (!allowed) { + return { argv: rawArgv, error: `Unknown command "${parsed._[0]} ${parsed._[1]}"` }; + } + const withCommand = result => ({ ...result, command: key }); + + const unknownFlag = Object.keys(parsed).find(flag => !['_', 'cwd', ...allowed].includes(flag)); + if (unknownFlag) { + return { argv: rawArgv, error: `Unknown option "--${unknownFlag}"` }; + } + + const booleanFlags = new Set(['backend-only', 'install', 'required', 'readonly', 'set']); + const valuelessFlag = Object.keys(parsed).find(flag => flag !== '_' && parsed[flag] === true && !booleanFlags.has(flag)); + if (valuelessFlag) { + return { argv: rawArgv, error: `Option "--${valuelessFlag}" requires a value` }; + } + + if (key === 'rule:add') { + if (!flagValue(parsed.type) || !flagValue(parsed.name)) { + return { argv: rawArgv, error: 'Usage: rule add --type --name ' }; + } + return withCommand({ + argv: ['rule', 'add', String(parsed.type), ...removeOptions(buildCommandArgv('rule', 'add', parsed, allowed).slice(2), ['type'])], + }); + } + + if (key === 'http-handler:add') { + const scope = flagValue(parsed.scope); + const routePath = flagValue(parsed.path); + if (scope || routePath) { + if (!scope) { + return { argv: rawArgv, error: 'Option "--scope" is required when --path is provided' }; + } + return withCommand({ + argv: ['http-handler', `${scope}/${routePath || ''}`, ...removeOptions(buildCommandArgv('http-handler', 'add', parsed, allowed).slice(2), ['scope', 'path'])], + }); + } + } + + if (key === 'extension-property:add') { + const entity = flagValue(parsed.entity); + const name = flagValue(parsed.name); + if (entity || name) { + if (!entity || !name) { + return { argv: rawArgv, error: 'Options "--entity" and "--name" must be provided together' }; + } + return withCommand({ + argv: ['extension-property', `${entity}.${name}`, ...removeOptions(buildCommandArgv('extension-property', 'add', parsed, allowed).slice(2), ['entity'])], + }); + } + } + + if (key === 'app:init') { + return withCommand({ argv: buildCommandArgv('app', 'init', parsed, allowed).slice(2) }); + } + + return withCommand({ argv: rawArgv }); +} + +function flagValue(value) { + return value === undefined || value === null || value === false || value === true ? undefined : String(value); +} + +function buildCommandArgv(entity, action, parsed, allowedFlags) { + const result = [entity, action]; + + for (const flag of allowedFlags) { + if (!Object.hasOwn(parsed, flag)) { + continue; + } + + const values = Array.isArray(parsed[flag]) ? parsed[flag] : [parsed[flag]]; + for (const value of values) { + if (value === false) { + result.push(`--no-${flag}`); + } else if (value === true) { + result.push(`--${flag}`); + } else { + result.push(`--${flag}`, String(value)); + } + } + } + + return result; +} + +function removeOptions(values, optionNames) { + const result = []; + for (let index = 0; index < values.length; index++) { + const value = values[index]; + const matchingName = optionNames.find(name => value === `--${name}` || value.startsWith(`--${name}=`)); + if (!matchingName) { + result.push(value); + continue; + } + if (value === `--${matchingName}` && values[index + 1] !== undefined && !values[index + 1].startsWith('-')) { + index++; + } + } + return result; +} + +function validateEndpointController() { + if (args.controller === undefined || args.controller === '') { + return; + } + + const scope = String(args.scope || ''); + const routePath = String(args.path || '').replace(/^\/+|\/+$/g, ''); + const endpointPath = scope === 'custom' ? routePath : `${scope}/${routePath}`; + const controllerModule = endpointPath.replace(/\//g, '.'); + const controllerCandidates = ['.ts', '.tsx', '.js'].map(extension => path.join( + cwd, + 'src', + 'backend', + 'controllers', + `${controllerModule}.controller${extension}` + )); + + if (!controllerCandidates.some(candidate => fs.existsSync(candidate))) { + const expectedPath = path.relative(cwd, controllerCandidates[0]); + throw new Error( + `Controller module not found for --controller ${args.controller}: ${expectedPath}. ` + + 'Create this module and export the controller function, or omit --controller to generate an inline handler.' + ); + } +} function isCancelled(e) { return e === '' || (e && e.code === 'ERR_USE_AFTER_CLOSE'); @@ -80,25 +264,212 @@ function runGeneratedFilesLintFix(files) { } } +function isInteractive() { + return Boolean(process.stdin.isTTY && process.stdout.isTTY); +} + +function getDefaultSkillInstallOptions() { + return { + agent: args.agent || 'all', + scope: args.scope || 'global', + }; +} + +function getDetectedAgentIds(agentDiscoveries) { + return agentDiscoveries + .filter(result => result.detected) + .map(result => result.agent); +} + +function buildAgentChoices(agentDiscoveries) { + return [ + { + name: 'all', + message: 'All supported agents', + }, + ...agentDiscoveries.map(result => ({ + name: result.agent, + message: `${result.displayName} (${result.detected ? 'detected' : 'not detected'})`, + })), + ]; +} + +function buildScopeChoices(projectAvailable) { + const choices = [ + { + name: 'global', + message: 'Global - symlink into the home agent config', + }, + ]; + + if (projectAvailable) { + choices.push( + { + name: 'project', + message: 'Project - copy into the current directory', + }, + { + name: 'all', + message: 'Global and project', + } + ); + } + + return choices; +} + +async function promptForSkillInstallOptions() { + const agentDiscoveries = runSystemAgentScan({ cwd }); + const detectedAgents = getDetectedAgentIds(agentDiscoveries); + const initialAgent = detectedAgents.length === 1 ? detectedAgents[0] : 'all'; + const agentChoices = buildAgentChoices(agentDiscoveries); + + const agent = await new Select({ + name: 'agent', + message: 'Install the YouTrack Apps skill for:', + initial: agentChoices.findIndex(choice => choice.name === initialAgent), + choices: agentChoices, + }).run(); + + const projectAvailable = agentDiscoveries.some(result => result.projectAvailable); + + const scope = await new Select({ + name: 'scope', + message: 'Choose installation scope:', + choices: buildScopeChoices(projectAvailable), + }).run(); + + return { + agent, + scope, + }; +} + +async function resolveSkillInstallOptions() { + if (!isInteractive() || args.agent || args.scope) { + return getDefaultSkillInstallOptions(); + } + + return promptForSkillInstallOptions(); +} + +function getSkillStatusOptions() { + const agent = args.agent || 'all'; + const projectAvailable = runSystemAgentScan({ cwd }).some(result => result.projectAvailable); + const scope = args.scope || (projectAvailable ? 'all' : 'global'); + + return { agent, scope, cwd }; +} + +async function handleSkillCommand(skillAction) { + if (skillAction === 'install') { + const installOptions = await resolveSkillInstallOptions(); + const results = await installSkill({ ...installOptions, cwd }); + console.log(styleText("green", formatInstallResults(results, skillAction))); + return true; + } + + if (skillAction === 'status') { + const statuses = getSkillStatus(getSkillStatusOptions()); + console.log(formatStatusResults(statuses)); + return true; + } + + return false; +} + +async function handleRuleCommand(ruleArgs) { + if (!ruleArgs) { + return false; + } + + const usage = 'Usage: rule add --type --name '; + if (ruleArgs[1] !== 'add' || ruleArgs.length !== 3) { + console.error(styleText("red", usage)); + process.exit(1); + } + + const ruleType = ruleArgs[2]; + const name = args.name; + + if (!ruleType || !name) { + console.error(styleText("red", usage)); + process.exit(1); + } + + validateRuleType(ruleType); + validateRuleName(name); + + const pkgPath = path.join(cwd, 'package.json'); + const pkg = fs.existsSync(pkgPath) ? JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) : {}; + const isEnhancedDX = pkg.enhancedDX === true || pkg.enhancedDX === 'true'; + + const { relativePath, absolutePath } = resolveRuleTarget(cwd, name, isEnhancedDX); + + if (fs.existsSync(absolutePath)) { + throw new Error(`Workflow rule already exists at ${relativePath}`); + } + + const result = await runHygen([ + 'rule', + 'add', + '--type', + ruleType, + '--name', + name, + '--enhanced', + String(isEnhancedDX), + '--cwd', + cwd, + ]); + + if (!result.success) { + process.exit(1); + } + + console.log(styleText("green", `\n✓ Workflow rule created at ${relativePath}\n`)); + return true; +} + (async function run() { + if (publicRoute.error) { + console.error(styleText("red", `Error: ${publicRoute.error}`)); + process.exit(1); + } + + if (publicRoute.meta === 'version') { + console.log(require('./package.json').version); + return; + } + if ('help' in args || 'h' in args) { require('./help'); return; } - // Map short aliases to full commands for NestJS-style simplicity - const aliasMap = { - 'handler': 'http-handler', - 'h': 'http-handler', - 'property': 'extension-property', - 'prop': 'extension-property', - 'p': 'extension-property', - 'setting': 'settings', - 's': 'settings' - }; + const normalizedArgv = argv; + + if (publicRoute.command === 'skill:install' || publicRoute.command === 'skill:status') { + const skillAction = publicRoute.command.split(':')[1]; + + try { + if (await handleSkillCommand(skillAction)) { + return; + } + } catch (error) { + console.error(styleText("red", `Error: ${(error && error.message) || String(error)}`)); + process.exit(1); + } + } - // Replace aliases in argv (create new array to avoid mutation issues) - const normalizedArgv = argv.map(arg => aliasMap[arg] || arg); + try { + if (publicRoute.command === 'rule:add' && await handleRuleCommand(['rule', 'add', String(args._[2])])) { + return; + } + } catch (error) { + console.error(styleText("red", `Error: ${(error && error.message) || String(error)}`)); + process.exit(1); + } const handlerIndex = normalizedArgv.findIndex(a => a === 'http-handler'); if (handlerIndex !== -1 && normalizedArgv[handlerIndex + 1]) { @@ -116,7 +487,12 @@ function runGeneratedFilesLintFix(files) { process.exit(1); } - const method = args.method || 'GET'; // Default to GET + const method = String(args.method || 'GET').toUpperCase(); // Default to GET + const validMethods = ['GET', 'POST', 'PUT', 'DELETE']; + if (!validMethods.includes(method)) { + console.error(styleText("red", `Invalid method: ${method}. Must be one of: ${validMethods.join(', ')}`)); + process.exit(1); + } const permissions = args.permissions || ''; const pkgPath = path.join(cwd, 'package.json'); @@ -124,8 +500,35 @@ function runGeneratedFilesLintFix(files) { const isEnhancedDX = pkg.enhancedDX === true || pkg.enhancedDX === 'true'; if (!isEnhancedDX) { - console.error(styleText("red", 'This command requires an Enhanced DX project.')); - process.exit(1); + const handlerName = args.handler != null ? String(args.handler) : 'backend'; + if (!/^[a-z][a-z0-9-]*$/.test(handlerName)) { + console.error(styleText("red", `Invalid handler name: "${handlerName}". Must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens.`)); + process.exit(1); + } + + const handlerRel = path.join('src', `${handlerName}.js`); + const hygenArgs = [ + 'http-handler', + 'add', + '--handlerName', + handlerName, + '--path', + routePath, + '--method', + method, + '--handlerScope', + scope, + '--permissions', + permissions, + '--cwd', + cwd + ]; + + console.log(styleText("cyan", `\nAdding ${method} handler to ${handlerRel}...\n`)); + await runHygen(hygenArgs); + runGeneratedFilesLintFix([handlerRel]); + console.log(styleText("green", `\n✓ HTTP handler created successfully!\n`)); + return; } const targetRel = path.join('src', 'backend', 'router', scope, routePath || '', `${method}.ts`); @@ -185,7 +588,7 @@ function runGeneratedFilesLintFix(files) { process.exit(1); } - const isSet = args.set === true || args.set === 'true' || args.multi === true || args.multi === 'true'; + const isSet = args.set === true || args.set === 'true'; const entityExtensionsPath = path.join(cwd, 'src', 'entity-extensions.json'); let entityExtensions; @@ -465,6 +868,28 @@ function runGeneratedFilesLintFix(files) { ); if (hasHygenParams) { + const isEndpointCmd = new Set(normalizedArgv).has('endpoint'); + if (isEndpointCmd) { + const pkgPath = path.join(cwd, 'package.json'); + const pkg = fs.existsSync(pkgPath) ? JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) : {}; + const isEnhancedDX = pkg.enhancedDX === true || pkg.enhancedDX === 'true'; + + if (!isEnhancedDX) { + console.error(styleText("red", 'This command requires a TypeScript Enhanced DX project.')); + process.exit(1); + return; + } + + try { + validateEndpointController(); + } catch (error) { + console.error(styleText("red", `Error: ${error.message}`)); + process.exit(1); + } + + return runHygen(); + } + // Intercept Enhanced DX http-handler flow for richer experience const isHttpHandlerCmd = new Set(normalizedArgv).has('http-handler') && (new Set(normalizedArgv).has('add') || !normalizedArgv.find(a => a === 'init' || a === 'enhanced-dx' || a === 'settings' || a === 'widget' || a === 'extension-property' || a === 'endpoint')); if (isHttpHandlerCmd) { @@ -681,6 +1106,72 @@ function runGeneratedFilesLintFix(files) { return runHygen(); } + // Non-interactive scaffold gate: `--name` with no subcommand bypasses every prompt + // and runs `init` directly. Mirrors the widget/handler flag-form pattern — the bare + // invocation (no --name) stays fully interactive for humans (backward-compatible). + if (args.name !== undefined) { + const appName = String(args.name); + + // Reuse the exact widget-key validation style for the app name. + if (!/^[a-z][a-z0-9-]*$/.test(appName)) { + console.error(styleText("red", `Invalid app name: "${appName}". Must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens.`)); + process.exit(1); + } + + const appType = args.type != null ? String(args.type) : 'ts'; + if (!['js', 'ts'].includes(appType)) { + console.error(styleText("red", `Invalid type: "${appType}". Must be one of: js, ts`)); + process.exit(1); + } + + const title = args.title != null + ? String(args.title) + : appName.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); + const description = args.description != null + ? String(args.description) + : `A YouTrack app created with ${appType === 'ts' ? 'TypeScript' : 'JavaScript'}`; + const vendor = args.vendor != null ? String(args.vendor) : 'VendorName'; + const vendorUrl = args['vendor-url'] != null ? String(args['vendor-url']) : 'https://vendor.com'; + + const templateName = appType === 'js' ? 'vite-app' : 'enhanced-dx'; + + // `--backend-only` scaffolds a widget-less Enhanced DX app (no src/widgets/, no + // manifest widgets key, frontend-free build scripts). Only meaningful for --type ts; + // the js/vite-app scaffold is already widget-less, so the flag is a silent no-op there. + const backendOnly = appType === 'ts' && (args['backend-only'] === true || args['backend-only'] === 'true'); + + console.log(styleText("cyan", `\nScaffolding ${appType === 'ts' ? 'Enhanced DX' : 'JavaScript'} app "${appName}"${backendOnly ? ' (backend-only)' : ''}...\n`)); + const appRes = await runHygen(["init", templateName, "--appName", appName, "--title", title, "--description", description, "--vendor", vendor, "--vendorUrl", vendorUrl, "--backendOnly", String(backendOnly)]); + if (!appRes.success) { + process.exitCode = 1; + return; + } + + // `--no-install` → minimist sets args.install === false + if (args.install === false) { + console.log(styleText("green", `\n✓ App "${appName}" scaffolded. Dependencies not installed (--no-install). Run "npm install" in the app directory.\n`)); + return; + } + + const toolsPackageDir = path.join(__dirname, '..', 'apps-tools'); + const isLocalWorkspace = fs.existsSync(toolsPackageDir); + + console.log(styleText("bold", '\nInstalling dependencies...\n')); + if (isLocalWorkspace) { + // Local monorepo clone — link local builds instead of pulling from npm. + const installProcess = execa("npm", ["link", "@jetbrains/youtrack-apps-tools", "@jetbrains/youtrack-workflow-types"], {cwd}); + installProcess.stdout.pipe(process.stdout); + await installProcess; + } else { + const installProcess = execa("npm", ["install"], {cwd}); + installProcess.stdout.pipe(process.stdout); + await installProcess; + } + + console.log(styleText("green", `\n✓ App "${appName}" created and dependencies installed.\n`)); + return; + } + const pkgPath = path.join(cwd, 'package.json'); const hasPkg = fs.existsSync(pkgPath); if (hasPkg) { @@ -899,7 +1390,9 @@ function runGeneratedFilesLintFix(files) { const vendorUrl = 'https://vendor.com'; const templateName = appType === 'js' ? 'vite-app' : 'enhanced-dx'; - const appRes = await runHygen(["init", templateName, "--appName", appName, "--title", title, "--description", description, "--vendor", vendor, "--vendorUrl", vendorUrl, ...argv]); + // Honor `--backend-only` for ts here too so the `backendOnly` template local is always defined. + const backendOnly = appType === 'ts' && (args['backend-only'] === true || args['backend-only'] === 'true'); + const appRes = await runHygen(["init", templateName, "--appName", appName, "--title", title, "--description", description, "--vendor", vendor, "--vendorUrl", vendorUrl, ...argv, "--backendOnly", String(backendOnly)]); if (!appRes.success) { return; } diff --git a/packages/create-youtrack-app/package.json b/packages/create-youtrack-app/package.json index cc2f74dc..e737348d 100644 --- a/packages/create-youtrack-app/package.json +++ b/packages/create-youtrack-app/package.json @@ -13,26 +13,28 @@ "index.js", "help.js", "README.md", - "CHANGELOG.md" + "CHANGELOG.md", + "skills" ], "scripts": { - "start": "node index.js --cwd ./tmp", + "start": "node index.js app init --cwd ./tmp", "widget": "node index.js widget add --cwd ./tmp", "http-handler": "node index.js http-handler add --cwd ./tmp", "settings": "node index.js settings init --cwd ./tmp", - "manifest": "node index.js init manifest --cwd ./tmp", "extension-property": "node index.js extension-property add --cwd ./tmp", "settings-property": "node index.js settings add --cwd ./tmp", "lint": "eslint .", "generate:test": "bash scripts/generate-test-app.sh", "test": "bash scripts/generate-and-test.sh", - "test:generator": "node --test test/generator-cli.test.js", - "test:generator:watch": "node --test --watch test/generator-cli.test.js", + "test:generator": "node --test test/generator-cli.test.js test/skill-cli.test.js", + "test:generator:watch": "node --test --watch test/generator-cli.test.js test/skill-cli.test.js", "test:settings": "node --test test/settings.test.js", "test:settings:watch": "node --test --watch test/settings.test.js", "test:widget": "node --test test/widget.test.js", "test:widget:watch": "node --test --watch test/widget.test.js", "build": "echo 'no build required'", + "prepack": "node scripts/package-skill.js", + "postpack": "node scripts/package-skill.js --clean", "release:ci": "bash scripts/release-ci.sh" }, "author": "Andrey Skladchikov", diff --git a/packages/create-youtrack-app/scripts/generate-test-app.sh b/packages/create-youtrack-app/scripts/generate-test-app.sh index 8ba0c6d7..96295bc3 100644 --- a/packages/create-youtrack-app/scripts/generate-test-app.sh +++ b/packages/create-youtrack-app/scripts/generate-test-app.sh @@ -1,3 +1,4 @@ -npm run start -- init vite-app --appName test --title Test --description test --vendor TestUser --vendorUrl http://test.com -npm run widget -- --key test-widget --name Test Widget --extensionPoint ISSUE_BELOW_SUMMARY --addDimensions false --description 'test' --limitPermissions false --addDimensions false -npm run widget -- --key test-widget2222 --name Test Widget2222 --extensionPoint ISSUE_ABOVE_ACTIVITY_STREAM --addDimensions false --description 'test22222' --limitPermissions false --addDimensions false +mkdir -p ./tmp +npm run start -- --name test --type js --title Test --description test --vendor TestUser --vendor-url http://test.com --no-install +npm run widget -- --key test-widget --name 'Test Widget' --extension-point ISSUE_BELOW_SUMMARY --description test +npm run widget -- --key test-widget2222 --name 'Test Widget2222' --extension-point ISSUE_ABOVE_ACTIVITY_STREAM --description test22222 diff --git a/packages/create-youtrack-app/scripts/package-skill.js b/packages/create-youtrack-app/scripts/package-skill.js new file mode 100644 index 00000000..22780edf --- /dev/null +++ b/packages/create-youtrack-app/scripts/package-skill.js @@ -0,0 +1,19 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const skillName = 'youtrack-apps-skill'; +const sourceDir = path.resolve(__dirname, '..', '..', '..', 'skills', skillName); +const targetDir = path.resolve(__dirname, '..', 'skills', skillName); + +if (process.argv.includes('--clean')) { + fs.rmSync(path.dirname(targetDir), { recursive: true, force: true }); + process.exit(0); +} + +if (!fs.existsSync(path.join(sourceDir, 'SKILL.md'))) { + throw new Error(`Could not find the skill source at ${sourceDir}.`); +} + +fs.rmSync(path.dirname(targetDir), { recursive: true, force: true }); +fs.mkdirSync(path.dirname(targetDir), { recursive: true }); +fs.cpSync(sourceDir, targetDir, { recursive: true }); diff --git a/packages/create-youtrack-app/test/generator-cli.test.js b/packages/create-youtrack-app/test/generator-cli.test.js index 7d12ac42..539b808e 100644 --- a/packages/create-youtrack-app/test/generator-cli.test.js +++ b/packages/create-youtrack-app/test/generator-cli.test.js @@ -13,13 +13,16 @@ const CLI_PATH = path.join(PKG_DIR, 'index.js'); */ function runCLI(args, options = {}) { const cwd = options.cwd || TEST_APP_DIR; - const cmd = `node "${CLI_PATH}" ${args} --cwd "${cwd}"`; - + const { cwdBefore, ...execOptions } = options; + const cmd = cwdBefore + ? `node "${CLI_PATH}" --cwd "${cwd}" ${args}` + : `node "${CLI_PATH}" ${args} --cwd "${cwd}"`; + try { const result = execSync(cmd, { encoding: 'utf8', - stdio: options.silent ? 'pipe' : 'inherit', - ...options + stdio: execOptions.silent ? 'pipe' : 'inherit', + ...execOptions }); return { success: true, output: result }; } catch (error) { @@ -130,7 +133,7 @@ describe('NestJS-Style Code Generation', () => { describe('HTTP Handlers', () => { test('should create simple GET handler by default', () => { - const result = runCLI('handler global/health', { silent: true }); + const result = runCLI('http-handler add --scope global --path health', { silent: true }); assert.strictEqual(result.success, true, 'Command should succeed'); assert.strictEqual( @@ -151,7 +154,7 @@ describe('NestJS-Style Code Generation', () => { }); test('should create POST handler with --method flag', () => { - const result = runCLI('handler project/users --method POST', { silent: true }); + const result = runCLI('http-handler add --scope project --path users --method POST', { silent: true }); assert.strictEqual(result.success, true, 'Command should succeed'); assert.strictEqual( @@ -172,7 +175,7 @@ describe('NestJS-Style Code Generation', () => { }); test('should create PUT handler', () => { - const result = runCLI('handler issue/status --method PUT', { silent: true }); + const result = runCLI('http-handler add --scope issue --path status --method PUT', { silent: true }); assert.strictEqual(result.success, true); assert.strictEqual(fileExists('src/backend/router/issue/status/PUT.ts'), true); @@ -183,7 +186,7 @@ describe('NestJS-Style Code Generation', () => { }); test('should create DELETE handler', () => { - const result = runCLI('handler global/cache --method DELETE', { silent: true }); + const result = runCLI('http-handler add --scope global --path cache --method DELETE', { silent: true }); assert.strictEqual(result.success, true); assert.strictEqual(fileExists('src/backend/router/global/cache/DELETE.ts'), true); @@ -194,7 +197,7 @@ describe('NestJS-Style Code Generation', () => { }); test('should create handler with permissions', () => { - const result = runCLI('handler issue/comments --method POST --permissions READ_ISSUE,UPDATE_ISSUE', { silent: true }); + const result = runCLI('http-handler add --scope issue --path comments --method POST --permissions READ_ISSUE,UPDATE_ISSUE', { silent: true }); assert.strictEqual(result.success, true); assert.strictEqual(fileExists('src/backend/router/issue/comments/POST.ts'), true); @@ -210,7 +213,7 @@ describe('NestJS-Style Code Generation', () => { const cleanupLintFixScript = createLintFixScript(argsPath); try { - const result = runCLI('handler global/lint-hook', { silent: true }); + const result = runCLI('http-handler add --scope global --path lint-hook', { silent: true }); assert.strictEqual(result.success, true, 'Command should succeed'); assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsPath, 'utf8')), [ @@ -222,7 +225,7 @@ describe('NestJS-Style Code Generation', () => { }); test('should handle nested paths', () => { - const result = runCLI('handler project/users/profile/settings', { silent: true }); + const result = runCLI('http-handler add --scope project --path users/profile/settings', { silent: true }); assert.strictEqual(result.success, true); assert.strictEqual( @@ -235,27 +238,27 @@ describe('NestJS-Style Code Generation', () => { ); }); - test('should work with short alias "h"', () => { + test('should reject the removed short alias "h"', () => { const result = runCLI('h global/ping', { silent: true }); - assert.strictEqual(result.success, true); - assert.strictEqual(fileExists('src/backend/router/global/ping/GET.ts'), true); + assert.strictEqual(result.success, false); + assert.match(result.output, /Unknown command/); }); - test('http-handler interception should use normalized aliases', () => { + test('http-handler interception should use normalized argv', () => { const indexPath = path.join(PKG_DIR, 'index.js'); const indexContent = fs.readFileSync(indexPath, 'utf8'); - // Regression guard: alias commands like `handler add` / `h add` + // Regression guard: public commands translated to Hygen's internal argv // must be detected via normalizedArgv, not raw argv. assert.ok( indexContent.includes("const isHttpHandlerCmd = new Set(normalizedArgv).has('http-handler')"), - 'HTTP handler interception should use normalizedArgv for alias handling' + 'HTTP handler interception should use normalizedArgv' ); }); test('should handle multiple permissions', () => { - const result = runCLI('handler project/admin --method POST --permissions READ_PROJECT,UPDATE_PROJECT,DELETE_PROJECT', { silent: true }); + const result = runCLI('http-handler add --scope project --path admin --method POST --permissions READ_PROJECT,UPDATE_PROJECT,DELETE_PROJECT', { silent: true }); assert.strictEqual(result.success, true); const content = readFile('src/backend/router/project/admin/POST.ts'); @@ -265,9 +268,63 @@ describe('NestJS-Style Code Generation', () => { }); }); + describe('Typed Endpoints', () => { + test('supports non-interactive flags while retaining the endpoint generator', () => { + const result = runCLI( + 'endpoint add --scope global --path cli-health --method GET --request-type never --response-type never', + {silent: true} + ); + + assert.strictEqual(result.success, true); + assert.strictEqual(fileExists('src/backend/router/global/cli-health/GET.ts'), true); + }); + + test('rejects a controller reference when its module does not exist', () => { + const result = runCLI( + 'endpoint add --scope global --path missing-controller --method GET --controller rand', + {silent: true} + ); + + assert.strictEqual(result.success, false); + assert.match(result.output, /Controller module not found/); + assert.match(result.output, /src\/backend\/controllers\/global\.missing-controller\.controller\.ts/); + assert.strictEqual(fileExists('src/backend/router/global/missing-controller/GET.ts'), false); + }); + + test('accepts a controller reference when its module exists', () => { + const controllerPath = path.join( + TEST_APP_DIR, + 'src', + 'backend', + 'controllers', + 'global.existing-controller.controller.ts' + ); + fs.mkdirSync(path.dirname(controllerPath), { recursive: true }); + fs.writeFileSync(controllerPath, 'export function rand(ctx) {}\n'); + + const result = runCLI( + 'endpoint add --scope global --path existing-controller --method GET --controller rand', + {silent: true} + ); + + assert.strictEqual(result.success, true); + assert.strictEqual(fileExists('src/backend/router/global/existing-controller/GET.ts'), true); + }); + }); + describe('Extension Properties', () => { + test('honors --cwd when it appears before the command', () => { + const result = runCLI('extension-property add --entity Issue --name cwdBefore', {silent: true, cwdBefore: true}); + + assert.strictEqual(result.success, true); + + const entityExtensions = JSON.parse(readFile('src/entity-extensions.json')); + const issueEntity = entityExtensions.entityTypeExtensions.find(e => e.entityType === 'Issue'); + assert.ok(issueEntity.properties.cwdBefore, 'cwdBefore property should exist'); + }); + test('should create string property by default', () => { - const result = runCLI('property Issue.customStatus', { silent: true }); + const result = runCLI('extension-property add --entity Issue --name customStatus', { silent: true }); assert.strictEqual(result.success, true); @@ -280,8 +337,17 @@ describe('NestJS-Style Code Generation', () => { assert.strictEqual(issueEntity.properties.customStatus.multi, false); }); + test('does not treat a flag value of skill as the skill command', () => { + const result = runCLI('extension-property add --entity Issue --name skill', { silent: true }); + + assert.strictEqual(result.success, true); + const entityExtensions = JSON.parse(readFile('src/entity-extensions.json')); + const issueEntity = entityExtensions.entityTypeExtensions.find(e => e.entityType === 'Issue'); + assert.ok(issueEntity.properties.skill); + }); + test('should create integer property', () => { - const result = runCLI('property Project.rating --type integer', { silent: true }); + const result = runCLI('extension-property add --entity Project --name rating --type integer', { silent: true }); assert.strictEqual(result.success, true); @@ -293,7 +359,7 @@ describe('NestJS-Style Code Generation', () => { }); test('should create boolean property', () => { - const result = runCLI('property Issue.isArchived --type boolean', { silent: true }); + const result = runCLI('extension-property add --entity Issue --name isArchived --type boolean', { silent: true }); assert.strictEqual(result.success, true); @@ -304,7 +370,7 @@ describe('NestJS-Style Code Generation', () => { }); test('should create Issue reference property', () => { - const result = runCLI('property Issue.relatedIssue --type Issue', { silent: true }); + const result = runCLI('extension-property add --entity Issue --name relatedIssue --type Issue', { silent: true }); assert.strictEqual(result.success, true); @@ -315,7 +381,7 @@ describe('NestJS-Style Code Generation', () => { }); test('should create multi-value property with --set flag', () => { - const result = runCLI('property Issue.tags --type string --set', { silent: true }); + const result = runCLI('extension-property add --entity Issue --name tags --type string --set', { silent: true }); assert.strictEqual(result.success, true); @@ -326,20 +392,15 @@ describe('NestJS-Style Code Generation', () => { assert.strictEqual(issueEntity.properties.tags.multi, true); }); - test('should create multi-value property with --multi true flag', () => { - const result = runCLI('property Issue.labels --type string --multi true', { silent: true }); - - assert.strictEqual(result.success, true); - - const entityExtensions = JSON.parse(readFile('src/entity-extensions.json')); - const issueEntity = entityExtensions.entityTypeExtensions.find(e => e.entityType === 'Issue'); + test('should reject removed --multi alias', () => { + const result = runCLI('extension-property add --entity Issue --name labels --type string --multi true', { silent: true }); - assert.strictEqual(issueEntity.properties.labels.type, 'string'); - assert.strictEqual(issueEntity.properties.labels.multi, true, '--multi true should set multi to boolean true'); + assert.strictEqual(result.success, false); + assert.match(result.output, /Unknown option "--multi"/); }); test('should store multi as boolean not string', () => { - const result = runCLI('property Issue.score --type integer --set', { silent: true }); + const result = runCLI('extension-property add --entity Issue --name score --type integer --set', { silent: true }); assert.strictEqual(result.success, true); @@ -350,7 +411,7 @@ describe('NestJS-Style Code Generation', () => { }); test('should create property on User entity', () => { - const result = runCLI('property User.department --type string', { silent: true }); + const result = runCLI('extension-property add --entity User --name department --type string', { silent: true }); assert.strictEqual(result.success, true); @@ -362,7 +423,7 @@ describe('NestJS-Style Code Generation', () => { }); test('should create property on Article', () => { - const result = runCLI('property Article.config --type string', { silent: true }); + const result = runCLI('extension-property add --entity Article --name config --type string', { silent: true }); assert.strictEqual(result.success, true); @@ -373,30 +434,22 @@ describe('NestJS-Style Code Generation', () => { assert.ok(articleEntity.properties.config); }); - test('should work with short alias "p"', () => { + test('should reject the removed short alias "p"', () => { const result = runCLI('p Issue.priority --type integer', { silent: true }); - assert.strictEqual(result.success, true); - - const entityExtensions = JSON.parse(readFile('src/entity-extensions.json')); - const issueEntity = entityExtensions.entityTypeExtensions.find(e => e.entityType === 'Issue'); - - assert.ok(issueEntity.properties.priority); + assert.strictEqual(result.success, false); + assert.match(result.output, /Unknown command/); }); - test('should work with short alias "prop"', () => { + test('should reject the removed alias "prop"', () => { const result = runCLI('prop Article.version --type integer', { silent: true }); - assert.strictEqual(result.success, true); - - const entityExtensions = JSON.parse(readFile('src/entity-extensions.json')); - const articleEntity = entityExtensions.entityTypeExtensions.find(e => e.entityType === 'Article'); - - assert.ok(articleEntity.properties.version); + assert.strictEqual(result.success, false); + assert.match(result.output, /Unknown command/); }); test('should handle property names with underscores', () => { - const result = runCLI('property Issue.custom_field_name --type string', { silent: true }); + const result = runCLI('extension-property add --entity Issue --name custom_field_name --type string', { silent: true }); assert.strictEqual(result.success, true); @@ -407,9 +460,27 @@ describe('NestJS-Style Code Generation', () => { }); }); + describe('Workflow Rules', () => { + test('should create workflow rules in src/workflows', () => { + const result = runCLI('rule add --type onChange --name notify-cli-rule', { silent: true }); + + assert.strictEqual(result.success, true, 'Command should succeed'); + assert.strictEqual(fileExists('src/workflows/notify-cli-rule.ts'), true); + assert.strictEqual(fileExists('src/workflows/notify-cli-rule.js'), false); + assert.strictEqual(fileExists('src/backend/workflows/notify-cli-rule.ts'), false); + }); + + test('does not treat a rule name of skill as the skill command', () => { + const result = runCLI('rule add --type onChange --name skill', { silent: true }); + + assert.strictEqual(result.success, true); + assert.strictEqual(fileExists('src/workflows/skill.ts'), true); + }); + }); + describe('Error Handling & Validation', () => { test('should reject invalid scope', () => { - const result = runCLI('handler invalid/health', { silent: true }); + const result = runCLI('http-handler add --scope invalid --path health', { silent: true }); assert.strictEqual(result.success, false, 'Command should fail'); assert.ok( @@ -419,7 +490,7 @@ describe('NestJS-Style Code Generation', () => { }); test('should reject invalid entity target', () => { - const result = runCLI('property InvalidEntity.field', { silent: true }); + const result = runCLI('extension-property add --entity InvalidEntity --name field', { silent: true }); assert.strictEqual(result.success, false, 'Command should fail'); assert.ok( @@ -429,7 +500,7 @@ describe('NestJS-Style Code Generation', () => { }); test('should reject invalid property type', () => { - const result = runCLI('property Issue.field --type invalidtype', { silent: true }); + const result = runCLI('extension-property add --entity Issue --name field --type invalidtype', { silent: true }); assert.strictEqual(result.success, false, 'Command should fail'); assert.ok( @@ -439,7 +510,7 @@ describe('NestJS-Style Code Generation', () => { }); test('should reject invalid property name with spaces', () => { - const result = runCLI('property "Issue.my field"', { silent: true }); + const result = runCLI('extension-property add --entity Issue --name "my field"', { silent: true }); assert.strictEqual(result.success, false, 'Command should fail'); assert.ok( @@ -449,13 +520,13 @@ describe('NestJS-Style Code Generation', () => { }); test('should reject property name starting with number', () => { - const result = runCLI('property Issue.123field', { silent: true }); + const result = runCLI('extension-property add --entity Issue --name 123field', { silent: true }); assert.strictEqual(result.success, false, 'Command should fail'); }); test('should reject property name with hyphens', () => { - const result = runCLI('property "Issue.field-name"', { silent: true }); + const result = runCLI('extension-property add --entity Issue --name field-name', { silent: true }); assert.strictEqual(result.success, false, 'Command should fail'); }); @@ -463,7 +534,7 @@ describe('NestJS-Style Code Generation', () => { describe('Edge Cases', () => { test('should handle very deep nested paths', () => { - const result = runCLI('handler project/api/v1/users/profile/settings/advanced', { silent: true }); + const result = runCLI('http-handler add --scope project --path api/v1/users/profile/settings/advanced', { silent: true }); assert.strictEqual(result.success, true); assert.strictEqual( @@ -473,7 +544,7 @@ describe('NestJS-Style Code Generation', () => { }); test('should handle single character property name', () => { - const result = runCLI('property Issue.x --type integer', { silent: true }); + const result = runCLI('extension-property add --entity Issue --name x --type integer', { silent: true }); assert.strictEqual(result.success, true); @@ -485,7 +556,7 @@ describe('NestJS-Style Code Generation', () => { test('should handle very long property name', () => { const longName = 'thisIsAVeryLongPropertyNameThatIsStillValidButUnusuallyLong'; - const result = runCLI(`property Issue.${longName} --type string`, { silent: true }); + const result = runCLI(`extension-property add --entity Issue --name ${longName} --type string`, { silent: true }); assert.strictEqual(result.success, true); @@ -500,7 +571,7 @@ describe('NestJS-Style Code Generation', () => { const backup = fs.readFileSync(entityExtPath, 'utf8'); try { fs.writeFileSync(entityExtPath, '{ invalid json }', 'utf8'); - const result = runCLI('property Issue.invalidJsonTest --type string', { silent: true }); + const result = runCLI('extension-property add --entity Issue --name invalidJsonTest --type string', { silent: true }); assert.strictEqual(result.success, false); assert.ok(result.output.includes('invalid JSON') || result.output.includes('entity-extensions')); @@ -512,19 +583,19 @@ describe('NestJS-Style Code Generation', () => { describe('All Scopes', () => { test('should work with global scope', () => { - const result = runCLI('handler global/test1', { silent: true }); + const result = runCLI('http-handler add --scope global --path test1', { silent: true }); assert.strictEqual(result.success, true); assert.strictEqual(fileExists('src/backend/router/global/test1/GET.ts'), true); }); test('should work with project scope', () => { - const result = runCLI('handler project/test2', { silent: true }); + const result = runCLI('http-handler add --scope project --path test2', { silent: true }); assert.strictEqual(result.success, true); assert.strictEqual(fileExists('src/backend/router/project/test2/GET.ts'), true); }); test('should work with issue scope', () => { - const result = runCLI('handler issue/test3', { silent: true }); + const result = runCLI('http-handler add --scope issue --path test3', { silent: true }); assert.strictEqual(result.success, true); assert.strictEqual(fileExists('src/backend/router/issue/test3/GET.ts'), true); }); @@ -533,7 +604,7 @@ describe('NestJS-Style Code Generation', () => { describe('All HTTP Methods', () => { ['GET', 'POST', 'PUT', 'DELETE'].forEach((method) => { test(`should create ${method} handler`, () => { - const result = runCLI(`handler global/method-test-${method.toLowerCase()} --method ${method}`, { silent: true }); + const result = runCLI(`http-handler add --scope global --path method-test-${method.toLowerCase()} --method ${method}`, { silent: true }); assert.strictEqual(result.success, true); assert.strictEqual( fileExists(`src/backend/router/global/method-test-${method.toLowerCase()}/${method}.ts`), @@ -546,7 +617,7 @@ describe('NestJS-Style Code Generation', () => { describe('All Property Types', () => { ['string', 'integer', 'float', 'boolean', 'Issue', 'User', 'Project', 'Article'].forEach((type) => { test(`should create property with ${type} type`, () => { - const result = runCLI(`property Issue.type_test_${type} --type ${type}`, { silent: true }); + const result = runCLI(`extension-property add --entity Issue --name type_test_${type} --type ${type}`, { silent: true }); assert.strictEqual(result.success, true); const entityExtensions = JSON.parse(readFile('src/entity-extensions.json')); @@ -560,7 +631,7 @@ describe('NestJS-Style Code Generation', () => { describe('All Entity Types', () => { ['Issue', 'User', 'Project', 'Article'].forEach((entity) => { test(`should create property on ${entity} entity`, () => { - const result = runCLI(`property ${entity}.entity_test --type string`, { silent: true }); + const result = runCLI(`extension-property add --entity ${entity} --name entity_test --type string`, { silent: true }); assert.strictEqual(result.success, true); const entityExtensions = JSON.parse(readFile('src/entity-extensions.json')); diff --git a/packages/create-youtrack-app/test/sanitize.test.js b/packages/create-youtrack-app/test/sanitize.test.js index c8650d88..7dff0a2d 100644 --- a/packages/create-youtrack-app/test/sanitize.test.js +++ b/packages/create-youtrack-app/test/sanitize.test.js @@ -1,8 +1,6 @@ -import { describe, it, expect } from 'vitest'; -import path from 'node:path'; - -// Resolve CommonJS module from ES context of Vitest -const { trimPathSegments } = require(path.resolve(__dirname, '../utils/sanitize.js')); +const assert = require('node:assert/strict'); +const {describe, it} = require('node:test'); +const {trimPathSegments} = require('../utils/sanitize.js'); describe('trimPathSegments', () => { const cases = [ @@ -20,7 +18,7 @@ describe('trimPathSegments', () => { it('normalizes various forms of slashes', () => { for (const c of cases) { - expect(trimPathSegments(c.in)).toBe(c.out); + assert.equal(trimPathSegments(c.in), c.out); } }); }); diff --git a/packages/create-youtrack-app/test/scaffold-cli.test.js b/packages/create-youtrack-app/test/scaffold-cli.test.js new file mode 100644 index 00000000..a819c9d6 --- /dev/null +++ b/packages/create-youtrack-app/test/scaffold-cli.test.js @@ -0,0 +1,217 @@ +const { test, describe, before, after } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const { execSync } = require('node:child_process'); + +const PKG_DIR = path.join(__dirname, '..'); +const CLI_PATH = path.join(PKG_DIR, 'index.js'); +const SCAFFOLD_ROOT = path.join(PKG_DIR, 'tmp', 'scaffold-tests'); + +let seq = 0; + +/** + * Run the scaffold CLI in a fresh empty directory and return { success, output, dir }. + * The `init` templates write to the cwd root (to: package.json), so each test gets + * its own isolated directory. + */ +function runScaffold(args, options = {}) { + const dir = path.join(SCAFFOLD_ROOT, `app-${seq++}`); + fs.mkdirSync(dir, { recursive: true }); + const cmd = options.cwdBefore + ? `node "${CLI_PATH}" --cwd "${dir}" ${args}` + : `node "${CLI_PATH}" ${args} --cwd "${dir}"`; + try { + const output = execSync(cmd, { encoding: 'utf8', stdio: 'pipe' }); + return { success: true, output, dir }; + } catch (error) { + return { success: false, output: (error.stdout || '') + (error.stderr || ''), dir, error }; + } +} + +function readJson(dir, rel) { + return JSON.parse(fs.readFileSync(path.join(dir, rel), 'utf8')); +} + +function exists(dir, rel) { + return fs.existsSync(path.join(dir, rel)); +} + +function readFile(dir, rel) { + return fs.readFileSync(path.join(dir, rel), 'utf8'); +} + +describe('Non-interactive scaffold gate (--name)', () => { + before(() => { + fs.rmSync(SCAFFOLD_ROOT, { recursive: true, force: true }); + fs.mkdirSync(SCAFFOLD_ROOT, { recursive: true }); + }); + + after(() => { + fs.rmSync(SCAFFOLD_ROOT, { recursive: true, force: true }); + }); + + describe('Enhanced DX (ts / default)', () => { + test('honors --cwd when it appears before the command', () => { + const { success, dir } = runScaffold('app init --name cwd-before --no-install', { cwdBefore: true }); + + assert.strictEqual(success, true, 'Command should succeed'); + assert.ok(exists(dir, 'package.json'), 'package.json created in the requested cwd'); + assert.ok(exists(dir, 'manifest.json'), 'manifest.json created in the requested cwd'); + }); + + test('scaffolds an Enhanced DX app and skips install with --no-install', () => { + const { success, dir } = runScaffold('app init --name my-app --type ts --no-install'); + + assert.strictEqual(success, true, 'Command should succeed'); + assert.ok(exists(dir, 'package.json'), 'package.json created'); + assert.ok(exists(dir, 'manifest.json'), 'manifest.json created'); + assert.ok(exists(dir, 'src/widgets/enhanced-dx/app.tsx'), 'enhanced-dx widget scaffolded'); + assert.ok(exists(dir, 'src/workflows/notify-on-change.ts'), 'sample workflows scaffolded under src/workflows'); + assert.ok(!exists(dir, 'src/backend/workflows/notify-on-change.ts'), 'sample workflows should not be scaffolded under src/backend/workflows'); + + const pkg = readJson(dir, 'package.json'); + assert.strictEqual(pkg.name, 'my-app', 'package name maps from --name'); + assert.strictEqual(pkg.enhancedDX, 'true', 'ts template is Enhanced DX'); + + const backendConfig = readFile(dir, 'vite.config.backend.ts'); + assert.ok(backendConfig.includes("{ src: 'src/workflows' }"), 'backend build should bundle src/workflows'); + + assert.ok(!exists(dir, 'node_modules'), '--no-install must skip dependency install'); + }); + + test('defaults --type to ts when omitted', () => { + const { success, dir } = runScaffold('app init --name default-type --no-install'); + + assert.strictEqual(success, true); + const pkg = readJson(dir, 'package.json'); + assert.strictEqual(pkg.enhancedDX, 'true', 'omitting --type defaults to Enhanced DX (ts)'); + }); + + test('derives title from name and applies default description/vendor', () => { + const { success, dir } = runScaffold('app init --name my-cool-app --no-install'); + + assert.strictEqual(success, true); + const manifest = readJson(dir, 'manifest.json'); + assert.strictEqual(manifest.title, 'My Cool App', 'title derived by title-casing hyphen segments'); + assert.strictEqual(manifest.description, 'A YouTrack app created with TypeScript'); + assert.strictEqual(manifest.vendor.name, 'VendorName'); + assert.strictEqual(manifest.vendor.url, 'https://vendor.com'); + }); + + test('honors explicit --title, --description, --vendor, --vendor-url', () => { + const { success, dir } = runScaffold( + 'app init --name flags-app --no-install --title "Custom Title" --description "Custom desc" --vendor "Acme" --vendor-url "https://acme.test"' + ); + + assert.strictEqual(success, true); + const manifest = readJson(dir, 'manifest.json'); + assert.strictEqual(manifest.title, 'Custom Title'); + assert.strictEqual(manifest.description, 'Custom desc'); + assert.strictEqual(manifest.vendor.name, 'Acme'); + assert.strictEqual(manifest.vendor.url, 'https://acme.test'); + }); + }); + + describe('JavaScript (js)', () => { + test('scaffolds a vite-app when --type js', () => { + const { success, dir } = runScaffold('app init --name js-app --type js --no-install'); + + assert.strictEqual(success, true); + assert.ok(exists(dir, 'package.json'), 'package.json created'); + assert.ok(exists(dir, 'manifest.json'), 'manifest.json created'); + + const pkg = readJson(dir, 'package.json'); + assert.strictEqual(pkg.name, 'js-app'); + assert.notStrictEqual(pkg.enhancedDX, 'true', 'js template is not Enhanced DX'); + + const manifest = readJson(dir, 'manifest.json'); + assert.strictEqual(manifest.description, 'A YouTrack app created with JavaScript'); + + const pkgScripts = readJson(dir, 'package.json').scripts; + assert.ok(!('build:workflows' in pkgScripts), 'JavaScript workflows should not need a separate bundler'); + assert.ok(pkgScripts['copy:dist'].includes('cp src/*.* dist/')); + + const viteConfig = readFile(dir, 'vite.config.ts'); + assert.ok(!viteConfig.includes("src: 'workflows/*.js'"), 'JavaScript rules are copied from the src root'); + + const ruleOutput = execSync( + `node "${CLI_PATH}" rule add --type onChange --name import-helper --cwd "${dir}"`, + { encoding: 'utf8', stdio: 'pipe' } + ); + assert.match(ruleOutput, /Workflow rule created/); + assert.ok(exists(dir, 'src/import-helper.js')); + assert.ok(!exists(dir, 'src/workflows/import-helper.js')); + const ruleSource = readFile(dir, 'src/import-helper.js'); + assert.match(ruleSource, /const entities = require/); + assert.match(ruleSource, /exports\.rule/); + + fs.writeFileSync(path.join(dir, 'src', 'helper.js'), "exports.helperValue = 'shared-helper';\n"); + fs.writeFileSync( + path.join(dir, 'src', 'import-helper.js'), + "const {helperValue} = require('./helper.js');\nexports.rule = {helperValue};\n" + ); + execSync('npm run copy:dist --silent', { cwd: dir, encoding: 'utf8', stdio: 'pipe' }); + + assert.ok(exists(dir, 'dist/import-helper.js')); + assert.ok(exists(dir, 'dist/helper.js')); + assert.match(readFile(dir, 'dist/import-helper.js'), /require\('\.\/helper\.js'\)/); + + try { + execSync(`node "${CLI_PATH}" endpoint add --cwd "${dir}"`, { encoding: 'utf8', stdio: 'pipe' }); + assert.fail('endpoint add should be rejected for JavaScript apps'); + } catch (error) { + const output = (error.stdout || '') + (error.stderr || ''); + assert.match(output, /requires a TypeScript Enhanced DX project/); + } + }); + }); + + describe('Validation', () => { + test('rejects an invalid app name', () => { + const { success, output } = runScaffold('app init --name "Bad Name" --no-install'); + + assert.strictEqual(success, false, 'Command should fail'); + assert.ok(output.includes('Invalid app name'), 'Should show invalid app name error'); + }); + + test('rejects a name starting with a digit', () => { + const { success, output } = runScaffold('app init --name 1app --no-install'); + + assert.strictEqual(success, false); + assert.ok(output.includes('Invalid app name')); + }); + + test('rejects an invalid --type', () => { + const { success, output } = runScaffold('app init --name typed-app --type python --no-install'); + + assert.strictEqual(success, false, 'Command should fail'); + assert.ok(output.includes('Invalid type'), 'Should show invalid type error'); + }); + }); + + describe('Command routing', () => { + test('entity/action commands take precedence over the --name gate', () => { + // `widget add --key ... --name ...` must scaffold a widget, never trigger the app gate. + const indexContent = fs.readFileSync(CLI_PATH, 'utf8'); + const gateIdx = indexContent.indexOf('Non-interactive scaffold gate'); + const widgetIdx = indexContent.indexOf("const widgetIndex = normalizedArgv.findIndex"); + assert.ok(widgetIdx !== -1 && gateIdx !== -1); + assert.ok(widgetIdx < gateIdx, 'widget handling must appear before the scaffold gate'); + }); + + test('rejects the removed commandless flag form', () => { + const { success, output } = runScaffold('--name legacy-app --no-install'); + + assert.strictEqual(success, false); + assert.match(output, /Expected command syntax/); + }); + + test('rejects the removed app create command', () => { + const { success, output } = runScaffold('app create --name legacy-app --no-install'); + + assert.strictEqual(success, false); + assert.match(output, /Unknown command "app create"/); + }); + }); +}); diff --git a/packages/create-youtrack-app/test/settings.test.js b/packages/create-youtrack-app/test/settings.test.js index 407c3cf7..1152c22f 100644 --- a/packages/create-youtrack-app/test/settings.test.js +++ b/packages/create-youtrack-app/test/settings.test.js @@ -94,26 +94,24 @@ describe('App Settings', () => { assert.ok(result.output.includes('already exists'), 'Should show "already exists" error'); }); - test('should work with "setting" alias', () => { + test('should reject removed "setting" alias', () => { const settingsPath = path.join(TEST_APP_DIR, 'src', 'settings.json'); if (fs.existsSync(settingsPath)) fs.unlinkSync(settingsPath); const result = runCLI('setting init --title "Alias Test" --description "Testing alias"', { silent: true }); - assert.strictEqual(result.success, true); - assert.strictEqual(fileExists('src/settings.json'), true); - assert.strictEqual(JSON.parse(readFile('src/settings.json')).title, 'Alias Test'); + assert.strictEqual(result.success, false); + assert.match(result.output, /Unknown command/); }); - test('should work with "s" short alias', () => { + test('should reject removed "s" short alias', () => { const settingsPath = path.join(TEST_APP_DIR, 'src', 'settings.json'); if (fs.existsSync(settingsPath)) fs.unlinkSync(settingsPath); const result = runCLI('s init --title "Short Alias" --description "Testing short"', { silent: true }); - assert.strictEqual(result.success, true); - assert.strictEqual(fileExists('src/settings.json'), true); - assert.strictEqual(JSON.parse(readFile('src/settings.json')).title, 'Short Alias'); + assert.strictEqual(result.success, false); + assert.match(result.output, /Unknown command/); }); test('should handle titles with special characters', () => { @@ -126,11 +124,11 @@ describe('App Settings', () => { assert.strictEqual(JSON.parse(readFile('src/settings.json')).title, "My App's Settings"); }); - test('alias mapping should exist in source code', () => { + test('canonical settings actions should exist in the command registry', () => { const indexContent = fs.readFileSync(path.join(PKG_DIR, 'index.js'), 'utf8'); - assert.ok(indexContent.includes("'s': 'settings'"), 's alias should be mapped to settings'); - assert.ok(indexContent.includes("'setting': 'settings'"), 'setting alias should be mapped to settings'); + assert.ok(indexContent.includes("'settings:init'")); + assert.ok(indexContent.includes("'settings:add'")); }); }); @@ -487,18 +485,16 @@ describe('App Settings', () => { // ── Aliases ──────────────────────────────────────────────────────────── - test('should work with "setting add" alias', () => { + test('should reject removed "setting add" alias', () => { const result = runCLI('setting add --name aliasSettingProp --type string', { silent: true }); - assert.strictEqual(result.success, true, result.output); - - assert.ok(readSettings().properties.aliasSettingProp); + assert.strictEqual(result.success, false); + assert.match(result.output, /Unknown command/); }); - test('should work with "s add" short alias', () => { + test('should reject removed "s add" short alias', () => { const result = runCLI('s add --name aliasShortProp --type integer', { silent: true }); - assert.strictEqual(result.success, true, result.output); - - assert.ok(readSettings().properties.aliasShortProp); + assert.strictEqual(result.success, false); + assert.match(result.output, /Unknown command/); }); // ── Error cases ──────────────────────────────────────────────────────── diff --git a/packages/create-youtrack-app/test/skill-cli.test.js b/packages/create-youtrack-app/test/skill-cli.test.js new file mode 100644 index 00000000..3790e8bd --- /dev/null +++ b/packages/create-youtrack-app/test/skill-cli.test.js @@ -0,0 +1,172 @@ +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const { + installSkill, + getSkillStatus, + runSystemAgentScan, +} = require('../utils/agent-skill'); + +const PKG_DIR = path.join(__dirname, '..'); +const CLI_PATH = path.join(PKG_DIR, 'index.js'); +const TEST_HOME = path.join(PKG_DIR, 'tmp', 'test-skill-home'); +const TEST_PROJECT = path.join(PKG_DIR, 'tmp', 'test-skill-project'); +const TEST_SOURCE = path.join(PKG_DIR, 'tmp', 'test-skill-source'); +const SKILL_NAME = 'youtrack-apps-skill'; + +function runCLI(args) { + try { + const output = execFileSync('node', [CLI_PATH, ...args], { + cwd: PKG_DIR, + encoding: 'utf8', + env: { + ...process.env, + YOUTRACK_SKILL_HOME: TEST_HOME, + NO_COLOR: '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + return { success: true, output }; + } catch (error) { + return { + success: false, + output: `${error.stdout || ''}${error.stderr || ''}`, + error, + }; + } +} + +function agentConfigDir(agent) { + return { + claude: '.claude', + codex: '.codex', + junie: '.junie', + }[agent]; +} + +function targetDir(agent, scope = 'global') { + const root = scope === 'global' ? TEST_HOME : TEST_PROJECT; + return path.join(root, agentConfigDir(agent), 'skills', SKILL_NAME); +} + +function targetDirFor(root, agent) { + return path.join(root, agentConfigDir(agent), 'skills', SKILL_NAME); +} + +describe('Agent skill CLI', () => { + beforeEach(() => { + fs.rmSync(TEST_HOME, { recursive: true, force: true }); + fs.rmSync(TEST_PROJECT, { recursive: true, force: true }); + fs.rmSync(TEST_SOURCE, { recursive: true, force: true }); + fs.mkdirSync(TEST_PROJECT, { recursive: true }); + fs.mkdirSync(TEST_SOURCE, { recursive: true }); + fs.writeFileSync(path.join(TEST_SOURCE, 'SKILL.md'), '# Test skill\n'); + fs.writeFileSync(path.join(TEST_PROJECT, 'package.json'), '{"name":"test-project"}\n'); + }); + + afterEach(() => { + fs.rmSync(TEST_HOME, { recursive: true, force: true }); + fs.rmSync(TEST_PROJECT, { recursive: true, force: true }); + fs.rmSync(TEST_SOURCE, { recursive: true, force: true }); + }); + + test('skill install defaults to global symlinks for all supported agents', async () => { + await installSkill({ homeDir: TEST_HOME }); + assert.strictEqual(fs.existsSync(path.join(targetDir('codex'), 'SKILL.md')), true); + assert.strictEqual(fs.existsSync(path.join(targetDir('claude'), 'SKILL.md')), true); + assert.strictEqual(fs.existsSync(path.join(targetDir('junie'), 'SKILL.md')), true); + assert.strictEqual(fs.lstatSync(targetDir('codex')).isSymbolicLink(), true); + assert.strictEqual(fs.lstatSync(targetDir('claude')).isSymbolicLink(), true); + assert.strictEqual(fs.lstatSync(targetDir('junie')).isSymbolicLink(), true); + assert.match(fs.readFileSync(path.join(targetDir('codex'), 'SKILL.md'), 'utf8'), /YouTrack App Builder/); + }); + + test('--version prints the package version', () => { + const result = runCLI(['--version']); + + assert.strictEqual(result.success, true, result.output); + assert.strictEqual(result.output.trim(), require('../package.json').version); + }); + + test('project-level install uses hard copies', async () => { + const results = await installSkill({ + sourceDir: TEST_SOURCE, + agent: 'codex', + scope: 'project', + cwd: TEST_PROJECT, + homeDir: TEST_HOME, + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].deploymentType, 'copy'); + assert.strictEqual(fs.existsSync(path.join(targetDir('codex', 'project'), 'SKILL.md')), true); + assert.strictEqual(fs.lstatSync(targetDir('codex', 'project')).isSymbolicLink(), false); + }); + + test('project-level install uses the current directory without searching parent project markers', async () => { + const nestedProjectDir = path.join(TEST_PROJECT, 'examples', 'nested-app'); + fs.mkdirSync(nestedProjectDir, { recursive: true }); + fs.mkdirSync(path.join(TEST_PROJECT, '.git')); + + const results = await installSkill({ + sourceDir: TEST_SOURCE, + agent: 'codex', + scope: 'project', + cwd: nestedProjectDir, + homeDir: TEST_HOME, + }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].targetDir, targetDirFor(nestedProjectDir, 'codex')); + assert.strictEqual(fs.existsSync(path.join(targetDirFor(nestedProjectDir, 'codex'), 'SKILL.md')), true); + assert.strictEqual(fs.existsSync(path.join(targetDir('codex', 'project'), 'SKILL.md')), false); + }); + + test('status reports installed and not installed based on target directories', async () => { + await installSkill({ + sourceDir: TEST_SOURCE, + agent: 'codex', + scope: 'global', + homeDir: TEST_HOME, + }); + + const statuses = getSkillStatus({ + agent: 'all', + scope: 'global', + homeDir: TEST_HOME, + cwd: TEST_PROJECT, + }); + + const codex = statuses.find(status => status.agent === 'codex'); + const claude = statuses.find(status => status.agent === 'claude'); + const junie = statuses.find(status => status.agent === 'junie'); + + assert.strictEqual(codex.installed, true); + assert.strictEqual(codex.isSymlink, true); + assert.strictEqual(claude.installed, false); + assert.strictEqual(junie.installed, false); + }); + + test('agent discovery scans only supported agents', () => { + const results = runSystemAgentScan({ + homeDir: TEST_HOME, + cwd: TEST_PROJECT, + env: { PATH: process.env.PATH || '' }, + }); + + assert.deepStrictEqual(results.map(result => result.agent).sort(), ['claude', 'codex', 'junie']); + assert.strictEqual(results.every(result => result.projectAvailable), true); + assert.strictEqual(results.every(result => result.projectRoot === TEST_PROJECT), true); + }); + + test('invalid skill command fails with a clear error', () => { + const result = runCLI(['skill', 'discover']); + + assert.strictEqual(result.success, false, 'Command should fail'); + assert.match(result.output, /Unknown command "skill discover"/); + }); +}); diff --git a/packages/create-youtrack-app/test/widget.test.js b/packages/create-youtrack-app/test/widget.test.js index c7d62e76..7cb9356d 100644 --- a/packages/create-youtrack-app/test/widget.test.js +++ b/packages/create-youtrack-app/test/widget.test.js @@ -148,7 +148,7 @@ describe('Widget Generator', () => { describe('Basic Widget Creation', () => { test('should create all expected widget files', () => { - const result = runCLI('widget --key basic-widget --extension-point DASHBOARD_WIDGET', { silent: true }); + const result = runCLI('widget add --key basic-widget --extension-point DASHBOARD_WIDGET', { silent: true }); assert.strictEqual(result.success, true, `Command should succeed. Output: ${result.output}`); assert.strictEqual(fileExists('src/widgets/basic-widget/index.html'), true, 'index.html should be created'); @@ -179,7 +179,7 @@ describe('Widget Generator', () => { const cleanupLintFixScript = createLintFixScript(argsPath); try { - const result = runCLI('widget --key lint-widget --extension-point DASHBOARD_WIDGET', { silent: true }); + const result = runCLI('widget add --key lint-widget --extension-point DASHBOARD_WIDGET', { silent: true }); assert.strictEqual(result.success, true, result.output); assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsPath, 'utf8')), [ @@ -196,7 +196,7 @@ describe('Widget Generator', () => { describe('Widget Naming', () => { test('should default name to titleized key when --name is omitted', () => { - const result = runCLI('widget --key auto-named-widget --extension-point MAIN_MENU_ITEM', { silent: true }); + const result = runCLI('widget add --key auto-named-widget --extension-point MAIN_MENU_ITEM', { silent: true }); assert.strictEqual(result.success, true, result.output); const widget = readManifest().widgets.find(w => w.key === 'auto-named-widget'); @@ -205,7 +205,7 @@ describe('Widget Generator', () => { }); test('should use explicit --name when provided', () => { - const result = runCLI('widget --key named-widget --name "My Custom Name" --extension-point DASHBOARD_WIDGET', { silent: true }); + const result = runCLI('widget add --key named-widget --name "My Custom Name" --extension-point DASHBOARD_WIDGET', { silent: true }); assert.strictEqual(result.success, true, result.output); const widget = readManifest().widgets.find(w => w.key === 'named-widget'); @@ -219,7 +219,7 @@ describe('Widget Generator', () => { describe('Widget Description', () => { test('should set description in manifest when --description is provided', () => { const result = runCLI( - 'widget --key described-widget --extension-point ISSUE_BELOW_SUMMARY --description "Shows issue metrics"', + 'widget add --key described-widget --extension-point ISSUE_BELOW_SUMMARY --description "Shows issue metrics"', { silent: true } ); assert.strictEqual(result.success, true, result.output); @@ -235,7 +235,7 @@ describe('Widget Generator', () => { describe('Widget Permissions', () => { test('should set permissions array in manifest when --permissions is provided', () => { const result = runCLI( - 'widget --key perms-widget --extension-point DASHBOARD_WIDGET --permissions READ_ISSUE,UPDATE_ISSUE', + 'widget add --key perms-widget --extension-point DASHBOARD_WIDGET --permissions READ_ISSUE,UPDATE_ISSUE', { silent: true } ); assert.strictEqual(result.success, true, result.output); @@ -260,7 +260,7 @@ describe('Widget Generator', () => { describe('Widget Dimensions', () => { test('should set expectedDimensions in manifest when --width and --height are provided', () => { const result = runCLI( - 'widget --key dims-widget --extension-point DASHBOARD_WIDGET --width 800 --height 600', + 'widget add --key dims-widget --extension-point DASHBOARD_WIDGET --width 800 --height 600', { silent: true } ); assert.strictEqual(result.success, true, result.output); @@ -283,7 +283,7 @@ describe('Widget Generator', () => { describe('Error Handling', () => { test('should fail when --extension-point is missing', () => { - const result = runCLI('widget --key no-ep-widget', { silent: true }); + const result = runCLI('widget add --key no-ep-widget', { silent: true }); assert.strictEqual(result.success, false, 'Command should fail without --extension-point'); assert.ok( result.output.includes('extension-point') || result.output.includes('extensionPoint'), @@ -292,7 +292,7 @@ describe('Widget Generator', () => { }); test('should fail with invalid extension point', () => { - const result = runCLI('widget --key invalid-ep-widget --extension-point INVALID_POINT', { silent: true }); + const result = runCLI('widget add --key invalid-ep-widget --extension-point INVALID_POINT', { silent: true }); assert.strictEqual(result.success, false, 'Command should fail with invalid extension point'); assert.ok( result.output.includes('Invalid extension point') || result.output.includes('INVALID_POINT'), @@ -301,17 +301,17 @@ describe('Widget Generator', () => { }); test('should fail with invalid key format (contains spaces)', () => { - const result = runCLI('widget --key "bad key" --extension-point DASHBOARD_WIDGET', { silent: true }); + const result = runCLI('widget add --key "bad key" --extension-point DASHBOARD_WIDGET', { silent: true }); assert.strictEqual(result.success, false, 'Command should fail with key containing spaces'); }); test('should fail with key starting with a digit', () => { - const result = runCLI('widget --key 1bad-key --extension-point DASHBOARD_WIDGET', { silent: true }); + const result = runCLI('widget add --key 1bad-key --extension-point DASHBOARD_WIDGET', { silent: true }); assert.strictEqual(result.success, false, 'Command should fail with key starting with a digit'); }); test('should fail with key containing uppercase letters', () => { - const result = runCLI('widget --key MyWidget --extension-point DASHBOARD_WIDGET', { silent: true }); + const result = runCLI('widget add --key MyWidget --extension-point DASHBOARD_WIDGET', { silent: true }); assert.strictEqual(result.success, false, 'Command should fail with key containing uppercase'); }); }); @@ -323,7 +323,7 @@ describe('Widget Generator', () => { const indexContent = fs.readFileSync(path.join(PKG_DIR, 'index.js'), 'utf8'); assert.ok( indexContent.includes("normalizedArgv.findIndex(a => a === 'widget')"), - 'Widget interception should search normalizedArgv for alias-safety' + 'Widget interception should search normalizedArgv' ); }); }); @@ -334,7 +334,7 @@ describe('Widget Generator', () => { EXTENSION_POINTS.forEach((ep, i) => { test(`should accept extension point ${ep}`, () => { const key = `ep-test-${i}`; - const result = runCLI(`widget --key ${key} --extension-point ${ep}`, { silent: true }); + const result = runCLI(`widget add --key ${key} --extension-point ${ep}`, { silent: true }); assert.strictEqual(result.success, true, `Should succeed for ${ep}. Output: ${result.output}`); diff --git a/packages/create-youtrack-app/utils/agent-skill.js b/packages/create-youtrack-app/utils/agent-skill.js new file mode 100644 index 00000000..3124d0d1 --- /dev/null +++ b/packages/create-youtrack-app/utils/agent-skill.js @@ -0,0 +1,284 @@ +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const SKILL_NAME = 'youtrack-apps-skill'; +const PACKAGED_SKILL_SOURCE_DIR = path.resolve(__dirname, '..', 'skills', SKILL_NAME); +const REPOSITORY_SKILL_SOURCE_DIR = path.resolve(__dirname, '..', '..', '..', 'skills', SKILL_NAME); + +const ALL_AGENTS = 'all'; +const ALL_SCOPES = 'all'; +const GLOBAL_SCOPE = 'global'; +const PROJECT_SCOPE = 'project'; +const DEFAULT_AGENT_SELECTION = ALL_AGENTS; +const DEFAULT_INSTALL_SCOPE = GLOBAL_SCOPE; + +const SUPPORTED_AGENTS = [ + { + id: 'claude', + displayName: 'Claude Code', + configDir: '.claude', + binary: 'claude', + }, + { + id: 'codex', + displayName: 'Codex CLI', + configDir: '.codex', + binary: 'codex', + }, + { + id: 'junie', + displayName: 'Junie', + configDir: '.junie', + binary: 'junie', + }, +]; + +const SUPPORTED_AGENT_BY_ID = Object.fromEntries( + SUPPORTED_AGENTS.map(agent => [agent.id, agent]) +); +const SUPPORTED_AGENT_IDS = SUPPORTED_AGENTS.map(agent => agent.id); +const VALID_AGENT_VALUES = [...SUPPORTED_AGENT_IDS, ALL_AGENTS]; +const VALID_SCOPE_VALUES = [GLOBAL_SCOPE, PROJECT_SCOPE]; +const VALID_SCOPE_INPUT_VALUES = [...VALID_SCOPE_VALUES, ALL_SCOPES]; +const DEPLOYMENT_BY_SCOPE = { + [GLOBAL_SCOPE]: 'symlink', + [PROJECT_SCOPE]: 'copy', +}; + +function getHomeDir() { + return process.env.YOUTRACK_SKILL_HOME || os.homedir(); +} + +function assertSupportedAgent(agentId) { + if (!SUPPORTED_AGENT_BY_ID[agentId]) { + throw new Error(`Invalid agent: "${agentId}". Must be one of: ${VALID_AGENT_VALUES.join(', ')}.`); + } +} + +function expandAgents(agentInput = DEFAULT_AGENT_SELECTION) { + const requestedAgents = String(agentInput || DEFAULT_AGENT_SELECTION) + .split(',') + .map(agent => agent.trim().toLowerCase()) + .filter(Boolean); + const expandedAgents = []; + + for (const agentId of requestedAgents) { + if (agentId === ALL_AGENTS) { + expandedAgents.push(...SUPPORTED_AGENT_IDS); + continue; + } + + assertSupportedAgent(agentId); + expandedAgents.push(agentId); + } + + return [...new Set(expandedAgents)]; +} + +function expandScopes(scopeInput = DEFAULT_INSTALL_SCOPE) { + const scope = String(scopeInput || DEFAULT_INSTALL_SCOPE).toLowerCase(); + + if (!VALID_SCOPE_INPUT_VALUES.includes(scope)) { + throw new Error(`Invalid skill scope: "${scopeInput}". Must be one of: ${VALID_SCOPE_INPUT_VALUES.join(', ')}.`); + } + + return scope === ALL_SCOPES ? [...VALID_SCOPE_VALUES] : [scope]; +} + +function resolveProjectRoot(options = {}) { + return path.resolve(options.projectRoot || options.cwd || process.cwd()); +} + +function getAgentSkillsDir(agentId, scope, options = {}) { + assertSupportedAgent(agentId); + + const agent = SUPPORTED_AGENT_BY_ID[agentId]; + const rootDir = scope === GLOBAL_SCOPE + ? (options.homeDir || getHomeDir()) + : resolveProjectRoot(options); + + return path.join(rootDir, agent.configDir, 'skills'); +} + +function getSkillSourceDir() { + for (const sourceDir of [PACKAGED_SKILL_SOURCE_DIR, REPOSITORY_SKILL_SOURCE_DIR]) { + if (fs.existsSync(path.join(sourceDir, 'SKILL.md'))) { + return sourceDir; + } + } + + throw new Error('Could not find the bundled YouTrack Apps skill.'); +} + +function createInstallPlan(sourceDir, options = {}) { + const agents = expandAgents(options.agent || DEFAULT_AGENT_SELECTION); + const scopes = expandScopes(options.scope || DEFAULT_INSTALL_SCOPE); + + return scopes.flatMap(scope => agents.map(agentId => { + const targetDir = path.join(getAgentSkillsDir(agentId, scope, options), SKILL_NAME); + const deploymentType = DEPLOYMENT_BY_SCOPE[scope]; + + return { + agent: agentId, + scope, + sourceDir, + targetDir, + deploymentType, + }; + })); +} + +function removePreviousInstall(targetDir) { + fs.rmSync(targetDir, { recursive: true, force: true }); +} + +function prepareTargetParent(targetDir) { + fs.mkdirSync(path.dirname(targetDir), { recursive: true }); +} + +function copySkillDirectory(sourceDir, targetDir) { + removePreviousInstall(targetDir); + prepareTargetParent(targetDir); + fs.cpSync(sourceDir, targetDir, { recursive: true }); +} + +function symlinkSkillDirectory(sourceDir, targetDir) { + removePreviousInstall(targetDir); + prepareTargetParent(targetDir); + const linkType = process.platform === 'win32' ? 'junction' : 'dir'; + fs.symlinkSync(sourceDir, targetDir, linkType); +} + +function deploySkill(planItem) { + if (planItem.deploymentType === 'symlink') { + symlinkSkillDirectory(planItem.sourceDir, planItem.targetDir); + return; + } + + copySkillDirectory(planItem.sourceDir, planItem.targetDir); +} + +async function installSkill(options = {}) { + const sourceDir = options.sourceDir || getSkillSourceDir(); + return createInstallPlan(sourceDir, options).map(planItem => { + deploySkill(planItem); + + return { + agent: planItem.agent, + scope: planItem.scope, + targetDir: planItem.targetDir, + deploymentType: planItem.deploymentType, + }; + }); +} + +function getInstallStatus(agentId, scope, options = {}) { + const targetDir = path.join(getAgentSkillsDir(agentId, scope, options), SKILL_NAME); + const targetExists = fs.existsSync(targetDir); + const targetStats = targetExists ? fs.lstatSync(targetDir) : null; + + return { + agent: agentId, + scope, + targetDir, + installed: targetExists, + isSymlink: Boolean(targetStats && targetStats.isSymbolicLink()), + }; +} + +function getSkillStatus(options = {}) { + const agents = expandAgents(options.agent || DEFAULT_AGENT_SELECTION); + const scopes = expandScopes(options.scope || DEFAULT_INSTALL_SCOPE); + + return scopes.flatMap(scope => ( + agents.map(agentId => getInstallStatus(agentId, scope, options)) + )); +} + +function findBinary(binary, options = {}) { + const command = process.platform === 'win32' ? 'where' : 'which'; + const result = spawnSync(command, [binary], { + encoding: 'utf8', + env: options.env || process.env, + shell: false, + stdio: ['ignore', 'pipe', 'ignore'], + }); + + if (result.status !== 0) { + return null; + } + + return String(result.stdout || '').split(/\r?\n/).find(Boolean) || null; +} + +function getAgentDiscovery(agent, options = {}) { + const homeDir = options.homeDir || getHomeDir(); + const projectRoot = resolveProjectRoot(options); + const globalBaseDir = path.join(homeDir, agent.configDir); + const binaryPath = findBinary(agent.binary, options); + + return { + agent: agent.id, + displayName: agent.displayName, + configDir: agent.configDir, + binary: agent.binary, + binaryPath, + binaryFound: Boolean(binaryPath), + globalBaseDir, + globalSkillsDir: path.join(globalBaseDir, 'skills'), + globalConfigExists: fs.existsSync(globalBaseDir), + detected: fs.existsSync(globalBaseDir) && Boolean(binaryPath), + projectRoot, + projectAvailable: true, + projectSkillsDir: path.join(projectRoot, agent.configDir, 'skills'), + }; +} + +function runSystemAgentScan(options = {}) { + return SUPPORTED_AGENTS.map(agent => getAgentDiscovery(agent, options)); +} + +function formatAgentName(agentId) { + const agent = SUPPORTED_AGENT_BY_ID[agentId]; + return agent ? agent.displayName : agentId.charAt(0).toUpperCase() + agentId.slice(1); +} + +function formatInstallResults(results, action) { + const verb = action === 'update' ? 'Updated' : 'Installed'; + const lines = [`${verb} YouTrack Apps skill:`]; + + for (const result of results) { + lines.push(`- ${formatAgentName(result.agent)} (${result.scope}, ${result.deploymentType}): ${result.targetDir}`); + } + + return lines.join('\n'); +} + +function formatStatusResults(statuses) { + const lines = ['YouTrack Apps skill status:']; + + for (const status of statuses) { + const agentName = formatAgentName(status.agent); + const deployment = status.isSymlink ? 'symlink' : 'copy'; + + if (status.installed) { + lines.push(`- ${agentName} (${status.scope}): installed (${deployment})`); + } else { + lines.push(`- ${agentName} (${status.scope}): not installed`); + } + + lines.push(` ${status.targetDir}`); + } + + return lines.join('\n'); +} + +module.exports = { + formatInstallResults, + formatStatusResults, + getSkillStatus, + installSkill, + runSystemAgentScan, +}; diff --git a/packages/create-youtrack-app/utils/rule-scaffold.js b/packages/create-youtrack-app/utils/rule-scaffold.js new file mode 100644 index 00000000..649529d4 --- /dev/null +++ b/packages/create-youtrack-app/utils/rule-scaffold.js @@ -0,0 +1,153 @@ +const path = require('node:path'); + +const VALID_RULE_TYPES = Object.freeze([ + 'onChange', + 'onSchedule', + 'action', + 'stateMachine', + 'sla', +]); + +function validateRuleType(ruleType) { + if (!VALID_RULE_TYPES.includes(ruleType)) { + throw new Error(`Invalid rule type: "${ruleType}". Must be one of: ${VALID_RULE_TYPES.join(', ')}.`); + } +} + +function validateRuleName(name) { + if (typeof name !== 'string' || name.length === 0) { + throw new Error('Invalid rule name: must be a lowercase dashed filename stem.'); + } + + if (name.includes('/') || name.includes('\\')) { + throw new Error(`Invalid rule name: "${name}". Nested paths are not supported.`); + } + + if (name.includes('.')) { + throw new Error(`Invalid rule name: "${name}". Do not include a file extension.`); + } + + if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(name)) { + throw new Error(`Invalid rule name: "${name}". Must use lowercase letters, numbers, and single hyphens.`); + } +} + +function getRuleExtension(isEnhancedDX = false) { + return isEnhancedDX ? 'ts' : 'js'; +} + +function resolveRuleTarget(cwd, name, isEnhancedDX = false) { + const relativePath = isEnhancedDX + ? path.join('src', 'workflows', `${name}.ts`) + : path.join('src', `${name}.js`); + + return { + relativePath, + absolutePath: path.join(cwd, relativePath), + }; +} + +function renderJsRequirements() { + return ' requirements: { /* TODO: add requirements */ },'; +} + +function renderTsRequirements() { + return ' requirements,'; +} + +function buildRuleBodies(renderRequirements) { + return { + onChange: ` title: '', // TODO: add rule title + guard: (ctx) => { + // TODO: return true when the rule should run + return false; + }, + action: (ctx) => { + // TODO: implement rule action + }, +${renderRequirements()}`, + + onSchedule: ` title: '', // TODO: add rule title + search: '', // TODO: add issue search query + cron: '', // TODO: add cron expression + action: (ctx) => { + // TODO: implement scheduled action + }, +${renderRequirements()}`, + + action: ` title: '', // TODO: add rule title + command: '', // TODO: add command name + guard: (ctx) => { + // TODO: return true when the action should be available + return false; + }, + action: (ctx) => { + // TODO: implement action + }, +${renderRequirements()}`, + + stateMachine: ` title: '', // TODO: add rule title + fieldName: '', // TODO: add field name + states: { /* TODO: define states and transitions */ }, +${renderRequirements()}`, + + sla: ` title: '', // TODO: add SLA title + guard: (ctx) => { + // TODO: return true when this SLA policy should apply + return false; + }, + onEnter: (ctx) => { + // TODO: initialize SLA timers + }, + action: (ctx) => { + // TODO: update SLA timers + }, + onBreach: (ctx) => { + // TODO: handle breached SLA goal + }, +${renderRequirements()}`, + }; +} + +function renderRuleTemplate(ruleType, isEnhancedDX = false) { + validateRuleType(ruleType); + + const ruleBodies = buildRuleBodies(isEnhancedDX ? renderTsRequirements : renderJsRequirements); + + if (isEnhancedDX) { + return `import { Issue } from '@jetbrains/youtrack-scripting-api/entities'; +import { requirements } from '../backend/requirements'; + +export const rule = Issue.${ruleType}({ +${ruleBodies[ruleType]} +}); +`; + } + + return `const entities = require('@jetbrains/youtrack-scripting-api/entities'); + +exports.rule = entities.Issue.${ruleType}({ +${ruleBodies[ruleType]} +}); +`; +} + +function buildRuleScaffold(cwd, ruleType, name, isEnhancedDX = false) { + validateRuleType(ruleType); + validateRuleName(name); + + return { + ...resolveRuleTarget(cwd, name, isEnhancedDX), + content: renderRuleTemplate(ruleType, isEnhancedDX), + }; +} + +module.exports = { + VALID_RULE_TYPES, + buildRuleScaffold, + getRuleExtension, + renderRuleTemplate, + resolveRuleTarget, + validateRuleName, + validateRuleType, +};