Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 58 additions & 15 deletions packages/create-youtrack-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -26,36 +41,60 @@ 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 <type> --name <name>`
- `<type>`: `onChange`, `onSchedule`, `action`, `stateMachine`, or `sla`
- `<name>`: lowercase dashed filename stem, for example `notify-on-change`
- JavaScript apps create `src/<name>.js`, beside handlers and shared helpers.
- TypeScript Enhanced DX apps create `src/workflows/<name>.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:**
```bash
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:**
Expand All @@ -65,27 +104,31 @@ npm run g # Shows a menu for choosin

#### Syntax Reference

**HTTP Handler:** `npm run g -- handler <scope>/<path> [--method METHOD] [--permissions PERMS]`
**HTTP Handler:** `npm run g -- http-handler add --scope <scope> [--path <path>] [--method METHOD] [--permissions PERMS]`
- `<scope>`: `global`, `project`, `issue`, `article`, or `user`
- `<path>`: 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 <Entity>.<name> [--type TYPE] [--set]`
**Typed Endpoint:** `npx @jetbrains/create-youtrack-app endpoint add [--scope <scope>] [--path <path>] [--method METHOD] [--request-type TYPE] [--response-type TYPE] [--controller NAME]`
- TypeScript Enhanced DX apps only; omit the options for interactive prompts.
- `<scope>`: `global`, `issue`, `project`, or `custom`
- `<path>`: 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 <Entity> --name <name> [--type TYPE] [--set]`
- `<Entity>`: `Issue`, `User`, `Project`, or `Article`
- `<name>`: 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

Expand Down
122 changes: 73 additions & 49 deletions packages/create-youtrack-app/_templates/endpoint/add/index.js
Original file line number Diff line number Diff line change
@@ -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 || '')
};
}
};

This file was deleted.

Loading