@dalzoubi/dev-agents-sync 1.0.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.
- package/package.json +43 -0
- package/src/auth.mjs +95 -0
- package/src/cli.mjs +157 -0
- package/src/commands/check.mjs +77 -0
- package/src/commands/diff.mjs +68 -0
- package/src/commands/init.mjs +159 -0
- package/src/commands/status.mjs +71 -0
- package/src/commands/update.mjs +103 -0
- package/src/fetcher.mjs +356 -0
- package/src/index.mjs +14 -0
- package/src/lockfile.mjs +147 -0
- package/src/marker.mjs +61 -0
- package/src/range.mjs +50 -0
- package/src/writer.mjs +132 -0
- package/tests/auth.test.mjs +290 -0
- package/tests/fetcher.test.mjs +1247 -0
- package/tests/fixtures/release-v1.0.0/claude/agents/define.md +18 -0
- package/tests/fixtures/release-v1.0.0/claude/agents/test.md +17 -0
- package/tests/fixtures/release-v1.0.0/claude/commands/preflight.md +7 -0
- package/tests/fixtures/release-v1.0.0/cursor/rules/define.mdc +16 -0
- package/tests/fixtures/release-v1.0.0/cursor/rules/test.mdc +15 -0
- package/tests/init.test.mjs +514 -0
- package/tests/lockfile.test.mjs +202 -0
- package/tests/marker.test.mjs +190 -0
- package/tests/paths.test.mjs +212 -0
- package/tests/range.test.mjs +163 -0
- package/tests/status-check-diff.test.mjs +489 -0
- package/tests/update.test.mjs +322 -0
- package/tests/writer-normalize.test.mjs +99 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/update.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Tests for the `update` subcommand.
|
|
5
|
+
*
|
|
6
|
+
* `update` reads the lockfile, resolves the latest tag within `range`,
|
|
7
|
+
* fetches that tag's dist, and rewrites managed files for the recorded targets.
|
|
8
|
+
* Unmanaged files at the same paths are NOT overwritten without --force.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import {
|
|
14
|
+
mkdtempSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
existsSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
readdirSync,
|
|
21
|
+
} from 'node:fs';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
import { runUpdate } from '../src/commands/update.mjs';
|
|
27
|
+
import { writeLockfile, readLockfile } from '../src/lockfile.mjs';
|
|
28
|
+
import { hasMarker } from '../src/marker.mjs';
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Fixture helpers
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'release-v1.0.0');
|
|
36
|
+
|
|
37
|
+
function readFixtureContent(relativePath) {
|
|
38
|
+
return readFileSync(path.join(FIXTURE_DIR, relativePath), 'utf8');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Builds a FileMap from the fixture directory.
|
|
43
|
+
* Keys are relative paths using forward slashes.
|
|
44
|
+
*/
|
|
45
|
+
function buildFixtureFileMap() {
|
|
46
|
+
const map = {};
|
|
47
|
+
for (const target of ['claude', 'cursor']) {
|
|
48
|
+
const targetDir = path.join(FIXTURE_DIR, target);
|
|
49
|
+
if (!existsSync(targetDir)) continue;
|
|
50
|
+
collectFiles(targetDir, targetDir, map, target);
|
|
51
|
+
}
|
|
52
|
+
return map;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function collectFiles(baseDir, currentDir, map, prefix) {
|
|
56
|
+
const entries = readdirSync(currentDir, { withFileTypes: true });
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
const full = path.join(currentDir, entry.name);
|
|
59
|
+
if (entry.isDirectory()) {
|
|
60
|
+
collectFiles(baseDir, full, map, prefix);
|
|
61
|
+
} else {
|
|
62
|
+
const rel = path.relative(baseDir, full).replace(/\\/g, '/');
|
|
63
|
+
map[`${prefix}/${rel}`] = readFileSync(full, 'utf8');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A fetcher that returns v1.0.0 content for all tags (unit test simplification).
|
|
70
|
+
* The second fetcher simulates v1.2.0 with slightly different content.
|
|
71
|
+
*/
|
|
72
|
+
function makeV100Fetcher() {
|
|
73
|
+
return async (_repo, _tag, _token) => buildFixtureFileMap();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function makeV120Fetcher() {
|
|
77
|
+
// Same files but with v1.2.0 marker for simulation
|
|
78
|
+
return async (_repo, _tag, _token) => {
|
|
79
|
+
const base = buildFixtureFileMap();
|
|
80
|
+
const updated = {};
|
|
81
|
+
for (const [key, content] of Object.entries(base)) {
|
|
82
|
+
updated[key] = content.replace(
|
|
83
|
+
/<!-- managed-by: dev-agents-sync v[\d.]+ -->/,
|
|
84
|
+
'<!-- managed-by: dev-agents-sync v1.2.0 -->',
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return updated;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Helpers
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
function makeTmpDir() {
|
|
96
|
+
return mkdtempSync(path.join(tmpdir(), 'das-update-test-'));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Sets up a consumer repo that has already run init (has lockfile + managed files). */
|
|
100
|
+
async function setupInitializedRepo(consumerDir, opts = {}) {
|
|
101
|
+
const {
|
|
102
|
+
targets = ['claude'],
|
|
103
|
+
resolvedVersion = '1.0.0',
|
|
104
|
+
range = '^1',
|
|
105
|
+
managedContent = null,
|
|
106
|
+
} = opts;
|
|
107
|
+
|
|
108
|
+
// Write lockfile
|
|
109
|
+
writeLockfile(consumerDir, {
|
|
110
|
+
source: 'github:dalzoubi/dev-agents',
|
|
111
|
+
range,
|
|
112
|
+
resolvedVersion,
|
|
113
|
+
targets,
|
|
114
|
+
lastUpdated: '2026-04-01T00:00:00Z',
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Write some managed files
|
|
118
|
+
if (targets.includes('claude')) {
|
|
119
|
+
const agentsDir = path.join(consumerDir, '.claude', 'agents');
|
|
120
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
121
|
+
const content = managedContent || readFixtureContent('claude/agents/define.md');
|
|
122
|
+
writeFileSync(path.join(agentsDir, 'define.md'), content, 'utf8');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Basic update behavior
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
describe('update — basic behavior', () => {
|
|
131
|
+
let consumerDir;
|
|
132
|
+
|
|
133
|
+
beforeEach(() => {
|
|
134
|
+
consumerDir = makeTmpDir();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
afterEach(() => {
|
|
138
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('reads the lockfile and updates resolvedVersion to the latest matching tag', async () => {
|
|
142
|
+
await setupInitializedRepo(consumerDir, { targets: ['claude'], resolvedVersion: '1.0.0' });
|
|
143
|
+
|
|
144
|
+
await runUpdate(consumerDir, {
|
|
145
|
+
fetcher: makeV120Fetcher(),
|
|
146
|
+
availableTags: ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'],
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const lockfile = readLockfile(consumerDir);
|
|
150
|
+
assert.equal(lockfile.resolvedVersion, '1.2.0');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('updates lastUpdated timestamp after a successful update', async () => {
|
|
154
|
+
await setupInitializedRepo(consumerDir, { resolvedVersion: '1.0.0' });
|
|
155
|
+
const before = new Date('2026-04-01T00:00:00Z').getTime();
|
|
156
|
+
|
|
157
|
+
await runUpdate(consumerDir, {
|
|
158
|
+
fetcher: makeV120Fetcher(),
|
|
159
|
+
availableTags: ['v1.0.0', 'v1.2.0'],
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const lockfile = readLockfile(consumerDir);
|
|
163
|
+
assert.ok(lockfile.lastUpdated, 'lastUpdated must be present after update');
|
|
164
|
+
// lastUpdated should be a valid ISO date string
|
|
165
|
+
assert.ok(!isNaN(Date.parse(lockfile.lastUpdated)), 'lastUpdated must be a valid date');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('rewrites managed files with new content from the resolved version', async () => {
|
|
169
|
+
await setupInitializedRepo(consumerDir, { targets: ['claude'], resolvedVersion: '1.0.0' });
|
|
170
|
+
|
|
171
|
+
await runUpdate(consumerDir, {
|
|
172
|
+
fetcher: makeV120Fetcher(),
|
|
173
|
+
availableTags: ['v1.0.0', 'v1.2.0'],
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const written = readFileSync(path.join(consumerDir, '.claude', 'agents', 'define.md'), 'utf8');
|
|
177
|
+
// The v1.2.0 fetcher replaces the marker version
|
|
178
|
+
assert.ok(
|
|
179
|
+
written.includes('v1.2.0') || hasMarker(written),
|
|
180
|
+
'rewritten file must still have managed-by marker',
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('only regenerates targets recorded in the lockfile (claude-only stays claude-only)', async () => {
|
|
185
|
+
await setupInitializedRepo(consumerDir, { targets: ['claude'], resolvedVersion: '1.0.0' });
|
|
186
|
+
|
|
187
|
+
await runUpdate(consumerDir, {
|
|
188
|
+
fetcher: makeV120Fetcher(),
|
|
189
|
+
availableTags: ['v1.0.0', 'v1.2.0'],
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// .cursor/ must NOT be created even though fetcher has cursor files
|
|
193
|
+
assert.ok(
|
|
194
|
+
!existsSync(path.join(consumerDir, '.cursor')),
|
|
195
|
+
'.cursor/ must not be created when lockfile targets is ["claude"]',
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('exits 0 with "already up to date" when no upgrade is available', async () => {
|
|
200
|
+
await setupInitializedRepo(consumerDir, { targets: ['claude'], resolvedVersion: '1.2.0' });
|
|
201
|
+
|
|
202
|
+
let result;
|
|
203
|
+
await assert.doesNotReject(async () => {
|
|
204
|
+
result = await runUpdate(consumerDir, {
|
|
205
|
+
fetcher: makeV100Fetcher(),
|
|
206
|
+
availableTags: ['v1.0.0', 'v1.1.0', 'v1.2.0'],
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Result should indicate up to date (either via a field or a logged message)
|
|
211
|
+
assert.ok(
|
|
212
|
+
result?.upToDate === true ||
|
|
213
|
+
result?.message?.toLowerCase().includes('up to date') ||
|
|
214
|
+
result?.alreadyCurrent === true,
|
|
215
|
+
`expected up-to-date indicator, got: ${JSON.stringify(result)}`,
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Preserves unmanaged files
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
describe('update — preserves unmanaged files', () => {
|
|
225
|
+
let consumerDir;
|
|
226
|
+
|
|
227
|
+
beforeEach(() => {
|
|
228
|
+
consumerDir = makeTmpDir();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
afterEach(() => {
|
|
232
|
+
rmSync(consumerDir, { recursive: true, force: true });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('does not overwrite an unmanaged file at a managed path (exit 1)', async () => {
|
|
236
|
+
await setupInitializedRepo(consumerDir, { targets: ['claude'], resolvedVersion: '1.0.0' });
|
|
237
|
+
|
|
238
|
+
// Replace the managed file with an unmanaged one (strip the marker)
|
|
239
|
+
writeFileSync(
|
|
240
|
+
path.join(consumerDir, '.claude', 'agents', 'define.md'),
|
|
241
|
+
'# My custom content — no marker',
|
|
242
|
+
'utf8',
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
let exitCode;
|
|
246
|
+
try {
|
|
247
|
+
await runUpdate(consumerDir, {
|
|
248
|
+
fetcher: makeV120Fetcher(),
|
|
249
|
+
availableTags: ['v1.0.0', 'v1.2.0'],
|
|
250
|
+
});
|
|
251
|
+
exitCode = 0;
|
|
252
|
+
} catch (err) {
|
|
253
|
+
exitCode = err.exitCode;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
assert.equal(exitCode, 1, 'expected exit code 1 when unmanaged collision found during update');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('--force overwrites unmanaged files during update', async () => {
|
|
260
|
+
await setupInitializedRepo(consumerDir, { targets: ['claude'], resolvedVersion: '1.0.0' });
|
|
261
|
+
|
|
262
|
+
writeFileSync(
|
|
263
|
+
path.join(consumerDir, '.claude', 'agents', 'define.md'),
|
|
264
|
+
'# No marker here',
|
|
265
|
+
'utf8',
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
await assert.doesNotReject(
|
|
269
|
+
runUpdate(consumerDir, {
|
|
270
|
+
force: true,
|
|
271
|
+
fetcher: makeV120Fetcher(),
|
|
272
|
+
availableTags: ['v1.0.0', 'v1.2.0'],
|
|
273
|
+
}),
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const content = readFileSync(
|
|
277
|
+
path.join(consumerDir, '.claude', 'agents', 'define.md'),
|
|
278
|
+
'utf8',
|
|
279
|
+
);
|
|
280
|
+
assert.ok(hasMarker(content), 'force-overwritten file must now have the managed-by marker');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('leaves files outside managed paths completely untouched', async () => {
|
|
284
|
+
await setupInitializedRepo(consumerDir, { targets: ['claude'], resolvedVersion: '1.0.0' });
|
|
285
|
+
|
|
286
|
+
// A completely unrelated file in .claude/
|
|
287
|
+
const customFile = path.join(consumerDir, '.claude', 'my-custom-skill.md');
|
|
288
|
+
mkdirSync(path.join(consumerDir, '.claude'), { recursive: true });
|
|
289
|
+
writeFileSync(customFile, '# My Custom Skill\nNot managed.', 'utf8');
|
|
290
|
+
|
|
291
|
+
await runUpdate(consumerDir, {
|
|
292
|
+
fetcher: makeV120Fetcher(),
|
|
293
|
+
availableTags: ['v1.0.0', 'v1.2.0'],
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// Custom file must still exist with original content
|
|
297
|
+
assert.ok(existsSync(customFile), 'unrelated custom file must not be deleted');
|
|
298
|
+
const content = readFileSync(customFile, 'utf8');
|
|
299
|
+
assert.equal(content, '# My Custom Skill\nNot managed.');
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// Lockfile is required
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
describe('update — lockfile required', () => {
|
|
308
|
+
it('throws an actionable error when no lockfile exists', async () => {
|
|
309
|
+
const emptyDir = makeTmpDir();
|
|
310
|
+
try {
|
|
311
|
+
await assert.rejects(
|
|
312
|
+
runUpdate(emptyDir, {
|
|
313
|
+
fetcher: makeV100Fetcher(),
|
|
314
|
+
availableTags: ['v1.0.0'],
|
|
315
|
+
}),
|
|
316
|
+
/lockfile|\.dev-agents-sync|init/i,
|
|
317
|
+
);
|
|
318
|
+
} finally {
|
|
319
|
+
rmSync(emptyDir, { recursive: true, force: true });
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/writer-normalize.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Tests for normalizeFileMap's tolerance + visibility behavior.
|
|
5
|
+
*
|
|
6
|
+
* The function still accepts un-prefixed first segments that map onto a
|
|
7
|
+
* known target subdir (claude/agents, claude/commands, cursor/rules) but
|
|
8
|
+
* must emit a stderr warning when an unrecognized key is silently dropped,
|
|
9
|
+
* so content-loss is observable during development.
|
|
10
|
+
*
|
|
11
|
+
* (Slice 4 will pin the fetcher to prefixed shape and remove this tolerance.)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it } from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
|
|
17
|
+
import { normalizeFileMap } from '../src/writer.mjs';
|
|
18
|
+
|
|
19
|
+
describe('normalizeFileMap — tolerance + visibility', () => {
|
|
20
|
+
it('passes through already-prefixed keys untouched', () => {
|
|
21
|
+
const out = normalizeFileMap({
|
|
22
|
+
'claude/agents/define.md': 'A',
|
|
23
|
+
'cursor/rules/x.mdc': 'B',
|
|
24
|
+
});
|
|
25
|
+
assert.equal(out['claude/agents/define.md'], 'A');
|
|
26
|
+
assert.equal(out['cursor/rules/x.mdc'], 'B');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('infers prefix for known un-prefixed first segments', () => {
|
|
30
|
+
const out = normalizeFileMap({
|
|
31
|
+
'agents/define.md': 'A',
|
|
32
|
+
'rules/x.mdc': 'B',
|
|
33
|
+
});
|
|
34
|
+
assert.equal(out['claude/agents/define.md'], 'A');
|
|
35
|
+
assert.equal(out['cursor/rules/x.mdc'], 'B');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('drops unrecognized keys and emits a stderr warning naming the key', () => {
|
|
39
|
+
const origWrite = process.stderr.write.bind(process.stderr);
|
|
40
|
+
let captured = '';
|
|
41
|
+
// Intercept stderr writes for the duration of this test.
|
|
42
|
+
process.stderr.write = (chunk, ...rest) => {
|
|
43
|
+
captured += typeof chunk === 'string' ? chunk : chunk.toString();
|
|
44
|
+
return true;
|
|
45
|
+
};
|
|
46
|
+
try {
|
|
47
|
+
const out = normalizeFileMap({
|
|
48
|
+
'foo/bar.md': 'should-be-dropped',
|
|
49
|
+
'claude/agents/keep.md': 'kept',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
assert.equal(
|
|
53
|
+
out['foo/bar.md'],
|
|
54
|
+
undefined,
|
|
55
|
+
'unrecognized key must not appear in output',
|
|
56
|
+
);
|
|
57
|
+
assert.equal(out['claude/agents/keep.md'], 'kept');
|
|
58
|
+
|
|
59
|
+
assert.ok(
|
|
60
|
+
captured.includes('warn:'),
|
|
61
|
+
`expected stderr warning, got: ${JSON.stringify(captured)}`,
|
|
62
|
+
);
|
|
63
|
+
assert.ok(
|
|
64
|
+
captured.includes('dev-agents-sync'),
|
|
65
|
+
`warning must be tagged with the package name: ${captured}`,
|
|
66
|
+
);
|
|
67
|
+
assert.ok(
|
|
68
|
+
captured.includes("'foo/bar.md'"),
|
|
69
|
+
`warning must name the dropped key: ${captured}`,
|
|
70
|
+
);
|
|
71
|
+
} finally {
|
|
72
|
+
process.stderr.write = origWrite;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('does not warn for recognized keys', () => {
|
|
77
|
+
const origWrite = process.stderr.write.bind(process.stderr);
|
|
78
|
+
let captured = '';
|
|
79
|
+
process.stderr.write = (chunk) => {
|
|
80
|
+
captured += typeof chunk === 'string' ? chunk : chunk.toString();
|
|
81
|
+
return true;
|
|
82
|
+
};
|
|
83
|
+
try {
|
|
84
|
+
normalizeFileMap({
|
|
85
|
+
'claude/agents/define.md': 'A',
|
|
86
|
+
'agents/test.md': 'B',
|
|
87
|
+
'cursor/rules/r.mdc': 'C',
|
|
88
|
+
'rules/s.mdc': 'D',
|
|
89
|
+
});
|
|
90
|
+
assert.equal(
|
|
91
|
+
captured,
|
|
92
|
+
'',
|
|
93
|
+
`expected no stderr output for recognized keys, got: ${captured}`,
|
|
94
|
+
);
|
|
95
|
+
} finally {
|
|
96
|
+
process.stderr.write = origWrite;
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|