doomain 0.1.21 → 0.1.23

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 (37) hide show
  1. package/README.md +59 -2
  2. package/bin/dev.js +3 -1
  3. package/bin/ensure-current-manifest.js +18 -0
  4. package/bin/run.js +4 -1
  5. package/dist/commands/dns/delete.d.ts +1 -0
  6. package/dist/commands/dns/delete.js +1 -0
  7. package/dist/commands/dns/diagnose.d.ts +16 -0
  8. package/dist/commands/dns/diagnose.js +49 -0
  9. package/dist/commands/dns/point.js +17 -0
  10. package/dist/commands/dns/remove.d.ts +18 -0
  11. package/dist/commands/dns/remove.js +77 -0
  12. package/dist/commands/providers/status.js +1 -2
  13. package/dist/commands/version.d.ts +5 -0
  14. package/dist/commands/version.js +8 -0
  15. package/dist/lib/command-schema.d.ts +1 -1
  16. package/dist/lib/command-schema.js +69 -1
  17. package/dist/lib/diagnose-dns.d.ts +53 -0
  18. package/dist/lib/diagnose-dns.js +156 -0
  19. package/dist/lib/dns-propagation.d.ts +45 -0
  20. package/dist/lib/dns-propagation.js +114 -0
  21. package/dist/lib/dns-reconciliation.d.ts +31 -0
  22. package/dist/lib/dns-reconciliation.js +53 -0
  23. package/dist/lib/dns-records.d.ts +18 -0
  24. package/dist/lib/dns-records.js +73 -0
  25. package/dist/lib/domain-provider.js +1 -1
  26. package/dist/lib/errors.d.ts +1 -1
  27. package/dist/lib/point-domain.d.ts +6 -3
  28. package/dist/lib/point-domain.js +36 -70
  29. package/dist/lib/providers/hostinger/index.d.ts +1 -0
  30. package/dist/lib/providers/hostinger/index.js +46 -10
  31. package/dist/lib/providers/spaceship/index.js +16 -7
  32. package/dist/lib/providers/status.d.ts +3 -0
  33. package/dist/lib/providers/status.js +2 -0
  34. package/dist/lib/remove-domain.d.ts +38 -0
  35. package/dist/lib/remove-domain.js +125 -0
  36. package/oclif.manifest.json +264 -1
  37. package/package.json +9 -2
@@ -1,87 +1,27 @@
1
- import { resolve4, resolve6, resolveCname } from 'node:dns/promises';
2
- import { isIP } from 'node:net';
1
+ import { waitForDnsPropagation } from './dns-propagation.js';
2
+ import { reconcileDesiredRecord } from './dns-reconciliation.js';
3
+ import { inferAddressRecordType, normalizeAddressRecordTarget } from './dns-records.js';
3
4
  import { resolveProviderTarget } from './domain-provider.js';
4
5
  import { DoomainError } from './errors.js';
5
6
  import { withProviderRecordOptions } from './link-domain.js';
6
7
  import { createProvider } from './providers/registry.js';
7
- import { normalizeDomain } from './validate.js';
8
8
  const defaultDependencies = {
9
9
  createProvider,
10
10
  resolveTarget: resolveProviderTarget,
11
11
  };
12
- function cleanTarget(value) {
13
- return value.trim().replace(/\.$/, '');
14
- }
15
- function inferredRecordType(target) {
16
- const version = isIP(target);
17
- if (version === 4)
18
- return 'A';
19
- if (version === 6)
20
- return 'AAAA';
21
- return 'CNAME';
22
- }
23
- function validateTarget(recordType, target) {
24
- const version = isIP(target);
25
- if (recordType === 'A' && version !== 4)
26
- throw new DoomainError('INVALID_INPUT', 'A records require an IPv4 target.');
27
- if (recordType === 'AAAA' && version !== 6)
28
- throw new DoomainError('INVALID_INPUT', 'AAAA records require an IPv6 target.');
29
- if (recordType === 'CNAME' && version !== 0)
30
- throw new DoomainError('INVALID_INPUT', 'CNAME records require a hostname target.');
31
- }
32
12
  export function createPointRecord(input) {
33
- const target = cleanTarget(input.target);
34
- if (!target)
35
- throw new DoomainError('MISSING_ARGUMENT', 'A DNS target is required.');
36
- const recordType = input.recordType ?? inferredRecordType(target);
37
- validateTarget(recordType, target);
13
+ const recordType = input.recordType ?? inferAddressRecordType(input.target.trim());
14
+ const target = normalizeAddressRecordTarget(recordType, input.target);
38
15
  return withProviderRecordOptions(input.provider, {
39
16
  name: input.recordName,
40
17
  ttl: input.ttl ?? 300,
41
18
  type: recordType,
42
- value: recordType === 'CNAME' ? normalizeDomain(target) : target,
19
+ value: target,
43
20
  });
44
21
  }
45
22
  function recordFqdn(record, zoneDomain) {
46
23
  return record.name === '@' ? zoneDomain : `${record.name}.${zoneDomain}`;
47
24
  }
48
- function cleanDnsValue(value) {
49
- return value.toLowerCase().replace(/\.$/, '');
50
- }
51
- function normalizeIpv6(value) {
52
- return new URL(`http://[${value}]`).hostname.slice(1, -1);
53
- }
54
- async function isPropagated(record, zoneDomain, dependencies) {
55
- const fqdn = recordFqdn(record, zoneDomain);
56
- try {
57
- if (record.type === 'A')
58
- return (await (dependencies.resolve4 ?? resolve4)(fqdn)).includes(record.value);
59
- if (record.type === 'AAAA') {
60
- const expected = normalizeIpv6(record.value);
61
- return (await (dependencies.resolve6 ?? resolve6)(fqdn)).some((value) => normalizeIpv6(value) === expected);
62
- }
63
- if (record.type === 'CNAME') {
64
- return (await (dependencies.resolveCname ?? resolveCname)(fqdn))
65
- .map(cleanDnsValue)
66
- .includes(cleanDnsValue(record.value));
67
- }
68
- }
69
- catch {
70
- return false;
71
- }
72
- return false;
73
- }
74
- async function waitForPropagation(record, zoneDomain, timeoutSeconds, dependencies) {
75
- const deadline = Date.now() + timeoutSeconds * 1000;
76
- while (true) {
77
- if (await isPropagated(record, zoneDomain, dependencies))
78
- return true;
79
- const remaining = deadline - Date.now();
80
- if (remaining <= 0)
81
- return false;
82
- await new Promise((resolve) => setTimeout(resolve, Math.min(5000, remaining)));
83
- }
84
- }
85
25
  function conflictWarning(resolved, provider, record, conflicts) {
86
26
  return {
87
27
  account: resolved.account,
@@ -170,6 +110,14 @@ export async function pointDomain(input, dependencies = defaultDependencies) {
170
110
  provider: resolved.provider,
171
111
  providerInferred: resolved.providerInferred,
172
112
  propagated: false,
113
+ propagation: {
114
+ elapsedMs: 0,
115
+ expected: record.value,
116
+ observations: [],
117
+ status: 'not_checked',
118
+ },
119
+ reconciled: false,
120
+ reconciliationAttempts: 0,
173
121
  record,
174
122
  skipped: [],
175
123
  updated: false,
@@ -191,10 +139,25 @@ export async function pointDomain(input, dependencies = defaultDependencies) {
191
139
  }
192
140
  input.progress?.(`Pointing ${resolved.target.fullDomain} to ${record.value}`);
193
141
  const result = await provider.applyChanges(zone, plan, { force });
142
+ const reconciliation = await reconcileDesiredRecord({
143
+ desired: record,
144
+ progress: input.progress,
145
+ provider,
146
+ timeoutMs: (input.reconcileTimeoutSeconds ?? 30) * 1000,
147
+ zone,
148
+ });
194
149
  const shouldWait = input.wait ?? true;
195
- const propagated = shouldWait
196
- ? await waitForPropagation(record, resolved.target.zoneDomain, input.timeoutSeconds ?? 300, dependencies)
197
- : false;
150
+ const propagation = shouldWait
151
+ ? await waitForDnsPropagation({
152
+ fqdn: recordFqdn(record, resolved.target.zoneDomain),
153
+ observe: dependencies.observeDns,
154
+ record,
155
+ timeoutSeconds: input.timeoutSeconds ?? 300,
156
+ })
157
+ : { elapsedMs: 0, expected: record.value, observations: [], status: 'not_checked' };
158
+ const propagated = propagation.status === 'propagated' ||
159
+ propagation.status === 'local_or_vpn_cache_stale' ||
160
+ propagation.status === 'system_resolver_unavailable';
198
161
  return {
199
162
  account: resolved.account,
200
163
  accountInferred: resolved.accountInferred,
@@ -204,9 +167,12 @@ export async function pointDomain(input, dependencies = defaultDependencies) {
204
167
  provider: resolved.provider,
205
168
  providerInferred: resolved.providerInferred,
206
169
  propagated,
170
+ propagation,
171
+ reconciled: reconciliation.reconciled,
172
+ reconciliationAttempts: reconciliation.attempts,
207
173
  record,
208
174
  skipped: result.skipped,
209
- updated: result.applied.length > 0,
175
+ updated: result.applied.length > 0 || reconciliation.appliedChanges > 0,
210
176
  zoneDomain: resolved.target.zoneDomain,
211
177
  };
212
178
  }
@@ -19,5 +19,6 @@ export declare class HostingerProvider implements DnsProvider {
19
19
  upsertRecord(zone: DnsZone, record: DnsRecordInput): Promise<DnsRecord>;
20
20
  deleteRecord(zone: DnsZone, record: DnsRecord): Promise<void>;
21
21
  private putRecords;
22
+ private putHostingerRecordSets;
22
23
  }
23
24
  export declare const hostingerProviderDefinition: DnsProviderDefinition;
@@ -47,7 +47,7 @@ function toDnsRecords(record, zone) {
47
47
  return [];
48
48
  return [
49
49
  {
50
- metadata: { hostinger: { ...record, records: [item] } },
50
+ metadata: { hostinger: record },
51
51
  name,
52
52
  ttl: record.ttl,
53
53
  type,
@@ -56,12 +56,13 @@ function toDnsRecords(record, zone) {
56
56
  ];
57
57
  });
58
58
  }
59
- function toHostingerRecord(record) {
59
+ function toHostingerRecordSet(records) {
60
+ const first = records[0];
60
61
  return {
61
- name: record.name,
62
- records: [{ content: record.value }],
63
- ttl: record.ttl ?? capabilities.defaultTtl,
64
- type: record.type,
62
+ name: first.name,
63
+ records: records.map((record) => ({ content: record.value })),
64
+ ttl: first.ttl ?? capabilities.defaultTtl,
65
+ type: first.type,
65
66
  };
66
67
  }
67
68
  function deleteFilter(record) {
@@ -142,15 +143,40 @@ export class HostingerProvider {
142
143
  assertNoConflicts(this.id, plan);
143
144
  const applied = [];
144
145
  const skipped = [];
146
+ const deletionSets = new Map();
147
+ for (const change of plan.changes) {
148
+ if (change.action !== 'delete')
149
+ continue;
150
+ const key = `${change.existing.type}\0${change.existing.name}`;
151
+ const records = deletionSets.get(key) ?? [];
152
+ records.push(change.existing);
153
+ deletionSets.set(key, records);
154
+ }
155
+ for (const deleted of deletionSets.values()) {
156
+ const sample = deleted[0];
157
+ const source = sample.metadata?.hostinger;
158
+ const deletedValues = new Set(deleted.map((record) => record.value));
159
+ const sourceRecords = source?.records?.filter((record) => record.is_disabled || !record.content || !deletedValues.has(record.content));
160
+ if (source && sourceRecords && sourceRecords.length > 0) {
161
+ await this.putHostingerRecordSets(zone, [{ ...source, records: sourceRecords }], true);
162
+ }
163
+ else {
164
+ const remaining = plan.existing.filter((record) => sameRecordSet(record, sample) && !deleted.includes(record));
165
+ if (remaining.length > 0)
166
+ await this.putRecords(zone, remaining, true);
167
+ else
168
+ await this.deleteRecord(zone, sample);
169
+ }
170
+ applied.push(...plan.changes.filter((change) => change.action === 'delete' && deleted.includes(change.existing)));
171
+ }
145
172
  for (const change of plan.changes) {
146
173
  if (change.action === 'skip') {
147
174
  skipped.push(change.record);
148
175
  continue;
149
176
  }
150
177
  if (change.action === 'delete')
151
- await this.deleteRecord(zone, change.existing);
152
- else
153
- await this.putRecords(zone, [change.record], change.action === 'update');
178
+ continue;
179
+ await this.putRecords(zone, [change.record], change.action === 'update');
154
180
  applied.push(change);
155
181
  }
156
182
  return { applied, skipped };
@@ -166,8 +192,18 @@ export class HostingerProvider {
166
192
  });
167
193
  }
168
194
  async putRecords(zone, records, overwrite) {
195
+ const recordSets = new Map();
196
+ for (const record of records) {
197
+ const key = `${record.type}\0${record.name}`;
198
+ const values = recordSets.get(key) ?? [];
199
+ values.push(record);
200
+ recordSets.set(key, values);
201
+ }
202
+ await this.putHostingerRecordSets(zone, [...recordSets.values()].map(toHostingerRecordSet), overwrite);
203
+ }
204
+ async putHostingerRecordSets(zone, recordSets, overwrite) {
169
205
  await this.http.request(`/api/dns/v1/zones/${encodeURIComponent(zone.name)}`, {
170
- body: { overwrite, zone: records.map(toHostingerRecord) },
206
+ body: { overwrite, zone: recordSets },
171
207
  method: 'PUT',
172
208
  });
173
209
  }
@@ -1,7 +1,7 @@
1
1
  import { normalizeDomain } from '../../validate.js';
2
2
  import { createProviderHttpClient } from '../core/http.js';
3
3
  import { paginateBySkip } from '../core/pagination.js';
4
- import { applyDnsChanges, planDnsChanges } from '../core/planner.js';
4
+ import { assertNoConflicts, planDnsChanges } from '../core/planner.js';
5
5
  const SPACESHIP_API_URL = 'https://spaceship.dev/api/v1';
6
6
  const capabilities = {
7
7
  defaultTtl: 3600,
@@ -108,12 +108,21 @@ export class SpaceshipProvider {
108
108
  });
109
109
  }
110
110
  async applyChanges(zone, plan) {
111
- return applyDnsChanges({
112
- deleteRecord: (record) => this.deleteRecord(zone, record),
113
- plan,
114
- providerId: this.id,
115
- upsertRecord: (record) => this.upsertRecord(zone, record),
116
- });
111
+ assertNoConflicts(this.id, plan);
112
+ const skipped = plan.changes.flatMap((change) => (change.action === 'skip' ? [change.record] : []));
113
+ const applied = plan.changes.filter((change) => change.action !== 'skip');
114
+ // Spaceship records do not have stable ids. Delete every old value before creating
115
+ // replacements so an API that processes accepted writes out of order cannot leave
116
+ // two address records in the same slot without the reconciler noticing and retrying.
117
+ for (const change of applied) {
118
+ if (change.action === 'delete' || change.action === 'update')
119
+ await this.deleteRecord(zone, change.existing);
120
+ }
121
+ for (const change of applied) {
122
+ if (change.action === 'create' || change.action === 'update')
123
+ await this.upsertRecord(zone, change.record);
124
+ }
125
+ return { applied, skipped };
117
126
  }
118
127
  async upsertRecord(zone, record) {
119
128
  await this.http.request(`/dns/records/${zone.name}`, {
@@ -2,7 +2,9 @@ import { type DoomainConfig } from '../config.js';
2
2
  import type { DnsProviderDefinition } from './types.js';
3
3
  export interface ProviderStatus {
4
4
  account: string;
5
+ accountLabel: string;
5
6
  configured: boolean;
7
+ /** @deprecated Use isPreferredProvider. */
6
8
  default: boolean;
7
9
  displayName: string;
8
10
  docsUrl?: string;
@@ -10,6 +12,7 @@ export interface ProviderStatus {
10
12
  error?: string;
11
13
  id: string;
12
14
  isDefaultAccount: boolean;
15
+ isPreferredProvider: boolean;
13
16
  verified?: boolean;
14
17
  }
15
18
  export declare function isProviderConfigured(definition: DnsProviderDefinition, config: DoomainConfig, opts?: {
@@ -17,12 +17,14 @@ export async function listProviderStatuses(opts = {}) {
17
17
  const configured = accounts.some((item) => item.account === account);
18
18
  const status = {
19
19
  account,
20
+ accountLabel: ref.isDefaultAccount ? `${definition.id}/default` : `${definition.id}/${account}`,
20
21
  configured,
21
22
  default: config.defaults?.provider === definition.id,
22
23
  displayName: definition.displayName,
23
24
  docsUrl: definition.docsUrl,
24
25
  id: definition.id,
25
26
  isDefaultAccount: ref.isDefaultAccount,
27
+ isPreferredProvider: config.defaults?.provider === definition.id,
26
28
  };
27
29
  if (configured && opts.verify) {
28
30
  try {
@@ -0,0 +1,38 @@
1
+ import { type DnsRecordSelector } from './dns-records.js';
2
+ import { type ResolvedDnsTarget } from './domain-provider.js';
3
+ import type { DnsProvider, DnsRecord, DnsRecordType } from './providers/types.js';
4
+ export interface RemoveDomainInput {
5
+ account?: string;
6
+ allMatching?: boolean;
7
+ domain: string;
8
+ dryRun?: boolean;
9
+ provider?: string;
10
+ recordType: DnsRecordType;
11
+ reconcileTimeoutSeconds?: number;
12
+ value?: string;
13
+ confirmMultiple?: (records: DnsRecord[]) => Promise<boolean>;
14
+ progress?: (message: string) => void;
15
+ }
16
+ export interface RemoveDomainResult {
17
+ account: string;
18
+ accountInferred: boolean;
19
+ domain: string;
20
+ dryRun: boolean;
21
+ isDefaultAccount: boolean;
22
+ matched: DnsRecord[];
23
+ provider: string;
24
+ providerInferred: boolean;
25
+ reconciled: boolean;
26
+ reconciliationAttempts: number;
27
+ removed: number;
28
+ selector: DnsRecordSelector;
29
+ zoneDomain: string;
30
+ }
31
+ interface RemoveDomainDependencies {
32
+ createProvider: (provider: string, opts: {
33
+ account?: string;
34
+ }) => Promise<DnsProvider>;
35
+ resolveTarget: (input: Pick<RemoveDomainInput, 'account' | 'domain' | 'provider'>) => Promise<ResolvedDnsTarget>;
36
+ }
37
+ export declare function removeDomain(input: RemoveDomainInput, dependencies?: RemoveDomainDependencies): Promise<RemoveDomainResult>;
38
+ export {};
@@ -0,0 +1,125 @@
1
+ import { reconcileRecordRemoval } from './dns-reconciliation.js';
2
+ import { recordMatchesSelector } from './dns-records.js';
3
+ import { resolveProviderTarget } from './domain-provider.js';
4
+ import { DoomainError } from './errors.js';
5
+ import { createProvider } from './providers/registry.js';
6
+ const defaultDependencies = { createProvider, resolveTarget: resolveProviderTarget };
7
+ function quoteCommandArgument(value) {
8
+ return /^[a-zA-Z0-9_./:@+-]+$/.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`;
9
+ }
10
+ function removalCommand(input, options) {
11
+ const arguments_ = ['doomain', 'dns', 'remove', quoteCommandArgument(input.domain)];
12
+ if (input.provider)
13
+ arguments_.push('--provider', quoteCommandArgument(input.provider));
14
+ if (input.account)
15
+ arguments_.push('--account', quoteCommandArgument(input.account));
16
+ arguments_.push('--type', input.recordType);
17
+ if (input.value !== undefined)
18
+ arguments_.push('--value', quoteCommandArgument(input.value));
19
+ if (options?.allMatching ?? input.allMatching)
20
+ arguments_.push('--all-matching');
21
+ if (options?.dryRun ?? input.dryRun)
22
+ arguments_.push('--dry-run');
23
+ arguments_.push('--json');
24
+ return arguments_.join(' ');
25
+ }
26
+ function removalResolutionError(error, input) {
27
+ if (error.code !== 'CONFIG_NOT_FOUND' && error.code !== 'PROVIDER_ZONE_NOT_FOUND')
28
+ return error;
29
+ const details = error.details && typeof error.details === 'object' ? error.details : {};
30
+ const retry = removalCommand(input);
31
+ return new DoomainError(error.code, error.message, {
32
+ ...details,
33
+ recovery: `Connect or repair the DNS provider account that owns this domain, then retry \`${retry}\`.`,
34
+ suggestedCommands: ['doomain providers connect', retry],
35
+ });
36
+ }
37
+ function ambiguousDeletionError(input, records) {
38
+ return new DoomainError('DNS_DELETE_AMBIGUOUS', `${records.length} DNS records match this deletion. Pass --all-matching to delete all of them.`, {
39
+ matched: records,
40
+ recovery: 'Narrow the deletion with --value, or explicitly approve all matching records with --all-matching.',
41
+ suggestedCommands: [removalCommand(input, { allMatching: true, dryRun: true })],
42
+ });
43
+ }
44
+ export async function removeDomain(input, dependencies = defaultDependencies) {
45
+ if (!input.allMatching && input.value === undefined) {
46
+ throw new DoomainError('INVALID_INPUT', 'An exact --value is required unless --all-matching is passed.', {
47
+ recovery: 'Pass --value <expected-value>, or preview --all-matching with --dry-run.',
48
+ });
49
+ }
50
+ let resolved;
51
+ try {
52
+ resolved = await dependencies.resolveTarget(input);
53
+ }
54
+ catch (error) {
55
+ if (error instanceof DoomainError)
56
+ throw removalResolutionError(error, input);
57
+ throw error;
58
+ }
59
+ const provider = await dependencies.createProvider(resolved.provider, { account: resolved.account });
60
+ if (!provider.capabilities.recordTypes.includes(input.recordType)) {
61
+ throw new DoomainError('PROVIDER_UNSUPPORTED_RECORD', `${provider.name} does not support ${input.recordType} records.`);
62
+ }
63
+ const zone = await provider.getZone(resolved.target.zoneDomain);
64
+ if (!zone) {
65
+ throw new DoomainError('PROVIDER_ZONE_NOT_FOUND', `${provider.name} does not have a DNS zone for ${resolved.target.zoneDomain}.`);
66
+ }
67
+ const selector = {
68
+ name: resolved.target.recordName,
69
+ type: input.recordType,
70
+ ...(input.value === undefined ? {} : { value: input.value }),
71
+ };
72
+ const existing = await provider.listRecords(zone);
73
+ const matched = existing.filter((record) => recordMatchesSelector(record, selector));
74
+ if (matched.length > 1 && !input.allMatching) {
75
+ const confirmed = (await input.confirmMultiple?.(matched)) === true;
76
+ if (!confirmed)
77
+ throw ambiguousDeletionError(input, matched);
78
+ }
79
+ const base = {
80
+ account: resolved.account,
81
+ accountInferred: resolved.accountInferred,
82
+ domain: resolved.target.fullDomain,
83
+ isDefaultAccount: resolved.isDefaultAccount,
84
+ matched,
85
+ provider: resolved.provider,
86
+ providerInferred: resolved.providerInferred,
87
+ selector,
88
+ zoneDomain: resolved.target.zoneDomain,
89
+ };
90
+ if (input.dryRun) {
91
+ return {
92
+ ...base,
93
+ dryRun: true,
94
+ reconciled: false,
95
+ reconciliationAttempts: 0,
96
+ removed: 0,
97
+ };
98
+ }
99
+ if (matched.length === 0) {
100
+ return { ...base, dryRun: false, reconciled: true, reconciliationAttempts: 1, removed: 0 };
101
+ }
102
+ input.progress?.(`Deleting ${matched.length} DNS record${matched.length === 1 ? '' : 's'}`);
103
+ const plan = {
104
+ changes: matched.map((record) => ({ action: 'delete', existing: record })),
105
+ conflicts: [],
106
+ desired: [],
107
+ existing,
108
+ zone,
109
+ };
110
+ await provider.applyChanges(zone, plan, { force: true });
111
+ const reconciliation = await reconcileRecordRemoval({
112
+ progress: input.progress,
113
+ provider,
114
+ selector,
115
+ timeoutMs: (input.reconcileTimeoutSeconds ?? 30) * 1000,
116
+ zone,
117
+ });
118
+ return {
119
+ ...base,
120
+ dryRun: false,
121
+ reconciled: reconciliation.reconciled,
122
+ reconciliationAttempts: reconciliation.attempts,
123
+ removed: matched.length,
124
+ };
125
+ }