@graphorin/cli 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +3 -3
  3. package/dist/bin/graphorin.js +51 -13
  4. package/dist/bin/graphorin.js.map +1 -1
  5. package/dist/commands/audit.d.ts.map +1 -1
  6. package/dist/commands/audit.js +2 -1
  7. package/dist/commands/audit.js.map +1 -1
  8. package/dist/commands/consolidator.d.ts +56 -1
  9. package/dist/commands/consolidator.d.ts.map +1 -1
  10. package/dist/commands/consolidator.js +88 -2
  11. package/dist/commands/consolidator.js.map +1 -1
  12. package/dist/commands/doctor.d.ts.map +1 -1
  13. package/dist/commands/index.d.ts +4 -4
  14. package/dist/commands/index.js +4 -4
  15. package/dist/commands/init.d.ts +11 -4
  16. package/dist/commands/init.d.ts.map +1 -1
  17. package/dist/commands/init.js +15 -11
  18. package/dist/commands/init.js.map +1 -1
  19. package/dist/commands/memory.d.ts +26 -1
  20. package/dist/commands/memory.d.ts.map +1 -1
  21. package/dist/commands/memory.js +56 -3
  22. package/dist/commands/memory.js.map +1 -1
  23. package/dist/commands/pricing.d.ts +6 -0
  24. package/dist/commands/pricing.d.ts.map +1 -1
  25. package/dist/commands/pricing.js +5 -2
  26. package/dist/commands/pricing.js.map +1 -1
  27. package/dist/commands/secrets.d.ts.map +1 -1
  28. package/dist/commands/secrets.js +2 -2
  29. package/dist/commands/secrets.js.map +1 -1
  30. package/dist/commands/skills.d.ts.map +1 -1
  31. package/dist/commands/skills.js +1 -1
  32. package/dist/commands/skills.js.map +1 -1
  33. package/dist/commands/storage.d.ts +41 -1
  34. package/dist/commands/storage.d.ts.map +1 -1
  35. package/dist/commands/storage.js +75 -1
  36. package/dist/commands/storage.js.map +1 -1
  37. package/dist/commands/token.d.ts.map +1 -1
  38. package/dist/commands/token.js +2 -2
  39. package/dist/commands/token.js.map +1 -1
  40. package/dist/commands/tools-lint.js +1 -1
  41. package/dist/commands/tools-lint.js.map +1 -1
  42. package/dist/commands/traces.d.ts +14 -2
  43. package/dist/commands/traces.d.ts.map +1 -1
  44. package/dist/commands/traces.js +39 -22
  45. package/dist/commands/traces.js.map +1 -1
  46. package/dist/commands/triggers.d.ts.map +1 -1
  47. package/dist/commands/triggers.js +5 -2
  48. package/dist/commands/triggers.js.map +1 -1
  49. package/dist/index.d.ts +4 -4
  50. package/dist/index.js +4 -5
  51. package/dist/index.js.map +1 -1
  52. package/dist/internal/output.js +6 -0
  53. package/dist/internal/output.js.map +1 -1
  54. package/dist/internal/store-context.js +13 -2
  55. package/dist/internal/store-context.js.map +1 -1
  56. package/dist/package.js +1 -1
  57. package/dist/package.js.map +1 -1
  58. package/package.json +18 -14
  59. package/src/bin/graphorin.ts +1387 -0
  60. package/src/commands/audit.ts +256 -0
  61. package/src/commands/auth.ts +238 -0
  62. package/src/commands/consolidator.ts +382 -0
  63. package/src/commands/doctor.ts +253 -0
  64. package/src/commands/guard.ts +144 -0
  65. package/src/commands/index.ts +223 -0
  66. package/src/commands/init.ts +194 -0
  67. package/src/commands/memory.ts +1052 -0
  68. package/src/commands/migrate-config.ts +77 -0
  69. package/src/commands/migrate-export.ts +117 -0
  70. package/src/commands/migrate.ts +83 -0
  71. package/src/commands/pricing.ts +244 -0
  72. package/src/commands/secrets.ts +309 -0
  73. package/src/commands/skills.ts +272 -0
  74. package/src/commands/start.ts +180 -0
  75. package/src/commands/storage.ts +659 -0
  76. package/src/commands/telemetry.ts +91 -0
  77. package/src/commands/token.ts +361 -0
  78. package/src/commands/tools-lint.ts +430 -0
  79. package/src/commands/traces.ts +188 -0
  80. package/src/commands/triggers.ts +237 -0
  81. package/src/index.ts +30 -0
  82. package/src/internal/exit.ts +62 -0
  83. package/src/internal/load-config.ts +107 -0
  84. package/src/internal/offline.ts +81 -0
  85. package/src/internal/output.ts +146 -0
  86. package/src/internal/prompts.ts +58 -0
  87. package/src/internal/store-context.ts +165 -0
@@ -0,0 +1,256 @@
1
+ /**
2
+ * `graphorin audit` - operate on the tamper-evident audit log
3
+ * (`audit.db`).
4
+ *
5
+ * Surface (per Phase 15 § Audit):
6
+ *
7
+ * - `graphorin audit verify` - full hash chain integrity check.
8
+ * - `graphorin audit prune --before <date>` - drop entries older than
9
+ * a cutoff while preserving the surviving suffix's chain integrity.
10
+ * - `graphorin audit export --to <file>` - stream every entry as JSONL.
11
+ *
12
+ * The CLI opens `audit.db` through the framework default binding
13
+ * registered by `@graphorin/server` (`ensureStoreAuditBinding()`); it
14
+ * never re-implements the cipher path.
15
+ *
16
+ * @packageDocumentation
17
+ */
18
+
19
+ import { writeFile } from 'node:fs/promises';
20
+ import { dirname, isAbsolute, resolve } from 'node:path';
21
+ import process from 'node:process';
22
+
23
+ import {
24
+ type AuditChainVerifyResult,
25
+ exportAudit,
26
+ openAuditDb,
27
+ type PruneAuditResult,
28
+ pruneAudit,
29
+ resolveSecret,
30
+ type SecretValue,
31
+ type StoredAuditEntry,
32
+ verifyAuditChain,
33
+ } from '@graphorin/security';
34
+ import { ensureStoreAuditBinding, parseServerConfig } from '@graphorin/server';
35
+
36
+ import { EXIT_CODES } from '../internal/exit.js';
37
+ import { loadConfig } from '../internal/load-config.js';
38
+ import {
39
+ brand,
40
+ type CommonOutputOptions,
41
+ defaultPrintSink,
42
+ emitReport,
43
+ statusMarker,
44
+ } from '../internal/output.js';
45
+
46
+ /** @stable */
47
+ export interface AuditCommonOptions extends CommonOutputOptions {
48
+ readonly config?: string;
49
+ }
50
+
51
+ /** @stable */
52
+ export interface AuditVerifyResult {
53
+ readonly ok: boolean;
54
+ readonly path: string;
55
+ readonly entries: number;
56
+ readonly broken?: { readonly seq: number; readonly expected: string; readonly actual: string };
57
+ }
58
+
59
+ /**
60
+ * `graphorin audit verify` - replay the chain and report the first
61
+ * broken link (if any).
62
+ *
63
+ * @stable
64
+ */
65
+ export async function runAuditVerify(options: AuditCommonOptions = {}): Promise<AuditVerifyResult> {
66
+ const ctx = await openAuditContext(options);
67
+ try {
68
+ const result: AuditChainVerifyResult = await verifyAuditChain(ctx.auditDb);
69
+ const out: AuditVerifyResult = result.ok
70
+ ? Object.freeze({ ok: true, path: ctx.auditDb.path, entries: result.count })
71
+ : Object.freeze({
72
+ ok: false,
73
+ path: ctx.auditDb.path,
74
+ entries: 0,
75
+ broken: Object.freeze({
76
+ seq: result.brokenAt,
77
+ expected: result.expected,
78
+ actual: result.actual,
79
+ }),
80
+ });
81
+ emitReport(options, out, () => {
82
+ const print = options.print ?? defaultPrintSink;
83
+ if (out.ok) {
84
+ print(
85
+ brand(`audit chain ${statusMarker('ok')} (${out.entries} entries verified, ${out.path})`),
86
+ );
87
+ return;
88
+ }
89
+ print(brand(`audit chain ${statusMarker('fail')} broken at seq ${out.broken?.seq}`));
90
+ print(` expected prevHash=${out.broken?.expected}`);
91
+ print(` actual prevHash=${out.broken?.actual}`);
92
+ });
93
+ // W-002: the exit code is part of the machine contract - it must be
94
+ // set OUTSIDE the human() callback (emitReport skips that callback
95
+ // entirely under --json, so a broken chain exited 0 for exactly the
96
+ // CI consumers the flag exists for).
97
+ if (!out.ok) process.exitCode = EXIT_CODES.RECOVERABLE_FAILURE;
98
+ return out;
99
+ } finally {
100
+ await ctx.close();
101
+ }
102
+ }
103
+
104
+ /** @stable */
105
+ export interface AuditPruneOptions extends AuditCommonOptions {
106
+ /**
107
+ * ISO-8601 date / `YYYY-MM-DD` / millis-since-epoch. The helper
108
+ * drops entries older than this cutoff.
109
+ */
110
+ readonly before: string;
111
+ /** Minimum number of entries that must survive. Default `1`. */
112
+ readonly retain?: number;
113
+ }
114
+
115
+ /** @stable */
116
+ export async function runAuditPrune(options: AuditPruneOptions): Promise<PruneAuditResult> {
117
+ const cutoff = parseCutoff(options.before);
118
+ const ctx = await openAuditContext(options);
119
+ try {
120
+ const result = await pruneAudit(ctx.auditDb, {
121
+ before: cutoff,
122
+ ...(options.retain !== undefined ? { retain: options.retain } : {}),
123
+ });
124
+ emitReport(options, result, () => {
125
+ const print = options.print ?? defaultPrintSink;
126
+ if (result.deleted === 0) {
127
+ print(
128
+ brand(`no audit entries qualified for prune (cutoff=${new Date(cutoff).toISOString()}).`),
129
+ );
130
+ return;
131
+ }
132
+ print(
133
+ brand(
134
+ `pruned ${result.deleted} audit entries (cutoff=${new Date(cutoff).toISOString()}, surviving from seq=${result.firstSurvivingSeq ?? '<empty>'})`,
135
+ ),
136
+ );
137
+ // W-062: the prune re-rooted the chain - every Merkle checkpoint
138
+ // signed BEFORE it now fails verification by design.
139
+ print(
140
+ brand(
141
+ 'note: Merkle checkpoints signed before this prune no longer verify (the chain was re-rooted). ' +
142
+ 'Sign and distribute a fresh checkpoint now (signAuditCheckpoint) and mark the old anchors superseded - ' +
143
+ 'see the "Retention and anchoring" runbook in the security guide.',
144
+ ),
145
+ );
146
+ });
147
+ return result;
148
+ } finally {
149
+ await ctx.close();
150
+ }
151
+ }
152
+
153
+ /** @stable */
154
+ export interface AuditExportOptions extends AuditCommonOptions {
155
+ readonly to: string;
156
+ readonly fromSeq?: number;
157
+ readonly toSeq?: number;
158
+ }
159
+
160
+ /** @stable */
161
+ export interface AuditExportResult {
162
+ readonly path: string;
163
+ readonly rows: number;
164
+ }
165
+
166
+ /**
167
+ * `graphorin audit export --to <file>` - stream every entry as JSONL.
168
+ *
169
+ * @stable
170
+ */
171
+ export async function runAuditExport(options: AuditExportOptions): Promise<AuditExportResult> {
172
+ const cwd = process.cwd();
173
+ const targetPath = isAbsolute(options.to) ? options.to : resolve(cwd, options.to);
174
+ const ctx = await openAuditContext(options);
175
+ try {
176
+ const lines: string[] = [];
177
+ const { rows } = await exportAudit(ctx.auditDb, {
178
+ writer: { write: (line: string) => void lines.push(line) },
179
+ ...(options.fromSeq !== undefined ? { fromSeq: options.fromSeq } : {}),
180
+ ...(options.toSeq !== undefined ? { toSeq: options.toSeq } : {}),
181
+ });
182
+ await writeFile(targetPath, lines.join(''), { mode: 0o600 });
183
+ const out: AuditExportResult = Object.freeze({ path: targetPath, rows });
184
+ emitReport(options, out, () => {
185
+ const print = options.print ?? defaultPrintSink;
186
+ print(brand(`exported ${rows} audit entries to ${targetPath} (mode 0600).`));
187
+ });
188
+ return out;
189
+ } finally {
190
+ await ctx.close();
191
+ }
192
+ }
193
+
194
+ interface AuditContext {
195
+ readonly auditDb: Awaited<ReturnType<typeof openAuditDb>>;
196
+ readonly path: string;
197
+ readonly close: () => Promise<void>;
198
+ }
199
+
200
+ async function openAuditContext(options: AuditCommonOptions): Promise<AuditContext> {
201
+ const loaded = await loadConfig(options.config);
202
+ const config = parseServerConfig(loaded.config);
203
+ if (!config.audit.enabled) {
204
+ throw new Error(
205
+ '[graphorin/cli] this command requires audit.enabled = true in the resolved config.',
206
+ );
207
+ }
208
+ const passphraseRef = config.audit.passphraseRef ?? config.storage.encryption.passphraseRef;
209
+ if (passphraseRef === undefined) {
210
+ throw new Error(
211
+ `[graphorin/cli] audit.enabled is true but no audit.passphraseRef (or storage.encryption.passphraseRef) is configured. audit.db is mandatory-encrypted by DEC-124.`,
212
+ );
213
+ }
214
+ let passphrase: SecretValue;
215
+ try {
216
+ passphrase = await resolveSecret(passphraseRef);
217
+ } catch (err) {
218
+ throw new Error(
219
+ `[graphorin/cli] failed to resolve audit passphrase '${passphraseRef}': ${(err as Error).message}`,
220
+ { cause: err },
221
+ );
222
+ }
223
+ const auditPath = config.audit.path ?? deriveAuditPath(config.storage.path);
224
+ ensureStoreAuditBinding();
225
+ const auditDb = await openAuditDb({
226
+ path: auditPath,
227
+ passphrase,
228
+ ...(config.audit.cipher !== undefined ? { cipher: config.audit.cipher } : {}),
229
+ });
230
+ return Object.freeze({
231
+ auditDb,
232
+ path: auditPath,
233
+ close: () => auditDb.close(),
234
+ });
235
+ }
236
+
237
+ function deriveAuditPath(storagePath: string): string {
238
+ const dir = dirname(storagePath);
239
+ return resolve(dir, 'audit.db');
240
+ }
241
+
242
+ function parseCutoff(input: string): number {
243
+ const numeric = Number(input);
244
+ if (Number.isFinite(numeric) && numeric > 0) return numeric;
245
+ const ms = Date.parse(input);
246
+ if (!Number.isFinite(ms)) {
247
+ throw new Error(
248
+ `[graphorin/cli] --before '${input}' is not a valid ISO date or epoch-ms value.`,
249
+ );
250
+ }
251
+ return ms;
252
+ }
253
+
254
+ // Internal type-only consumers - keep in scope so tree-shaking
255
+ // preserves the entry point.
256
+ void ((_e?: StoredAuditEntry) => undefined);
@@ -0,0 +1,238 @@
1
+ /**
2
+ * `graphorin auth` - outbound OAuth subsystem (e.g. for MCP servers).
3
+ *
4
+ * Surface (per Phase 15 § Auth):
5
+ *
6
+ * - `graphorin auth login --server <url> [--device-flow]`
7
+ * - `graphorin auth list`
8
+ * - `graphorin auth refresh <id>`
9
+ * - `graphorin auth revoke <id>`
10
+ * - `graphorin auth status`
11
+ *
12
+ * Honours `GRAPHORIN_OFFLINE=1` - when set, every subcommand short-
13
+ * circuits with an explanatory message and exits `1` so CI pipelines
14
+ * see the failure.
15
+ *
16
+ * @packageDocumentation
17
+ */
18
+
19
+ import {
20
+ getOAuthStatus,
21
+ type LoginInteractiveResult,
22
+ listOAuthSessions,
23
+ loginInteractive,
24
+ refreshOAuthSession,
25
+ revokeOAuthSession,
26
+ } from '@graphorin/security';
27
+ import { createSecretsStore, getActiveSecretsStore } from '@graphorin/security/secrets';
28
+ import { EXIT_CODES } from '../internal/exit.js';
29
+ import { checkOfflineModeBlocked } from '../internal/offline.js';
30
+ import {
31
+ brand,
32
+ type CommonOutputOptions,
33
+ defaultPrintSink,
34
+ emitReport,
35
+ statusMarker,
36
+ } from '../internal/output.js';
37
+ import { openStoreContext } from '../internal/store-context.js';
38
+
39
+ /**
40
+ * Resolve the CLI's secrets store for OAuth token persistence (SPL-1):
41
+ * the already-active store when one exists, else the default auto
42
+ * chain (keyring → encrypted-file → env). Best-effort - a store
43
+ * failure degrades to the in-memory-only pre-SPL-1 behavior instead of
44
+ * blocking the auth command.
45
+ */
46
+ async function resolveAuthSecretsStore(): Promise<
47
+ import('@graphorin/core/contracts').SecretsStore | undefined
48
+ > {
49
+ try {
50
+ return getActiveSecretsStore() ?? (await createSecretsStore({}));
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ /** @stable */
57
+ export interface AuthCommonOptions extends CommonOutputOptions {
58
+ readonly config?: string;
59
+ }
60
+
61
+ /** @stable */
62
+ export interface AuthLoginOptions extends AuthCommonOptions {
63
+ readonly serverUrl: string;
64
+ readonly serverId?: string;
65
+ readonly scope?: string;
66
+ readonly deviceFlow?: boolean;
67
+ /**
68
+ * Optional pre-existing client identifier - skips Dynamic Client
69
+ * Registration when supplied.
70
+ */
71
+ readonly clientId?: string;
72
+ }
73
+
74
+ /** @stable */
75
+ export async function runAuthLogin(options: AuthLoginOptions): Promise<LoginInteractiveResult> {
76
+ if (
77
+ !checkOfflineModeBlocked('auth login', {
78
+ ...(options.print !== undefined ? { print: options.print } : {}),
79
+ })
80
+ ) {
81
+ process.exit(EXIT_CODES.RECOVERABLE_FAILURE);
82
+ }
83
+ const ctx = await openStoreContext({
84
+ ...(options.config !== undefined ? { config: options.config } : {}),
85
+ });
86
+ try {
87
+ const serverId = options.serverId ?? deriveServerId(options.serverUrl);
88
+ const secretsStore = await resolveAuthSecretsStore();
89
+ const result = await loginInteractive({
90
+ serverId,
91
+ serverUrl: options.serverUrl,
92
+ storage: ctx.store.oauthServers,
93
+ ...(secretsStore === undefined ? {} : { secretsStore }),
94
+ ...(options.deviceFlow !== undefined ? { deviceFlow: options.deviceFlow } : {}),
95
+ ...(options.scope !== undefined ? { scope: options.scope } : {}),
96
+ ...(options.clientId !== undefined ? { clientId: options.clientId } : {}),
97
+ });
98
+ emitReport(options, result.status, () => {
99
+ const print = options.print ?? defaultPrintSink;
100
+ print(brand(`oauth login ${statusMarker('ok')} (server=${result.status.serverId})`));
101
+ print(` status: ${result.status.status}`);
102
+ print(` expiresAt: ${result.status.expiresAt ?? '-'}`);
103
+ });
104
+ return result;
105
+ } finally {
106
+ await ctx.close();
107
+ }
108
+ }
109
+
110
+ /** @stable */
111
+ export interface AuthListOptions extends AuthCommonOptions {}
112
+
113
+ /** @stable */
114
+ export async function runAuthList(options: AuthListOptions = {}) {
115
+ const ctx = await openStoreContext({
116
+ ...(options.config !== undefined ? { config: options.config } : {}),
117
+ });
118
+ try {
119
+ const secretsStore = await resolveAuthSecretsStore();
120
+ const list = await listOAuthSessions(ctx.store.oauthServers, {
121
+ ...(secretsStore === undefined ? {} : { secretsStore }),
122
+ });
123
+ emitReport(options, list, () => {
124
+ const print = options.print ?? defaultPrintSink;
125
+ if (list.length === 0) {
126
+ print(brand('no OAuth sessions persisted.'));
127
+ return;
128
+ }
129
+ print(brand(`${list.length} OAuth session(s):`));
130
+ for (const s of list) {
131
+ const mark = s.status === 'expired' ? statusMarker('warn') : statusMarker('ok');
132
+ print(` ${mark} ${s.serverId} (status=${s.status}, scope=${s.scope ?? '-'})`);
133
+ }
134
+ });
135
+ return list;
136
+ } finally {
137
+ await ctx.close();
138
+ }
139
+ }
140
+
141
+ /** @stable */
142
+ export interface AuthRefreshOptions extends AuthCommonOptions {
143
+ readonly id: string;
144
+ }
145
+
146
+ /** @stable */
147
+ export async function runAuthRefresh(options: AuthRefreshOptions) {
148
+ if (
149
+ !checkOfflineModeBlocked('auth refresh', {
150
+ ...(options.print !== undefined ? { print: options.print } : {}),
151
+ })
152
+ ) {
153
+ process.exit(EXIT_CODES.RECOVERABLE_FAILURE);
154
+ }
155
+ const ctx = await openStoreContext({
156
+ ...(options.config !== undefined ? { config: options.config } : {}),
157
+ });
158
+ try {
159
+ const secretsStore = await resolveAuthSecretsStore();
160
+ const session = await refreshOAuthSession(ctx.store.oauthServers, options.id, {
161
+ ...(secretsStore === undefined ? {} : { secretsStore }),
162
+ });
163
+ emitReport(options, { ok: true, id: options.id }, () => {
164
+ const print = options.print ?? defaultPrintSink;
165
+ print(
166
+ brand(
167
+ `oauth session '${options.id}' refreshed (expiresAt=${new Date(session.expiresAt ?? Date.now()).toISOString()})`,
168
+ ),
169
+ );
170
+ });
171
+ return session;
172
+ } finally {
173
+ await ctx.close();
174
+ }
175
+ }
176
+
177
+ /** @stable */
178
+ export interface AuthRevokeOptions extends AuthCommonOptions {
179
+ readonly id: string;
180
+ readonly reason?: string;
181
+ }
182
+
183
+ /** @stable */
184
+ export async function runAuthRevoke(options: AuthRevokeOptions): Promise<{ readonly ok: true }> {
185
+ const ctx = await openStoreContext({
186
+ ...(options.config !== undefined ? { config: options.config } : {}),
187
+ });
188
+ try {
189
+ const secretsStore = await resolveAuthSecretsStore();
190
+ await revokeOAuthSession(ctx.store.oauthServers, options.id, {
191
+ ...(secretsStore === undefined ? {} : { secretsStore }),
192
+ ...(options.reason !== undefined ? { reason: options.reason } : {}),
193
+ });
194
+ emitReport(options, { ok: true } as const, () => {
195
+ const print = options.print ?? defaultPrintSink;
196
+ print(brand(`oauth session '${options.id}' revoked.`));
197
+ });
198
+ return { ok: true };
199
+ } finally {
200
+ await ctx.close();
201
+ }
202
+ }
203
+
204
+ /** @stable */
205
+ export async function runAuthStatus(options: AuthCommonOptions = {}) {
206
+ const ctx = await openStoreContext({
207
+ ...(options.config !== undefined ? { config: options.config } : {}),
208
+ });
209
+ try {
210
+ const secretsStore = await resolveAuthSecretsStore();
211
+ const status = await getOAuthStatus(ctx.store.oauthServers, {
212
+ ...(secretsStore === undefined ? {} : { secretsStore }),
213
+ });
214
+ emitReport(options, status, () => {
215
+ const print = options.print ?? defaultPrintSink;
216
+ print(
217
+ brand(
218
+ `oauth subsystem status: ${status.sessions.length} session(s), ${status.providers.length} strategy(ies)`,
219
+ ),
220
+ );
221
+ if (status.defaultStrategy !== null) {
222
+ print(brand(`default strategy: ${status.defaultStrategy.id}`));
223
+ }
224
+ });
225
+ return status;
226
+ } finally {
227
+ await ctx.close();
228
+ }
229
+ }
230
+
231
+ function deriveServerId(serverUrl: string): string {
232
+ try {
233
+ const u = new URL(serverUrl);
234
+ return u.host;
235
+ } catch {
236
+ return serverUrl;
237
+ }
238
+ }