@ryuhq/sdk 0.1.14 → 0.2.2

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.
Files changed (105) hide show
  1. package/README.md +69 -7
  2. package/dist/action.cjs +839 -0
  3. package/dist/action.d.cts +88 -0
  4. package/dist/action.d.ts +88 -0
  5. package/dist/action.js +8 -0
  6. package/dist/agent-plugin.cjs +46 -0
  7. package/dist/agent-plugin.d.cts +43 -33
  8. package/dist/agent-plugin.d.ts +43 -33
  9. package/dist/agent-plugin.js +1 -1
  10. package/dist/agent.cjs +16 -0
  11. package/dist/agent.d.cts +377 -1
  12. package/dist/agent.d.ts +377 -1
  13. package/dist/agent.js +4 -2
  14. package/dist/app-B0Z9Ew_R.d.cts +141 -0
  15. package/dist/app-C-BDJwfG.d.ts +141 -0
  16. package/dist/builder.cjs +1194 -0
  17. package/dist/builder.d.cts +217 -0
  18. package/dist/builder.d.ts +217 -0
  19. package/dist/builder.js +29 -0
  20. package/dist/chunk-4TPUZDTI.js +94 -0
  21. package/dist/chunk-BC3A7HMO.js +164 -0
  22. package/dist/chunk-FZSFZOIN.js +200 -0
  23. package/dist/{chunk-ODFEUVPW.js → chunk-HACAGK65.js} +1 -18
  24. package/dist/chunk-HLKJZAFK.js +361 -0
  25. package/dist/chunk-IKEDLLFY.js +19 -0
  26. package/dist/{chunk-G6FLVEC4.js → chunk-JX6DDRXV.js} +46 -0
  27. package/dist/{chunk-AO2KJRDD.js → chunk-NZKVOSC2.js} +192 -3
  28. package/dist/chunk-QYFUNJOH.js +83 -0
  29. package/dist/chunk-SN2QBJUF.js +148 -0
  30. package/dist/chunk-TLDPEGC7.js +21 -0
  31. package/dist/chunk-TXSHHZF2.js +0 -0
  32. package/dist/chunk-VLIRNNAE.js +154 -0
  33. package/dist/{chunk-MTUBUPIV.js → chunk-Z57QDDJR.js} +8 -227
  34. package/dist/chunk-ZTJWBRUL.js +282 -0
  35. package/dist/cli.cjs +347 -18
  36. package/dist/cli.js +128 -23
  37. package/dist/client-D5U6ssPc.d.cts +84 -0
  38. package/dist/client-D5U6ssPc.d.ts +84 -0
  39. package/dist/index.cjs +810 -86
  40. package/dist/index.d.cts +19 -634
  41. package/dist/index.d.ts +19 -634
  42. package/dist/index.js +82 -699
  43. package/dist/manifest.cjs +200 -4
  44. package/dist/manifest.d.cts +260 -5
  45. package/dist/manifest.d.ts +260 -5
  46. package/dist/manifest.js +15 -1
  47. package/dist/mcp/client.cjs +180 -0
  48. package/dist/mcp/client.d.cts +49 -0
  49. package/dist/mcp/client.d.ts +49 -0
  50. package/dist/mcp/client.js +10 -0
  51. package/dist/mcp/server.cjs +370 -0
  52. package/dist/mcp/server.d.cts +127 -0
  53. package/dist/mcp/server.d.ts +127 -0
  54. package/dist/mcp/server.js +9 -0
  55. package/dist/mcp.cjs +376 -0
  56. package/dist/mcp.d.cts +2 -0
  57. package/dist/mcp.d.ts +2 -0
  58. package/dist/mcp.js +17 -0
  59. package/dist/model.cjs +141 -0
  60. package/dist/model.d.cts +33 -0
  61. package/dist/model.d.ts +33 -0
  62. package/dist/model.js +18 -0
  63. package/dist/plugin.cjs +46 -0
  64. package/dist/plugin.d.cts +215 -0
  65. package/dist/plugin.d.ts +215 -0
  66. package/dist/plugin.js +8 -0
  67. package/dist/runnable.cjs +1438 -0
  68. package/dist/runnable.d.cts +274 -0
  69. package/dist/runnable.d.ts +274 -0
  70. package/dist/runnable.js +34 -0
  71. package/dist/{index-B6SkaAjJ.d.ts → tool-AjkdFvhE.d.ts} +88 -461
  72. package/dist/{index-BvAB5eMk.d.cts → tool-CgzW92O_.d.cts} +88 -461
  73. package/package.json +48 -3
  74. package/src/agent/loop.test.ts +4 -4
  75. package/src/agent/tools.ts +3 -3
  76. package/src/agent-plugin.test.ts +58 -0
  77. package/src/agent-plugin.ts +109 -36
  78. package/src/builder.ts +9 -0
  79. package/src/cli/dev.test.ts +26 -47
  80. package/src/cli/dev.ts +10 -2
  81. package/src/cli-security.test.ts +109 -0
  82. package/src/cli.ts +141 -25
  83. package/src/contracts-lockstep.test.ts +16 -2
  84. package/src/exports-lockstep.test.ts +93 -0
  85. package/src/generated/plugin-manifest.ts +322 -29
  86. package/src/index.ts +53 -0
  87. package/src/manifest-schema.test.ts +30 -1
  88. package/src/manifest.fixtures.test.ts +22 -6
  89. package/src/manifest.test.ts +165 -10
  90. package/src/manifest.ts +454 -188
  91. package/src/mcp/index.ts +18 -0
  92. package/src/mcp/server.ts +2 -1
  93. package/src/model/index.ts +22 -0
  94. package/src/plugin/ryu-plugin.ts +82 -0
  95. package/src/runnable/action.test.ts +128 -0
  96. package/src/runnable/action.ts +202 -0
  97. package/src/runnable/app.test.ts +2 -0
  98. package/src/runnable/app.ts +50 -14
  99. package/src/runnable/index.ts +20 -3
  100. package/src/runnable/primitives.test.ts +34 -0
  101. package/src/runnable/primitives.ts +116 -0
  102. package/src/runnable/runnable-types.ts +3 -0
  103. package/src/runnable/tool.ts +36 -5
  104. package/src/runnable/turn-hook.ts +8 -3
  105. package/src/slash-command.test.ts +69 -0
package/src/cli.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * Usage:
6
6
  * bunx ryu pack <dir>
7
- * bunx ryu publish <dir>
7
+ * bunx ryu github-publish <repository-or-package-url>
8
+ * bunx ryu publish <dir> (legacy migration escape hatch)
8
9
  * bunx ryu agent-plugin <dir>
9
10
  *
10
11
  * Commands:
@@ -12,9 +13,10 @@
12
13
  * Plugin bundle at <dir>/dist/plugin.bundle.json, plus the
13
14
  * Agent Plugins interop pair in <dir> itself.
14
15
  * Exits 0 on success; exits 1 with the failing field on error.
15
- * publish <dir> Validate the manifest.json and POST it to the Ryu Marketplace
16
- * publish endpoint with the author's auth token. The item is
17
- * stored as `pending` until a moderator approves it.
16
+ * github-publish <url>
17
+ * Submit a seller-owned GitHub repository/package URL to the
18
+ * GitHub-backed marketplace bridge.
19
+ * publish <dir> Deprecated server-artifact publishing; disabled by default.
18
20
  * agent-plugin <dir>
19
21
  * Emit only the Agent Plugins v1 interop pair (plugin.json and,
20
22
  * when servers exist, mcp.json) derived from the manifest.json.
@@ -25,10 +27,12 @@ import {
25
27
  existsSync,
26
28
  mkdirSync,
27
29
  readFileSync,
30
+ realpathSync,
28
31
  rmSync,
29
32
  writeFileSync,
33
+ writeSync,
30
34
  } from "node:fs";
31
- import { join, resolve } from "node:path";
35
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
32
36
  import {
33
37
  AGENT_PLUGIN_MANIFEST_FILE,
34
38
  AGENT_PLUGIN_MCP_FILE,
@@ -56,7 +60,9 @@ function printUsage(): void {
56
60
  "",
57
61
  "Usage:",
58
62
  " bunx ryu pack <dir> Validate and bundle a manifest.json Plugin",
59
- " bunx ryu publish <dir> Validate and publish a manifest.json Plugin to the Ryu Marketplace",
63
+ " bunx ryu github-publish <url>",
64
+ " Submit a GitHub repository/package URL to the Marketplace",
65
+ " bunx ryu publish <dir> Deprecated server-artifact publishing (migration only)",
60
66
  " bunx ryu dev <entry> Run a Runnable locally with an interactive chat loop",
61
67
  " bunx ryu agent-plugin <dir>",
62
68
  " Emit the Agent Plugins v1 interop pair (plugin.json + mcp.json)",
@@ -66,7 +72,7 @@ function printUsage(): void {
66
72
  }
67
73
 
68
74
  function exitError(message: string): never {
69
- process.stderr.write(`error: ${message}\n`);
75
+ writeSync(process.stderr.fd, `error: ${message}\n`);
70
76
  process.exit(1);
71
77
  }
72
78
 
@@ -91,17 +97,21 @@ const MANIFEST_FILE_NAMES = [
91
97
  // `manifest.json` — and then fail validation for missing `id`. The `$schema`
92
98
  // discriminator separates them (see `isAgentPluginManifest`).
93
99
  function resolveNativeManifestPath(dir: string): string | undefined {
94
- return MANIFEST_FILE_NAMES.map((name) => join(dir, name)).find((candidate) => {
95
- if (!existsSync(candidate)) {
96
- return false;
97
- }
98
- try {
99
- return !isAgentPluginManifest(JSON.parse(readFileSync(candidate, "utf8")));
100
- } catch {
101
- // Unparseable: let the caller surface the JSON error against this path.
102
- return true;
100
+ return MANIFEST_FILE_NAMES.map((name) => join(dir, name)).find(
101
+ (candidate) => {
102
+ if (!existsSync(candidate)) {
103
+ return false;
104
+ }
105
+ try {
106
+ return !isAgentPluginManifest(
107
+ JSON.parse(readFileSync(candidate, "utf8"))
108
+ );
109
+ } catch {
110
+ // Unparseable: let the caller surface the JSON error against this path.
111
+ return true;
112
+ }
103
113
  }
104
- });
114
+ );
105
115
  }
106
116
 
107
117
  // Read + parse + validate the manifest in `dir`. Exits with the failing
@@ -140,6 +150,28 @@ function loadManifest(dir: string): LoadedManifest {
140
150
  const CODE_FILE_DIRS = ["hooks", "adapters"];
141
151
  const CODE_FILE_PATH = /^(hooks|adapters)\/[A-Za-z0-9_][A-Za-z0-9._-]*\.m?js$/;
142
152
 
153
+ function containedPackageFile(dir: string, rel: string, label: string): string {
154
+ let realRoot: string;
155
+ let realTarget: string;
156
+ try {
157
+ realRoot = realpathSync(dir);
158
+ realTarget = realpathSync(join(dir, rel));
159
+ } catch (error) {
160
+ exitError(`${label}: could not resolve '${rel}': ${String(error)}`);
161
+ }
162
+ const fromRoot = relative(realRoot, realTarget);
163
+ if (
164
+ fromRoot === ".." ||
165
+ fromRoot.startsWith(`..${sep}`) ||
166
+ isAbsolute(fromRoot)
167
+ ) {
168
+ exitError(
169
+ `${label}: '${rel}' resolves outside the plugin package (${realTarget})`
170
+ );
171
+ }
172
+ return realTarget;
173
+ }
174
+
143
175
  /**
144
176
  * Read one `code_file` and return its contents, or exit with a clear error.
145
177
  *
@@ -153,7 +185,7 @@ function readCodeFile(dir: string, rel: string, label: string): string {
153
185
  `${label}: code_file '${rel}' must be exactly '<${CODE_FILE_DIRS.join("|")}>/<name>.js' with no traversal`
154
186
  );
155
187
  }
156
- const path = join(dir, rel);
188
+ const path = containedPackageFile(dir, rel, label);
157
189
  let body: string;
158
190
  try {
159
191
  body = readFileSync(path, "utf8");
@@ -267,7 +299,7 @@ function inlineOutputStyleFiles(
267
299
  }
268
300
  let body: string;
269
301
  try {
270
- body = readFileSync(join(dir, rel), "utf8");
302
+ body = readFileSync(containedPackageFile(dir, rel, label), "utf8");
271
303
  } catch (err) {
272
304
  exitError(`${label}: could not read file '${rel}': ${String(err)}`);
273
305
  }
@@ -316,12 +348,12 @@ function resolveUiEntry(manifest: LoadedManifest): string | null {
316
348
  return null;
317
349
  }
318
350
 
319
- // Resolve a companion's `ui_format` discriminator. `"html"` (Path B) means the
320
- // `ui_entry` file is ALREADY a self-contained HTML document (a
321
- // vite-plugin-singlefile build for a heavy app like the whiteboard) and must be
322
- // shipped VERBATIM as `ui_code` — NOT run through `Bun.build`, which would try to
323
- // bundle an HTML file as an ESM entry and fail. Anything else (absent / `"js"`) is
324
- // the default: `ui_entry` is an ESM module `Bun.build` bundles into `ui_code`.
351
+ // Resolve a UI entry's format. `"html"` means the `ui_entry` file is ALREADY a
352
+ // self-contained HTML document (a vite-plugin-singlefile build for a heavy
353
+ // companion, or a hand-authored widget) and must be shipped VERBATIM as
354
+ // `ui_code` — NOT run through `Bun.build`, which would try to bundle an HTML
355
+ // file as an ESM entry and fail. Anything else (absent / `"js"`) is the default:
356
+ // `ui_entry` is an ESM module `Bun.build` bundles into `ui_code`.
325
357
  function resolveUiFormat(manifest: LoadedManifest): "html" | "js" {
326
358
  for (const runnable of manifest.runnables) {
327
359
  if (runnable.kind !== "companion") {
@@ -333,6 +365,15 @@ function resolveUiFormat(manifest: LoadedManifest): "html" | "js" {
333
365
  return "html";
334
366
  }
335
367
  }
368
+ for (const widget of manifest.contributes?.widgets ?? []) {
369
+ const entry = widget.ui_entry;
370
+ if (
371
+ typeof entry === "string" &&
372
+ entry.trim().toLowerCase().endsWith(".html")
373
+ ) {
374
+ return "html";
375
+ }
376
+ }
336
377
  return "js";
337
378
  }
338
379
 
@@ -572,8 +613,16 @@ async function commandPublish(rawDir: string): Promise<void> {
572
613
  ...(manifest.description ? { description: manifest.description } : {}),
573
614
  ...(manifest.tagline ? { tagline: manifest.tagline } : {}),
574
615
  ...(developer ? { developer } : {}),
616
+ ...(manifest.author ? { author: manifest.author } : {}),
617
+ ...(manifest.repository ? { repository: manifest.repository } : {}),
618
+ ...(manifest.external ? { external: true } : {}),
575
619
  ...(manifest.category ? { category: manifest.category } : {}),
620
+ ...(manifest.license ? { license: manifest.license } : {}),
621
+ ...(manifest.keywords?.length ? { keywords: manifest.keywords } : {}),
576
622
  ...(manifest.iconUrl ? { iconUrl: manifest.iconUrl } : {}),
623
+ ...(manifest.icon ? { icon: manifest.icon } : {}),
624
+ ...(manifest.iconDither ? { iconDither: manifest.iconDither } : {}),
625
+ ...(manifest.banner ? { banner: manifest.banner } : {}),
577
626
  ...(manifest.screenshots?.length
578
627
  ? { screenshots: manifest.screenshots }
579
628
  : {}),
@@ -648,6 +697,58 @@ async function commandPublish(rawDir: string): Promise<void> {
648
697
  );
649
698
  }
650
699
 
700
+ async function commandGithubPublish(repositoryUrl: string): Promise<void> {
701
+ const input = repositoryUrl.trim();
702
+ if (!input) {
703
+ exitError(
704
+ "github-publish requires a GitHub repository or package URL, for example https://github.com/acme/my-app"
705
+ );
706
+ }
707
+ const token = authToken();
708
+ const url = `${publishBaseUrl()}/api/marketplace/github/publish`;
709
+ const proof = process.env.RYU_GITHUB_INSTALLATION_PROOF?.trim();
710
+ const body = {
711
+ url: input,
712
+ ...(proof ? { installationProof: proof } : {}),
713
+ };
714
+ let response: Response;
715
+ try {
716
+ response = await fetch(url, {
717
+ method: "POST",
718
+ headers: {
719
+ authorization: `Bearer ${token}`,
720
+ "content-type": "application/json",
721
+ },
722
+ body: JSON.stringify(body),
723
+ });
724
+ } catch (error) {
725
+ exitError(`could not reach ${url}: ${String(error)}`);
726
+ }
727
+ const text = await response.text();
728
+ if (!response.ok) {
729
+ try {
730
+ const error = JSON.parse(text) as {
731
+ code?: string;
732
+ installationUrl?: string;
733
+ error?: string;
734
+ };
735
+ if (error.installationUrl) {
736
+ exitError(
737
+ `${error.error ?? "GitHub App installation required"}. Open ${error.installationUrl}, then set RYU_GITHUB_INSTALLATION_PROOF to the callback proof and retry.`
738
+ );
739
+ }
740
+ exitError(
741
+ `github-publish failed (${response.status}): ${error.error ?? text}`
742
+ );
743
+ } catch {
744
+ exitError(`github-publish failed (${response.status}): ${text}`);
745
+ }
746
+ }
747
+ process.stdout.write(
748
+ `GitHub-backed listing submitted for moderation:\n${text}\n`
749
+ );
750
+ }
751
+
651
752
  // ── main ──────────────────────────────────────────────────────────────────────
652
753
 
653
754
  const [, , command, ...args] = process.argv;
@@ -670,9 +771,24 @@ if (command === "pack") {
670
771
  if (!dir) {
671
772
  exitError("publish requires a directory argument: bunx ryu publish <dir>");
672
773
  }
774
+ if (process.env.RYU_ENABLE_LEGACY_MARKETPLACE_WRITES !== "true") {
775
+ exitError(
776
+ "ryu publish is deprecated because marketplace package contents now live in GitHub Releases; use bunx ryu github-publish <repository-or-package-url>"
777
+ );
778
+ }
673
779
  commandPublish(dir).catch((err: unknown) => {
674
780
  exitError(String(err));
675
781
  });
782
+ } else if (command === "github-publish") {
783
+ const repositoryUrl = args[0];
784
+ if (!repositoryUrl) {
785
+ exitError(
786
+ "github-publish requires a GitHub repository or package URL, for example https://github.com/acme/my-app"
787
+ );
788
+ }
789
+ commandGithubPublish(repositoryUrl).catch((err: unknown) => {
790
+ exitError(String(err));
791
+ });
676
792
  } else if (command === "agent-plugin") {
677
793
  const dir = args[0];
678
794
  if (!dir) {
@@ -9,7 +9,7 @@
9
9
  * Schema, and the generated types stay describing the same manifest:
10
10
  *
11
11
  * 1. a known-good repo manifest (apps-store/mail/manifest.json — the first
12
- * fully manifest-driven app) parses with the zod `PluginManifestSchema`;
12
+ * fully manifest-driven app) parses without losing any top-level wire field;
13
13
  * 2. every key the schema marks required exists in the schema, the generated
14
14
  * TS, and the fixture;
15
15
  * 3. every top-level key the fixture uses is a key the wire model knows.
@@ -42,12 +42,26 @@ const schema = JSON.parse(readFileSync(SCHEMA_PATH, "utf8")) as {
42
42
  properties: Record<string, unknown>;
43
43
  };
44
44
  const generatedSource = readFileSync(GENERATED_PATH, "utf8");
45
+ const CORE_FIELDS_PREVIOUSLY_STRIPPED = [
46
+ "permission_levels",
47
+ "sidecars",
48
+ "stability",
49
+ "surfaces",
50
+ ] as const;
45
51
 
46
52
  describe("contracts lockstep (zod ↔ blessed JSON Schema ↔ generated TS)", () => {
47
- test("the known-good mail manifest.json parses with the zod authoring schema", () => {
53
+ test("the known-good mail manifest survives the zod pack path without field loss", () => {
48
54
  const parsed = PluginManifestSchema.parse(fixture);
49
55
  expect(parsed.id).toBe("@ryu/mail");
50
56
  expect(parsed.runnables.length).toBeGreaterThan(0);
57
+ for (const key of Object.keys(fixture)) {
58
+ expect(Object.hasOwn(parsed, key), key).toBe(true);
59
+ }
60
+ for (const key of CORE_FIELDS_PREVIOUSLY_STRIPPED) {
61
+ expect(schema.properties).toHaveProperty(key);
62
+ expect(fixture).toHaveProperty(key);
63
+ expect(parsed[key]).toEqual(fixture[key]);
64
+ }
51
65
  });
52
66
 
53
67
  test("blessed schema describes PluginManifest with the required identity keys", () => {
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Package exports ↔ tsup entries lockstep guard.
3
+ *
4
+ * The SDK's public surface is split across `package.json` `exports` subpaths
5
+ * (`@ryuhq/sdk/model`, `@ryuhq/sdk/mcp/server`, …) and `tsup.config.ts`
6
+ * entrypoints that compile each one. They must stay a bijection: a subpath
7
+ * with no entry never builds, and an entry with no subpath is unreachable.
8
+ * This is the cheap structural guard that keeps the two from drifting again
9
+ * (a past drift left docs importing `@ryuhq/sdk/model` / `@ryuhq/sdk/mcp/server`
10
+ * that resolved to nothing).
11
+ *
12
+ * Deterministic, filesystem-only, no network.
13
+ */
14
+
15
+ import { describe, expect, test } from "bun:test";
16
+ import { readFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import tsupConfig from "../tsup.config.ts";
19
+
20
+ const PKG_PATH = join(import.meta.dir, "../package.json");
21
+ const pkg = JSON.parse(readFileSync(PKG_PATH, "utf8")) as {
22
+ exports: Record<
23
+ string,
24
+ { types?: string; import?: string; require?: string }
25
+ >;
26
+ };
27
+
28
+ // tsup's `defineConfig` type is the `Options | Options[] | fn` union, which the
29
+ // spread doesn't narrow; the entry map is the one shape this package uses.
30
+ const entryKeys = Object.keys(
31
+ (tsupConfig as { entry?: Record<string, string> }).entry ?? {}
32
+ );
33
+ const exportKeys = Object.keys(pkg.exports);
34
+
35
+ describe("package exports ↔ tsup entries lockstep", () => {
36
+ test("every tsup entry has a matching exports subpath", () => {
37
+ // `.` in the exports map corresponds to the `index` entry.
38
+ const expected = entryKeys.map((key) =>
39
+ key === "index" ? "." : `./${key}`
40
+ );
41
+ for (const key of expected) {
42
+ expect(exportKeys).toContain(key);
43
+ }
44
+ });
45
+
46
+ test("every exports subpath has a matching tsup entry", () => {
47
+ const subpaths = exportKeys
48
+ .filter((key) => key !== ".")
49
+ .map((key) => key.replace(/^\.\//, ""));
50
+ for (const key of subpaths) {
51
+ expect(entryKeys).toContain(key);
52
+ }
53
+ expect(entryKeys).toContain("index");
54
+ });
55
+
56
+ test("every subpath the docs/examples import resolves in the exports map", () => {
57
+ // Each of these is referenced by real consumers (fumadocs cookbooks,
58
+ // examples/, the desktop host, create-ryu-app) and must never 404.
59
+ const documented = [
60
+ "action",
61
+ "manifest",
62
+ "agent",
63
+ "model",
64
+ "mcp",
65
+ "mcp/server",
66
+ "mcp/client",
67
+ "plugin",
68
+ "runnable",
69
+ "builder",
70
+ ];
71
+ for (const sub of documented) {
72
+ expect(
73
+ pkg.exports[`./${sub}`],
74
+ `@ryuhq/sdk/${sub} must be exported`
75
+ ).toBeTruthy();
76
+ }
77
+ });
78
+
79
+ test("each subpath points at its own dist files (no collisions)", () => {
80
+ const distFiles = new Set<string>();
81
+ for (const entry of Object.values(pkg.exports)) {
82
+ for (const kind of ["types", "import", "require"] as const) {
83
+ const path = entry?.[kind];
84
+ if (path) {
85
+ expect(distFiles.has(path), `dist file collision: ${path}`).toBe(
86
+ false
87
+ );
88
+ distFiles.add(path);
89
+ }
90
+ }
91
+ }
92
+ });
93
+ });