@termix-ssh/plugin-sdk 1.0.1 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +74 -0
- package/LICENSE +202 -0
- package/README.md +4 -3
- package/cli/commands/build.mjs +37 -12
- package/cli/commands/dev.mjs +1 -1
- package/cli/commands/openapi.mjs +39 -0
- package/cli/commands/validate.mjs +67 -0
- package/cli/index.mjs +3 -0
- package/cli/lib/css-classes.mjs +37 -0
- package/dist/changelog.d.ts +3 -8
- package/dist/changelog.js +6 -14
- package/dist/docs.d.ts +28 -0
- package/dist/docs.js +39 -0
- package/dist/frontend.d.ts +17 -0
- package/dist/frontend.js +8 -0
- package/dist/host/{chunk-COGRSHKO.js → chunk-F5RZH6HE.js} +13 -2
- package/dist/host/{chunk-3KG44G55.js → chunk-O2OHNVFX.js} +1 -1
- package/dist/host/{chunk-7GL25GFS.js → chunk-RU4E5XLY.js} +531 -300
- package/dist/host/core-classes.json +1 -0
- package/dist/host/{linked-server-BD573BEG.js → linked-server-4VWYYABP.js} +1 -1
- package/dist/host/{rbac-api-7GHDJLU4.js → rbac-api-WIJVZH3M.js} +1 -1
- package/dist/host/testing-host.js +3 -3
- package/dist/host/types/src/ui/api/plugins-api.d.ts +23 -0
- package/dist/host/types/src/ui/api/system-status-api.d.ts +24 -0
- package/dist/host/types/src/ui/components/docs-link.d.ts +25 -0
- package/dist/host/types/src/ui/components/panel-layout.d.ts +3 -1
- package/dist/host/types/src/ui/hooks/use-installed-plugins.d.ts +6 -0
- package/dist/host/types/src/ui/lib/docs.d.ts +10 -0
- package/dist/host/types/src/ui/main-axios.d.ts +1 -0
- package/dist/host/types/src/ui/plugin-host/sdk-ui.d.ts +1 -0
- package/dist/host/types/src/ui/settings/settings-fields-util.d.ts +10 -0
- package/dist/host/ui.js +139 -110
- package/dist/host/{use-permissions-SKWXHWDJ.js → use-permissions-KIQCJ35M.js} +1 -1
- package/dist/manifest.d.ts +16 -1
- package/dist/manifest.js +84 -1
- package/dist/openapi.d.ts +43 -0
- package/dist/openapi.js +60 -0
- package/dist/testing/setup.js +1 -0
- package/package.json +12 -2
- package/schema/manifest.schema.json +27 -0
package/dist/manifest.d.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* "permissionGroups" used to validate clean and be silently dropped, which
|
|
10
10
|
* made a manifest look like it declared something it did not.
|
|
11
11
|
*/
|
|
12
|
+
import { type PluginEnvVar } from "./docs.js";
|
|
13
|
+
export { ENV_NAME_PATTERN, MAX_ENV_DESCRIPTION_LENGTH, pluginDocsPage, pluginDocsUrl, pluginEnvVars, type PluginEnvVar, } from "./docs.js";
|
|
12
14
|
/**
|
|
13
15
|
* The plugin API this build implements. A minor bump adds to the API and
|
|
14
16
|
* never removes; a major bump may break plugins.
|
|
@@ -17,7 +19,7 @@
|
|
|
17
19
|
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
|
18
20
|
* version by the server.
|
|
19
21
|
*/
|
|
20
|
-
export declare const PLUGIN_API_VERSION = "1.
|
|
22
|
+
export declare const PLUGIN_API_VERSION = "1.2.0";
|
|
21
23
|
/** The API major, the default engine.api for a new plugin. */
|
|
22
24
|
export declare const SUPPORTED_PLUGIN_API_VERSION: string;
|
|
23
25
|
/** Whether this build's plugin API satisfies a manifest's engine.api. */
|
|
@@ -27,6 +29,13 @@ export declare function isApiCompatible(range: string): boolean;
|
|
|
27
29
|
* core (2.9.0-beta.1) counts as that release.
|
|
28
30
|
*/
|
|
29
31
|
export declare function isTermixCompatible(range: string, coreVersion: string | null): boolean;
|
|
32
|
+
export declare const MAX_FEATURES = 20;
|
|
33
|
+
export declare const MAX_FEATURE_LENGTH = 160;
|
|
34
|
+
/**
|
|
35
|
+
* The usable feature lines of a manifest or registry entry. Anything that is
|
|
36
|
+
* not a short non-empty string is dropped, so an index cannot flood the page.
|
|
37
|
+
*/
|
|
38
|
+
export declare function pluginFeatures(value: unknown): string[];
|
|
30
39
|
/**
|
|
31
40
|
* The video id of a YouTube link, or null for anything else. Only the id is
|
|
32
41
|
* ever used, so a manifest can never point the embed at another site.
|
|
@@ -416,6 +425,12 @@ export interface PluginManifest {
|
|
|
416
425
|
icon?: string;
|
|
417
426
|
/** A YouTube link shown at the top of the plugin's page. */
|
|
418
427
|
video?: string;
|
|
428
|
+
/** Short lines listing what the plugin does, shown on its page. */
|
|
429
|
+
features?: string[];
|
|
430
|
+
/** Where the plugin's docs live. https only. */
|
|
431
|
+
docs?: string;
|
|
432
|
+
/** Environment variables the plugin reads. */
|
|
433
|
+
env?: PluginEnvVar[];
|
|
419
434
|
engine: PluginEngine;
|
|
420
435
|
/** Catalog capability ids. */
|
|
421
436
|
capabilities: string[];
|
package/dist/manifest.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import semver from "semver";
|
|
13
13
|
import { isKnownCapability } from "./capabilities.js";
|
|
14
|
+
import { ENV_NAME_PATTERN, MAX_ENV_DESCRIPTION_LENGTH, pluginDocsUrl, } from "./docs.js";
|
|
15
|
+
export { ENV_NAME_PATTERN, MAX_ENV_DESCRIPTION_LENGTH, pluginDocsPage, pluginDocsUrl, pluginEnvVars, } from "./docs.js";
|
|
14
16
|
/**
|
|
15
17
|
* The plugin API this build implements. A minor bump adds to the API and
|
|
16
18
|
* never removes; a major bump may break plugins.
|
|
@@ -19,7 +21,7 @@ import { isKnownCapability } from "./capabilities.js";
|
|
|
19
21
|
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
|
20
22
|
* version by the server.
|
|
21
23
|
*/
|
|
22
|
-
export const PLUGIN_API_VERSION = "1.
|
|
24
|
+
export const PLUGIN_API_VERSION = "1.2.0";
|
|
23
25
|
/** The API major, the default engine.api for a new plugin. */
|
|
24
26
|
export const SUPPORTED_PLUGIN_API_VERSION = String(semver.major(PLUGIN_API_VERSION));
|
|
25
27
|
/** Whether this build's plugin API satisfies a manifest's engine.api. */
|
|
@@ -47,6 +49,22 @@ const YOUTUBE_HOSTS = new Set([
|
|
|
47
49
|
"youtube-nocookie.com",
|
|
48
50
|
"www.youtube-nocookie.com",
|
|
49
51
|
]);
|
|
52
|
+
export const MAX_FEATURES = 20;
|
|
53
|
+
export const MAX_FEATURE_LENGTH = 160;
|
|
54
|
+
/**
|
|
55
|
+
* The usable feature lines of a manifest or registry entry. Anything that is
|
|
56
|
+
* not a short non-empty string is dropped, so an index cannot flood the page.
|
|
57
|
+
*/
|
|
58
|
+
export function pluginFeatures(value) {
|
|
59
|
+
if (!Array.isArray(value))
|
|
60
|
+
return [];
|
|
61
|
+
return value
|
|
62
|
+
.filter((f) => typeof f === "string" &&
|
|
63
|
+
f.trim().length > 0 &&
|
|
64
|
+
f.length <= MAX_FEATURE_LENGTH)
|
|
65
|
+
.map((f) => f.trim())
|
|
66
|
+
.slice(0, MAX_FEATURES);
|
|
67
|
+
}
|
|
50
68
|
/**
|
|
51
69
|
* The video id of a YouTube link, or null for anything else. Only the id is
|
|
52
70
|
* ever used, so a manifest can never point the embed at another site.
|
|
@@ -190,6 +208,9 @@ const ALLOWED_TOP_LEVEL = new Set([
|
|
|
190
208
|
"repository",
|
|
191
209
|
"icon",
|
|
192
210
|
"video",
|
|
211
|
+
"features",
|
|
212
|
+
"docs",
|
|
213
|
+
"env",
|
|
193
214
|
"dependencies",
|
|
194
215
|
"optionalDependencies",
|
|
195
216
|
"provides",
|
|
@@ -319,6 +340,11 @@ export function validateManifest(manifest) {
|
|
|
319
340
|
if ("video" in m && youtubeVideoId(m.video) === null) {
|
|
320
341
|
errors.push(`Field "video" must be a YouTube link such as https://www.youtube.com/watch?v=..., got: ${JSON.stringify(m.video)}`);
|
|
321
342
|
}
|
|
343
|
+
validateFeatures(m.features, errors);
|
|
344
|
+
if ("docs" in m && pluginDocsUrl(m.docs) === null) {
|
|
345
|
+
errors.push(`Field "docs" must be an https URL, got: ${JSON.stringify(m.docs)}`);
|
|
346
|
+
}
|
|
347
|
+
validateEnv(m.env, errors);
|
|
322
348
|
for (const field of ["backend", "frontend", "locales"]) {
|
|
323
349
|
if (field in m && requireString(m[field], field, errors)) {
|
|
324
350
|
requireRelativePath(m[field], field, errors);
|
|
@@ -343,6 +369,63 @@ export function validateManifest(manifest) {
|
|
|
343
369
|
validateContributes(m.contributes, typeof m.id === "string" ? m.id : undefined, errors);
|
|
344
370
|
return errors;
|
|
345
371
|
}
|
|
372
|
+
function validateFeatures(features, errors) {
|
|
373
|
+
if (features === undefined)
|
|
374
|
+
return;
|
|
375
|
+
if (!Array.isArray(features)) {
|
|
376
|
+
errors.push('Field "features" must be an array of strings');
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (features.length > MAX_FEATURES) {
|
|
380
|
+
errors.push(`Field "features" may list at most ${MAX_FEATURES} items`);
|
|
381
|
+
}
|
|
382
|
+
features.forEach((feature, i) => {
|
|
383
|
+
if (typeof feature !== "string" ||
|
|
384
|
+
feature.trim().length === 0 ||
|
|
385
|
+
feature.length > MAX_FEATURE_LENGTH) {
|
|
386
|
+
errors.push(`Field "features[${i}]" must be a non-empty string of at most ${MAX_FEATURE_LENGTH} characters`);
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
function validateEnv(env, errors) {
|
|
391
|
+
if (env === undefined)
|
|
392
|
+
return;
|
|
393
|
+
if (!Array.isArray(env)) {
|
|
394
|
+
errors.push('Field "env" must be an array');
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const seen = new Set();
|
|
398
|
+
env.forEach((entry, i) => {
|
|
399
|
+
const where = `env[${i}]`;
|
|
400
|
+
if (!isPlainObject(entry)) {
|
|
401
|
+
errors.push(`Field "${where}" must be an object`);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
rejectUnknown(entry, ["name", "description", "default", "required", "secret"], where, errors);
|
|
405
|
+
if (typeof entry.name !== "string" || !ENV_NAME_PATTERN.test(entry.name)) {
|
|
406
|
+
errors.push(`Field "${where}.name" must be an uppercase name like MY_PLUGIN_URL, got: ${JSON.stringify(entry.name)}`);
|
|
407
|
+
}
|
|
408
|
+
else if (seen.has(entry.name)) {
|
|
409
|
+
errors.push(`Field "${where}.name" repeats ${entry.name}`);
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
seen.add(entry.name);
|
|
413
|
+
}
|
|
414
|
+
if (typeof entry.description !== "string" ||
|
|
415
|
+
entry.description.trim().length === 0 ||
|
|
416
|
+
entry.description.length > MAX_ENV_DESCRIPTION_LENGTH) {
|
|
417
|
+
errors.push(`Field "${where}.description" must be a non-empty string of at most ${MAX_ENV_DESCRIPTION_LENGTH} characters`);
|
|
418
|
+
}
|
|
419
|
+
if ("default" in entry && typeof entry.default !== "string") {
|
|
420
|
+
errors.push(`Field "${where}.default" must be a string`);
|
|
421
|
+
}
|
|
422
|
+
for (const flag of ["required", "secret"]) {
|
|
423
|
+
if (flag in entry && typeof entry[flag] !== "boolean") {
|
|
424
|
+
errors.push(`Field "${where}.${flag}" must be true or false`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
}
|
|
346
429
|
function validateAuthor(author, errors) {
|
|
347
430
|
if (author === undefined)
|
|
348
431
|
return;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds an OpenAPI spec from the @openapi JSDoc blocks above routes. Core and
|
|
3
|
+
* every plugin use this, so the docs site renders them all the same way.
|
|
4
|
+
*/
|
|
5
|
+
export interface OpenApiTag {
|
|
6
|
+
name: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface BuildOpenApiOptions {
|
|
10
|
+
title: string;
|
|
11
|
+
version: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
/** Source files or globs holding @openapi blocks. */
|
|
14
|
+
files: string[];
|
|
15
|
+
tags?: OpenApiTag[];
|
|
16
|
+
}
|
|
17
|
+
export type OpenApiSpec = Record<string, unknown> & {
|
|
18
|
+
paths?: Record<string, unknown>;
|
|
19
|
+
};
|
|
20
|
+
/** How every Termix route is signed in to. */
|
|
21
|
+
export declare const TERMIX_SECURITY_SCHEMES: {
|
|
22
|
+
readonly bearerAuth: {
|
|
23
|
+
readonly type: "http";
|
|
24
|
+
readonly scheme: "bearer";
|
|
25
|
+
readonly bearerFormat: "JWT";
|
|
26
|
+
readonly description: "Session token sent in the Authorization header as Bearer and the token.";
|
|
27
|
+
};
|
|
28
|
+
readonly cookieAuth: {
|
|
29
|
+
readonly type: "apiKey";
|
|
30
|
+
readonly in: "cookie";
|
|
31
|
+
readonly name: "jwt";
|
|
32
|
+
readonly description: "Session JWT cookie set at sign-in. When present, it takes precedence over the Authorization header.";
|
|
33
|
+
};
|
|
34
|
+
readonly apiKeyAuth: {
|
|
35
|
+
readonly type: "http";
|
|
36
|
+
readonly scheme: "bearer";
|
|
37
|
+
readonly bearerFormat: "API key starting with tmx_";
|
|
38
|
+
readonly description: "API key sent in the Authorization header as Bearer and the key, which starts with tmx_. It acts as the key owner and cannot act as anyone else.";
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
export declare function buildOpenApi(options: BuildOpenApiOptions): Promise<OpenApiSpec>;
|
|
42
|
+
/** Whether a spec documents any route. */
|
|
43
|
+
export declare function hasOpenApiPaths(spec: OpenApiSpec): boolean;
|
package/dist/openapi.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds an OpenAPI spec from the @openapi JSDoc blocks above routes. Core and
|
|
3
|
+
* every plugin use this, so the docs site renders them all the same way.
|
|
4
|
+
*/
|
|
5
|
+
import swaggerJSDoc from "@deadendjs/swagger-jsdoc";
|
|
6
|
+
/** How every Termix route is signed in to. */
|
|
7
|
+
export const TERMIX_SECURITY_SCHEMES = {
|
|
8
|
+
bearerAuth: {
|
|
9
|
+
type: "http",
|
|
10
|
+
scheme: "bearer",
|
|
11
|
+
bearerFormat: "JWT",
|
|
12
|
+
description: "Session token sent in the Authorization header as Bearer and the token.",
|
|
13
|
+
},
|
|
14
|
+
cookieAuth: {
|
|
15
|
+
type: "apiKey",
|
|
16
|
+
in: "cookie",
|
|
17
|
+
name: "jwt",
|
|
18
|
+
description: "Session JWT cookie set at sign-in. When present, it takes precedence over the Authorization header.",
|
|
19
|
+
},
|
|
20
|
+
apiKeyAuth: {
|
|
21
|
+
type: "http",
|
|
22
|
+
scheme: "bearer",
|
|
23
|
+
bearerFormat: "API key starting with tmx_",
|
|
24
|
+
description: "API key sent in the Authorization header as Bearer and the key, which starts with tmx_. It acts as the key owner and cannot act as anyone else.",
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
export async function buildOpenApi(options) {
|
|
28
|
+
const spec = (await swaggerJSDoc({
|
|
29
|
+
definition: {
|
|
30
|
+
openapi: "3.0.3",
|
|
31
|
+
info: {
|
|
32
|
+
title: options.title,
|
|
33
|
+
version: options.version,
|
|
34
|
+
...(options.description ? { description: options.description } : {}),
|
|
35
|
+
},
|
|
36
|
+
servers: [{ url: "/", description: "Your Termix server" }],
|
|
37
|
+
components: {
|
|
38
|
+
securitySchemes: TERMIX_SECURITY_SCHEMES,
|
|
39
|
+
schemas: {
|
|
40
|
+
Error: {
|
|
41
|
+
type: "object",
|
|
42
|
+
properties: {
|
|
43
|
+
error: { type: "string" },
|
|
44
|
+
details: { type: "string" },
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
security: [{ bearerAuth: [] }, { cookieAuth: [] }, { apiKeyAuth: [] }],
|
|
50
|
+
...(options.tags?.length ? { tags: options.tags } : {}),
|
|
51
|
+
},
|
|
52
|
+
apis: options.files.map((f) => f.replace(/\\/g, "/")),
|
|
53
|
+
failOnErrors: true,
|
|
54
|
+
}));
|
|
55
|
+
return spec;
|
|
56
|
+
}
|
|
57
|
+
/** Whether a spec documents any route. */
|
|
58
|
+
export function hasOpenApiPaths(spec) {
|
|
59
|
+
return Object.keys(spec.paths ?? {}).length > 0;
|
|
60
|
+
}
|
package/dist/testing/setup.js
CHANGED
|
@@ -77,6 +77,7 @@ const noop = () => { };
|
|
|
77
77
|
const offline = () => Promise.reject(new Error("No plugin API in a unit test; mock it"));
|
|
78
78
|
globalThis.__termixTestPluginHost = {
|
|
79
79
|
usePluginId: () => "test-plugin",
|
|
80
|
+
useDocsUrl: () => null,
|
|
80
81
|
useTranslation: () => ({
|
|
81
82
|
t: (key) => key,
|
|
82
83
|
language: "en",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@termix-ssh/plugin-sdk",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"node": ">=22"
|
|
15
15
|
},
|
|
16
16
|
"bin": {
|
|
17
|
-
"termix-plugin": "
|
|
17
|
+
"termix-plugin": "cli/index.mjs"
|
|
18
18
|
},
|
|
19
19
|
"exports": {
|
|
20
20
|
"./backend": {
|
|
@@ -53,6 +53,14 @@
|
|
|
53
53
|
"types": "./dist/changelog.d.ts",
|
|
54
54
|
"default": "./dist/changelog.js"
|
|
55
55
|
},
|
|
56
|
+
"./docs": {
|
|
57
|
+
"types": "./dist/docs.d.ts",
|
|
58
|
+
"default": "./dist/docs.js"
|
|
59
|
+
},
|
|
60
|
+
"./openapi": {
|
|
61
|
+
"types": "./dist/openapi.d.ts",
|
|
62
|
+
"default": "./dist/openapi.js"
|
|
63
|
+
},
|
|
56
64
|
"./settings": {
|
|
57
65
|
"types": "./dist/settings.d.ts",
|
|
58
66
|
"default": "./dist/settings.js"
|
|
@@ -94,6 +102,7 @@
|
|
|
94
102
|
"schema",
|
|
95
103
|
"tsconfig.plugin.json",
|
|
96
104
|
"README.md",
|
|
105
|
+
"CHANGELOG.md",
|
|
97
106
|
"client.d.ts"
|
|
98
107
|
],
|
|
99
108
|
"scripts": {
|
|
@@ -101,6 +110,7 @@
|
|
|
101
110
|
"prepublishOnly": "npm run build"
|
|
102
111
|
},
|
|
103
112
|
"dependencies": {
|
|
113
|
+
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
|
104
114
|
"@tailwindcss/node": "^4.2.4",
|
|
105
115
|
"@tailwindcss/oxide": "^4.2.4",
|
|
106
116
|
"esbuild": "^0.28.1",
|
|
@@ -79,6 +79,33 @@
|
|
|
79
79
|
"pattern": "^https://(www\\.|m\\.)?(youtube\\.com|youtu\\.be|youtube-nocookie\\.com)/",
|
|
80
80
|
"description": "A YouTube link shown at the top of the plugin's page."
|
|
81
81
|
},
|
|
82
|
+
"features": {
|
|
83
|
+
"type": "array",
|
|
84
|
+
"maxItems": 20,
|
|
85
|
+
"items": { "type": "string", "minLength": 1, "maxLength": 160 },
|
|
86
|
+
"description": "Short lines listing what the plugin does, shown on its page."
|
|
87
|
+
},
|
|
88
|
+
"docs": {
|
|
89
|
+
"type": "string",
|
|
90
|
+
"pattern": "^https://",
|
|
91
|
+
"description": "Where the plugin's docs live. Shown as a Documentation link in Termix."
|
|
92
|
+
},
|
|
93
|
+
"env": {
|
|
94
|
+
"type": "array",
|
|
95
|
+
"description": "Environment variables the plugin reads, listed in its docs.",
|
|
96
|
+
"items": {
|
|
97
|
+
"type": "object",
|
|
98
|
+
"additionalProperties": false,
|
|
99
|
+
"required": ["name", "description"],
|
|
100
|
+
"properties": {
|
|
101
|
+
"name": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,63}$" },
|
|
102
|
+
"description": { "type": "string", "minLength": 1, "maxLength": 300 },
|
|
103
|
+
"default": { "type": "string" },
|
|
104
|
+
"required": { "type": "boolean" },
|
|
105
|
+
"secret": { "type": "boolean" }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
},
|
|
82
109
|
"engine": {
|
|
83
110
|
"type": "object",
|
|
84
111
|
"additionalProperties": false,
|