@vincentt-xr/cli 0.15.2 → 0.17.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 (83) hide show
  1. package/dist/cli/args.js +1 -0
  2. package/dist/cli/index.js +198 -3
  3. package/dist/cli/usage-text.js +27 -0
  4. package/dist/cli/verb-surface.d.ts +3 -1
  5. package/dist/cli/verb-surface.js +12 -0
  6. package/dist/domain/api.d.ts +51 -0
  7. package/dist/domain/api.js +145 -0
  8. package/dist/domain/command.d.ts +75 -0
  9. package/dist/domain/command.js +467 -0
  10. package/dist/domain/copy.d.ts +334 -0
  11. package/dist/domain/copy.js +952 -0
  12. package/dist/domain/hostname.d.ts +57 -0
  13. package/dist/domain/hostname.js +116 -0
  14. package/dist/domain/names.d.ts +78 -0
  15. package/dist/domain/names.js +113 -0
  16. package/dist/domain/routedVerbs.d.ts +10 -0
  17. package/dist/domain/routedVerbs.js +30 -0
  18. package/dist/domain/wire.d.ts +140 -0
  19. package/dist/domain/wire.js +174 -0
  20. package/dist/dx/command.d.ts +13 -4
  21. package/dist/dx/command.js +26 -5
  22. package/dist/dx/report.d.ts +39 -7
  23. package/dist/dx/report.js +71 -20
  24. package/dist/dx/send.d.ts +22 -4
  25. package/dist/dx/send.js +29 -8
  26. package/dist/dx/skeleton.js +19 -0
  27. package/dist/dx/unbiddenVerbs.d.ts +25 -0
  28. package/dist/dx/unbiddenVerbs.js +39 -0
  29. package/dist/lib/initCommand.d.ts +3 -1
  30. package/dist/lib/initCommand.js +10 -0
  31. package/dist/lib/projects.d.ts +8 -1
  32. package/dist/lib/projects.js +12 -2
  33. package/dist/platform/allowlist.d.ts +36 -0
  34. package/dist/platform/allowlist.js +34 -0
  35. package/dist/platform/behind.d.ts +41 -0
  36. package/dist/platform/behind.js +82 -0
  37. package/dist/platform/command.d.ts +16 -0
  38. package/dist/platform/command.js +90 -0
  39. package/dist/platform/footer.d.ts +60 -0
  40. package/dist/platform/footer.js +101 -0
  41. package/dist/platform/notice.d.ts +29 -0
  42. package/dist/platform/notice.js +66 -0
  43. package/dist/platform/offSwitch.d.ts +19 -0
  44. package/dist/platform/offSwitch.js +21 -0
  45. package/dist/platform/outdated.d.ts +25 -0
  46. package/dist/platform/outdated.js +246 -0
  47. package/dist/platform/releaseCache.d.ts +69 -0
  48. package/dist/platform/releaseCache.js +140 -0
  49. package/dist/platform/releaseFetch.d.ts +51 -0
  50. package/dist/platform/releaseFetch.js +150 -0
  51. package/dist/platform/resolve.d.ts +30 -0
  52. package/dist/platform/resolve.js +128 -0
  53. package/dist/platform/templateUpgrade.d.ts +56 -0
  54. package/dist/platform/templateUpgrade.js +476 -0
  55. package/dist/platform/upgradeReport.d.ts +132 -0
  56. package/dist/platform/upgradeReport.js +258 -0
  57. package/dist/platform/versionSummary.d.ts +82 -0
  58. package/dist/platform/versionSummary.js +181 -0
  59. package/dist/platform/zonePath.d.ts +154 -0
  60. package/dist/platform/zonePath.js +286 -0
  61. package/dist/platform/zones.d.ts +64 -0
  62. package/dist/platform/zones.js +79 -0
  63. package/dist/preview/api.d.ts +19 -0
  64. package/dist/preview/api.js +29 -0
  65. package/dist/publish/api.d.ts +32 -5
  66. package/dist/publish/api.js +19 -5
  67. package/dist/publish/command.d.ts +10 -0
  68. package/dist/publish/command.js +30 -2
  69. package/dist/publish/copy.d.ts +14 -1
  70. package/dist/publish/copy.js +32 -3
  71. package/dist/scaffold/index.d.ts +50 -3
  72. package/dist/scaffold/index.js +61 -1
  73. package/dist/shared/config.d.ts +16 -0
  74. package/dist/shared/config.js +9 -1
  75. package/dist/shared/countLimit.d.ts +109 -0
  76. package/dist/shared/countLimit.js +169 -0
  77. package/package.json +1 -1
  78. package/dist/dx/qa.f7.fixtures.d.ts +0 -73
  79. package/dist/dx/qa.f7.fixtures.js +0 -58
  80. package/dist/publish/qa.f4.fixtures.d.ts +0 -25
  81. package/dist/publish/qa.f4.fixtures.js +0 -140
  82. package/dist/relay/qa.f12.fixtures.d.ts +0 -154
  83. package/dist/relay/qa.f12.fixtures.js +0 -358
@@ -0,0 +1,467 @@
1
+ // `vincentt domain <attach|list|verify|detach>` — Flows C1-C7.
2
+ //
3
+ // The IO boundary (print, config, binding, org, the four API calls, confirm) is
4
+ // injected, so every branch is testable with no API, no HOME and no network.
5
+ //
6
+ // THE VERB SET, and the aliases:
7
+ // attach (alias: add) — claim a name and print the three records
8
+ // list — this workspace's domains, and the count
9
+ // verify — re-check now, and diagnose what is observed
10
+ // detach (alias: remove) — stop serving the name
11
+ //
12
+ // THE ALIASES ARE SILENT. `add` produces `attach`'s transcript and `remove`
13
+ // produces `detach`'s, WITH NOTHING PRINTED ABOUT THE ALIAS — no "did you mean",
14
+ // no deprecation notice, and no echo of the canonical name (D-45, CLI convention
15
+ // 6). f2's *a warning about a decision already taken reports it; it does not ask*
16
+ // has a quieter cousin here: THERE IS NO DECISION TO REPORT. The creator got what
17
+ // they wanted, and a correction on a working command is the product being
18
+ // pedantic about its own vocabulary at the one moment the creator is not
19
+ // interested in it. Help, docs and every error string say `attach` / `detach`,
20
+ // which is where the vocabulary is actually taught.
21
+ import { EXIT } from "../lib/exitCodes.js";
22
+ import { VALIDATION_RECORD_GONE } from "./wire.js";
23
+ import { DomainApiError } from "./api.js";
24
+ import { localRefusal, normalizeArgument } from "./hostname.js";
25
+ import * as copy from "./copy.js";
26
+ /** The canonical subcommands. */
27
+ export const DOMAIN_SUBCOMMANDS = ["attach", "list", "verify", "detach"];
28
+ /**
29
+ * The silent aliases. `add`→`attach`, `remove`→`detach`.
30
+ *
31
+ * A map, not a chain of `if`s, so the pair is data a test can enumerate and the
32
+ * set cannot drift from what `resolveSub` accepts.
33
+ */
34
+ export const DOMAIN_ALIASES = {
35
+ add: "attach",
36
+ remove: "detach",
37
+ };
38
+ /**
39
+ * Resolve a typed subcommand to its canonical verb, or undefined.
40
+ *
41
+ * RETURNS ONLY THE CANONICAL VERB. It deliberately does not report whether an
42
+ * alias was used: a caller that cannot tell cannot print a correction, which is
43
+ * the structural form of "the aliases are silent". Making the alias invisible at
44
+ * the type level is stronger than remembering not to print it.
45
+ */
46
+ export function resolveSub(typed) {
47
+ if (typed === undefined)
48
+ return undefined;
49
+ if (DOMAIN_SUBCOMMANDS.includes(typed)) {
50
+ return typed;
51
+ }
52
+ return DOMAIN_ALIASES[typed];
53
+ }
54
+ const NOT_SIGNED_IN = [
55
+ "",
56
+ "✗ You're not signed in.",
57
+ "",
58
+ " Attaching a domain needs an account.",
59
+ "",
60
+ " Sign in: vincentt login",
61
+ "",
62
+ ];
63
+ const NO_PROJECT = [
64
+ "",
65
+ "✗ This folder isn't linked to a project.",
66
+ "",
67
+ " A custom domain attaches to one published project.",
68
+ "",
69
+ " Link it: vincentt link <project-id>",
70
+ " Or list your projects: vincentt projects",
71
+ "",
72
+ ];
73
+ /**
74
+ * The dispatch. Returns the process exit code.
75
+ *
76
+ * Every arm returns explicitly rather than falling through to a shared tail: an
77
+ * exit code assembled at the end of a long function is an exit code a later
78
+ * branch changes by accident, and the exit code is the thing an agent branches
79
+ * on.
80
+ */
81
+ export async function domainCommand(deps, sub, opts = {}) {
82
+ const cfg = await deps.config();
83
+ if (!cfg) {
84
+ // A platform call needed an account and the command did not answer for it.
85
+ // The NAMED constant, never the literal — B-F12-1 is enforceable only when
86
+ // the code is a symbol a reader can follow to its meaning.
87
+ for (const l of NOT_SIGNED_IN)
88
+ deps.print(l);
89
+ return EXIT.NOT_AUTHENTICATED;
90
+ }
91
+ switch (sub) {
92
+ case "attach":
93
+ return attachVerb(deps, cfg, opts);
94
+ case "list":
95
+ return listVerb(deps, cfg, opts);
96
+ case "verify":
97
+ return verifyVerb(deps, cfg, opts);
98
+ case "detach":
99
+ return detachVerb(deps, cfg, opts);
100
+ }
101
+ }
102
+ /** The hostname argument is required on three of the four verbs. */
103
+ function requireHostname(deps, opts, verb) {
104
+ const raw = opts.hostname;
105
+ if (!raw) {
106
+ deps.print("");
107
+ deps.print(`✗ Which domain? \`vincentt domain ${verb} <domain>\``);
108
+ deps.print("");
109
+ return undefined;
110
+ }
111
+ return normalizeArgument(raw);
112
+ }
113
+ /**
114
+ * Flow C1 — attach.
115
+ *
116
+ * NO PROMPT anywhere: attaching asks nothing irreversible.
117
+ *
118
+ * The two local refusals fire with NO NETWORK CALL, because their copy is a fact
119
+ * about the creator's own registrar rather than about our state. Everything else
120
+ * is the server's judgement.
121
+ */
122
+ async function attachVerb(deps, cfg, opts) {
123
+ const hostname = requireHostname(deps, opts, "attach");
124
+ if (hostname === undefined)
125
+ return 1;
126
+ const refusal = localRefusal(hostname);
127
+ if (refusal === "platform_host") {
128
+ for (const l of copy.refusedPlatformHost())
129
+ deps.print(l);
130
+ return 1;
131
+ }
132
+ if (refusal === "root_domain") {
133
+ for (const l of copy.refusedRootDomain(hostname))
134
+ deps.print(l);
135
+ return 1;
136
+ }
137
+ if (refusal === "empty") {
138
+ for (const l of copy.refusedRootDomain(""))
139
+ deps.print(l);
140
+ return 1;
141
+ }
142
+ const binding = await deps.binding();
143
+ if (!binding) {
144
+ for (const l of NO_PROJECT)
145
+ deps.print(l);
146
+ return 1;
147
+ }
148
+ let result;
149
+ try {
150
+ result = await deps.attach(cfg, binding.projectId, hostname);
151
+ }
152
+ catch (err) {
153
+ return printAttachError(deps, err, hostname);
154
+ }
155
+ if (opts.json) {
156
+ deps.print(JSON.stringify({ domain: result.domain }, null, 2));
157
+ return 0;
158
+ }
159
+ // EVERY rendered hostname comes from `result.domain.domain` — the normalized
160
+ // form the API returned — never from `hostname`, the value we sent. A client
161
+ // sending `AR.Acme.COM.` gets `ar.acme.com` back and MUST display what it got.
162
+ for (const l of copy.attachSuccess({
163
+ domain: result.domain.domain,
164
+ records: result.domain.records,
165
+ roles: result.roles,
166
+ permanentAddress: result.domain.permanentAddress,
167
+ projectName: result.domain.projectName,
168
+ })) {
169
+ deps.print(l);
170
+ }
171
+ return 0;
172
+ }
173
+ function printAttachError(deps, err, hostname) {
174
+ if (!(err instanceof DomainApiError)) {
175
+ deps.print("");
176
+ deps.print(`✗ Couldn't reach Vincentt to attach ${hostname}.`);
177
+ deps.print("");
178
+ deps.print(copy.NOTHING_WAS_CLAIMED);
179
+ deps.print("");
180
+ return 1;
181
+ }
182
+ // The refusals, each by the server's own code. The ceiling figure and the plan
183
+ // name come from the API — the CLI holds no plan ladder.
184
+ switch (err.code) {
185
+ case "domain_limit_reached":
186
+ for (const l of copy.refusedAtCeiling(err.detail.limit ?? 0))
187
+ deps.print(l);
188
+ return 1;
189
+ case "domain_taken":
190
+ // BYTE-IDENTICAL whether the holder is this workspace or a stranger. The
191
+ // CLI passes no holder information because the API sends none.
192
+ for (const l of copy.refusedAlreadyAttached(hostname))
193
+ deps.print(l);
194
+ return 1;
195
+ case "platform_hostname":
196
+ for (const l of copy.refusedPlatformHost())
197
+ deps.print(l);
198
+ return 1;
199
+ case "root_domain_unsupported":
200
+ for (const l of copy.refusedRootDomain(hostname))
201
+ deps.print(l);
202
+ return 1;
203
+ case "no_entitlement":
204
+ for (const l of copy.refusedNoEntitlement(err.detail.planName))
205
+ deps.print(l);
206
+ return 1;
207
+ default:
208
+ break;
209
+ }
210
+ if (err.status === 429) {
211
+ for (const l of copy.refusedTooSoon(err.retryAfterSec))
212
+ deps.print(l);
213
+ return 1;
214
+ }
215
+ deps.print("");
216
+ deps.print(`✗ ${err.message}`);
217
+ deps.print("");
218
+ deps.print(copy.NOTHING_WAS_CLAIMED);
219
+ deps.print("");
220
+ return 1;
221
+ }
222
+ /**
223
+ * Flow L43 — list. A listing verb REPORTS; it never asks and never picks.
224
+ *
225
+ * Reads the ORG route, not the project one: the count is against the workspace's
226
+ * entitlement and a creator checking whether they hold a name needs every one.
227
+ */
228
+ async function listVerb(deps, cfg, opts) {
229
+ const org = await deps.org();
230
+ if (!org.ok) {
231
+ deps.print(org.message);
232
+ return 1;
233
+ }
234
+ let view;
235
+ try {
236
+ view = await deps.listOrg(cfg, org.org.slug);
237
+ }
238
+ catch (err) {
239
+ deps.print("");
240
+ deps.print(err instanceof DomainApiError
241
+ ? `✗ ${err.message}`
242
+ : "✗ Couldn't reach Vincentt to list domains.");
243
+ deps.print("");
244
+ return 1;
245
+ }
246
+ if (opts.json) {
247
+ deps.print(JSON.stringify(view, null, 2));
248
+ return 0;
249
+ }
250
+ if (view.domains.length === 0) {
251
+ for (const l of copy.listEmpty(view.planName))
252
+ deps.print(l);
253
+ return 0;
254
+ }
255
+ deps.print("");
256
+ for (const l of copy.listRows(view.domains))
257
+ deps.print(l);
258
+ deps.print("");
259
+ for (const l of copy.entitlementLine(view.count ?? view.domains.length, view.limit, view.planName)) {
260
+ deps.print(l);
261
+ }
262
+ deps.print("");
263
+ deps.print(" vincentt domain verify <domain> to check one now");
264
+ deps.print("");
265
+ return 0;
266
+ }
267
+ /**
268
+ * Flow C2 — verify, and the diagnosis that is the differentiation.
269
+ *
270
+ * THE EXIT CODE FOLLOWS THE GLYPH, NOT THE COUNT. The mapping is
271
+ * `exitCodeForFindings`, called on the findings alone, from one place — so a
272
+ * report of ten advisories exits 0 and a report of one blocking finding exits 1.
273
+ */
274
+ async function verifyVerb(deps, cfg, opts) {
275
+ const hostname = requireHostname(deps, opts, "verify");
276
+ if (hostname === undefined)
277
+ return 1;
278
+ const binding = await deps.binding();
279
+ if (!binding) {
280
+ for (const l of NO_PROJECT)
281
+ deps.print(l);
282
+ return 1;
283
+ }
284
+ deps.print("");
285
+ deps.print(copy.verifyReading(hostname));
286
+ let result;
287
+ try {
288
+ result = await deps.verify(cfg, binding.projectId, hostname);
289
+ }
290
+ catch (err) {
291
+ if (err instanceof DomainApiError && err.status === 404) {
292
+ for (const l of copy.notAttached(hostname))
293
+ deps.print(l);
294
+ return 1;
295
+ }
296
+ if (err instanceof DomainApiError && err.status === 429) {
297
+ for (const l of copy.refusedTooSoon(err.retryAfterSec))
298
+ deps.print(l);
299
+ return 1;
300
+ }
301
+ deps.print("");
302
+ deps.print(err instanceof DomainApiError
303
+ ? `✗ ${err.message}`
304
+ : `✗ Couldn't reach Vincentt to check ${hostname}.`);
305
+ deps.print("");
306
+ deps.print(copy.NOTHING_WAS_CHANGED);
307
+ deps.print("");
308
+ return 1;
309
+ }
310
+ const view = result.domain;
311
+ if (opts.json) {
312
+ deps.print(JSON.stringify({ domain: view }, null, 2));
313
+ return copy.exitCodeForFindings(view.findings);
314
+ }
315
+ return renderVerify(deps, view, result.roles);
316
+ }
317
+ /**
318
+ * The verify render, split out so the state→transcript mapping is one readable
319
+ * table and the exit code is computed in exactly one place per arm.
320
+ */
321
+ export function renderVerify(deps, view, roles) {
322
+ // cert_failed is a STATE, not an error: it is customer-actionable, and the
323
+ // domain is proven theirs. It names a different actor than any other state.
324
+ if (view.state === "cert_failed") {
325
+ for (const l of copy.verifyCertFailed(view.domain, view.findings, view.permanentAddress)) {
326
+ deps.print(l);
327
+ }
328
+ return 1;
329
+ }
330
+ // `failing` gets its OWN arm, before the serving one, because the serving copy
331
+ // is false here: it opens by confirming the TXT record matches, which sat
332
+ // directly above "we can't find the TXT record". A failing domain is serving on
333
+ // a record we can no longer see, so nothing about its DNS is confirmed — and
334
+ // the record values are re-printed, which QA-F16-C1 requires for this state.
335
+ if (view.state === "failing") {
336
+ for (const l of copy.verifyFailing(view.domain, view.findings, view.holdsAt, view.permanentAddress)) {
337
+ deps.print(l);
338
+ }
339
+ if (view.records.length > 0) {
340
+ for (const l of copy.allRecordBlocks(view.records, view.domain, roles)) {
341
+ deps.print(l);
342
+ }
343
+ }
344
+ // Still serving, but the creator must act: a non-zero exit is what tells an
345
+ // agent this needs attention while the address is still up.
346
+ return 1;
347
+ }
348
+ const serving = view.state === "live";
349
+ if (serving && view.findings.length > 0) {
350
+ // Serving WITH findings. When they are all advisory this EXITS 0 — the
351
+ // D-44 case, and QA-F16-C2's assertion. The record block is re-printed only
352
+ // when the certificate validation record is the missing one, because that
353
+ // state's entire cause is a record the creator tidied away.
354
+ const certGone = view.findings.some((f) => f.code === VALIDATION_RECORD_GONE);
355
+ const reprint = certGone ? certRecords(view, roles) : { records: [], roles: [] };
356
+ for (const l of copy.verifyServingWithAdvisories(view.domain, view.findings, reprint.records, reprint.roles)) {
357
+ deps.print(l);
358
+ }
359
+ // THE GLYPH, NOT THE COUNT. All-advisory ⇒ 0, however many there are.
360
+ return copy.exitCodeForFindings(view.findings);
361
+ }
362
+ if (serving) {
363
+ for (const l of copy.verifyLive(view.domain, view.permanentAddress, undefined)) {
364
+ deps.print(l);
365
+ }
366
+ return 0;
367
+ }
368
+ // Not serving: pending, verified, held, detached, or an unknown state.
369
+ //
370
+ // THE EXIT CODE HERE IS 1, and it is NOT the findings mapping. That mapping
371
+ // answers "do the observed findings block?"; this arm answers "is the domain
372
+ // serving?", and the answer is no. A `pending` domain with zero findings — the
373
+ // records simply have not propagated yet — is not ready, and an agent that read
374
+ // exit 0 there would move on to sharing an address that does not resolve.
375
+ //
376
+ // The all-advisory exit-0 rule (QA-F16-C2) governs a report about a SERVING
377
+ // domain, which is the arm above. Applying the glyph mapping here would make a
378
+ // domain that has never resolved exit 0 whenever its only finding is a proxy
379
+ // observation, which is the opposite of what the creator needs to know.
380
+ for (const l of copy.verifyNotReady(view.domain, view.findings, view.permanentAddress)) {
381
+ deps.print(l);
382
+ }
383
+ // The record values are re-printed for the states where a creator returns to
384
+ // re-read them (QA-F16-C1: pending, failing, held) — a creator whose brand is
385
+ // dark should not have to navigate to find the values again.
386
+ if (needsRecordsReprinted(view) && view.records.length > 0) {
387
+ for (const l of copy.allRecordBlocks(view.records, view.domain, roles)) {
388
+ deps.print(l);
389
+ }
390
+ }
391
+ return 1;
392
+ }
393
+ /** The certificate record alone, for the D-44 re-print. */
394
+ function certRecords(view, roles) {
395
+ const records = [];
396
+ const outRoles = [];
397
+ view.records.forEach((r, i) => {
398
+ if (roles[i] === "certificate") {
399
+ records.push(r);
400
+ outRoles.push(roles[i]);
401
+ }
402
+ });
403
+ return { records, roles: outRoles };
404
+ }
405
+ /**
406
+ * The states where a creator returns to re-read the record values: pending,
407
+ * failing and held (QA-F16-C1). A creator whose brand is dark should not have to
408
+ * navigate to find the values again.
409
+ */
410
+ export function needsRecordsReprinted(view) {
411
+ return view.state === "pending" || view.state === "failing" || view.state === "held";
412
+ }
413
+ /**
414
+ * Flow C7 — detach.
415
+ *
416
+ * `[y/N]`. The default tracks whether an accidental Enter would be EXPENSIVE, of
417
+ * which irreversibility is one cause and IMMEDIATE PUBLIC VISIBILITY is another.
418
+ * Detaching is fully reversible and still takes a client's advertised address
419
+ * dark mid-campaign.
420
+ *
421
+ * DECLINING IS EXIT 0 WITH ZERO NETWORK CALLS beyond the reads already made.
422
+ * Declining is not a failure; it is the prompt working.
423
+ */
424
+ async function detachVerb(deps, cfg, opts) {
425
+ const hostname = requireHostname(deps, opts, "detach");
426
+ if (hostname === undefined)
427
+ return 1;
428
+ const binding = await deps.binding();
429
+ if (!binding) {
430
+ for (const l of NO_PROJECT)
431
+ deps.print(l);
432
+ return 1;
433
+ }
434
+ // A non-TTY with no explicit flag REFUSES. A pipe cannot consent, and assuming
435
+ // yes on a public-visibility act is the failure the convention exists to stop.
436
+ if (!deps.isTTY && !opts.yes) {
437
+ for (const l of copy.detachNeedsConsent(hostname))
438
+ deps.print(l);
439
+ return 1;
440
+ }
441
+ if (!opts.yes) {
442
+ for (const l of copy.detachDisclosure(hostname, undefined))
443
+ deps.print(l);
444
+ const ok = await deps.confirm(copy.detachPrompt(hostname), false);
445
+ if (!ok) {
446
+ for (const l of copy.detachDeclined(hostname))
447
+ deps.print(l);
448
+ return 0;
449
+ }
450
+ }
451
+ try {
452
+ await deps.detach(cfg, binding.projectId, hostname);
453
+ }
454
+ catch (err) {
455
+ deps.print("");
456
+ deps.print(err instanceof DomainApiError
457
+ ? `✗ ${err.message}`
458
+ : `✗ Couldn't reach Vincentt to detach ${hostname}.`);
459
+ deps.print("");
460
+ deps.print(copy.NOTHING_WAS_CHANGED);
461
+ deps.print("");
462
+ return 1;
463
+ }
464
+ for (const l of copy.detachDone(hostname))
465
+ deps.print(l);
466
+ return 0;
467
+ }