@kungfu-tech/buildchain 2.1.0 → 2.2.0-alpha.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.
@@ -0,0 +1,587 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import http from "node:http";
4
+ import https from "node:https";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+
8
+ export const RELEASE_PASSPORT_CONTRACT = "kungfu-buildchain-release-passport";
9
+ export const ARTIFACT_EVIDENCE_CONTRACT = "kungfu-buildchain-artifact-evidence";
10
+ export const IMPACT_LEDGER_CONTRACT = "kungfu-buildchain-impact";
11
+ export const AGENT_INDEX_CONTRACT = "kungfu-buildchain-agent-index";
12
+ export const PRODUCT_MECHANISM_CONTRACT = "kungfu-buildchain-product-mechanism";
13
+ export const RELEASE_CHECK_REPORT_CONTRACT = "kungfu-buildchain-release-check-report";
14
+
15
+ const CONTRACTS = new Set([
16
+ RELEASE_PASSPORT_CONTRACT,
17
+ ARTIFACT_EVIDENCE_CONTRACT,
18
+ IMPACT_LEDGER_CONTRACT,
19
+ AGENT_INDEX_CONTRACT,
20
+ PRODUCT_MECHANISM_CONTRACT,
21
+ ]);
22
+
23
+ function nowIso() {
24
+ return new Date().toISOString();
25
+ }
26
+
27
+ function nonEmptyString(value, label) {
28
+ const normalized = String(value || "").trim();
29
+ if (!normalized) {
30
+ throw new Error(`${label} must be a non-empty string`);
31
+ }
32
+ return normalized;
33
+ }
34
+
35
+ function optionalString(value) {
36
+ return value === undefined || value === null ? "" : String(value);
37
+ }
38
+
39
+ function stableJson(value) {
40
+ if (Array.isArray(value)) {
41
+ return `[${value.map(stableJson).join(",")}]`;
42
+ }
43
+ if (value && typeof value === "object") {
44
+ return `{${Object.keys(value)
45
+ .sort()
46
+ .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
47
+ .join(",")}}`;
48
+ }
49
+ return JSON.stringify(value);
50
+ }
51
+
52
+ export function sha256Text(value) {
53
+ return crypto.createHash("sha256").update(value).digest("hex");
54
+ }
55
+
56
+ export function sha256File(filePath) {
57
+ return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
58
+ }
59
+
60
+ function readJsonFile(filePath) {
61
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
62
+ }
63
+
64
+ function writeJsonFile(filePath, value) {
65
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
66
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
67
+ }
68
+
69
+ function writeTextFile(filePath, value) {
70
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
71
+ fs.writeFileSync(filePath, value.endsWith("\n") ? value : `${value}\n`);
72
+ }
73
+
74
+ function inferPlatformFromName(name) {
75
+ const lower = String(name || "").toLowerCase();
76
+ if (lower.includes("apple-darwin") || lower.includes("darwin") || lower.includes("macos")) {
77
+ return lower.includes("aarch64") || lower.includes("arm64") ? "darwin-arm64" : "darwin-x64";
78
+ }
79
+ if (lower.includes("windows") || lower.includes("pc-windows") || lower.endsWith(".zip")) {
80
+ return "windows-x64";
81
+ }
82
+ if (lower.includes("linux") || lower.includes("unknown-linux")) {
83
+ return lower.includes("aarch64") || lower.includes("arm64") ? "linux-arm64" : "linux-x64";
84
+ }
85
+ return "";
86
+ }
87
+
88
+ function normalizeAsset(asset, index = 0) {
89
+ const name = nonEmptyString(asset.name || asset.filename, `assets[${index}].name`);
90
+ const digest = optionalString(asset.digest || asset.sha256 || asset.checksum);
91
+ const sha256 = digest.replace(/^sha256:/, "");
92
+ return {
93
+ name,
94
+ kind: optionalString(asset.kind || "release-asset"),
95
+ platform: optionalString(asset.platform || inferPlatformFromName(name)),
96
+ size: Number(asset.size || asset.sizeBytes || 0),
97
+ url: optionalString(asset.browser_download_url || asset.downloadUrl || asset.url),
98
+ githubAssetId: optionalString(asset.id || asset.githubAssetId),
99
+ sha256,
100
+ sourcePath: optionalString(asset.path || asset.sourcePath),
101
+ };
102
+ }
103
+
104
+ function defaultProductMechanism({ repository = "", productName = "Buildchain" } = {}) {
105
+ return {
106
+ schemaVersion: 1,
107
+ contract: PRODUCT_MECHANISM_CONTRACT,
108
+ product: {
109
+ name: productName,
110
+ repository,
111
+ northStar: "GitHub-native release passport protocol for binary and multi-artifact products.",
112
+ },
113
+ trustModel: {
114
+ executionSubstrate: "github-actions",
115
+ releaseAuthority: "protected Buildchain channel refs, exact tags, and release passport evidence",
116
+ runnerRequirement: "runner facts are recorded, but the protocol is runner-agnostic",
117
+ },
118
+ compatibility: {
119
+ promise: "release passport schemas are welded surfaces; additive fields are allowed, breaking semantic changes require a new major line",
120
+ kfd: "KFD-1",
121
+ },
122
+ };
123
+ }
124
+
125
+ function defaultImpact({ tag = "", line = "", decision = "unknown" } = {}) {
126
+ return {
127
+ schemaVersion: 1,
128
+ contract: IMPACT_LEDGER_CONTRACT,
129
+ release: { tag, line },
130
+ classification: decision,
131
+ breaking: false,
132
+ security: false,
133
+ migrationRequired: false,
134
+ summary: "No release impact summary was supplied.",
135
+ recovery: {
136
+ rollback: "Use the previous exact release tag or previous floating channel ref.",
137
+ block: "Fail closed if release passport verification fails.",
138
+ },
139
+ };
140
+ }
141
+
142
+ function defaultAgentIndex({ tag = "", passportPath = "buildchain.release.json" } = {}) {
143
+ return {
144
+ schemaVersion: 1,
145
+ contract: AGENT_INDEX_CONTRACT,
146
+ release: { tag },
147
+ entrypoints: [
148
+ {
149
+ id: "release-passport",
150
+ kind: "json",
151
+ path: passportPath,
152
+ description: "Read this first to verify release completeness, artifacts, impact, and recovery pointers.",
153
+ },
154
+ {
155
+ id: "llms",
156
+ kind: "text",
157
+ path: "llms.txt",
158
+ description: "Short agent-readable release instructions.",
159
+ },
160
+ ],
161
+ };
162
+ }
163
+
164
+ function defaultLlmsText({ tag = "", passportPath = "buildchain.release.json" } = {}) {
165
+ return [
166
+ "# Buildchain Release Passport",
167
+ "",
168
+ `Release: ${tag || "unknown"}`,
169
+ "",
170
+ `Start with ${passportPath}. Verify artifact-evidence.json before installing binaries.`,
171
+ "If verification fails, do not install or promote this release.",
172
+ ].join("\n");
173
+ }
174
+
175
+ function parseJsonInput(value, fallback = undefined) {
176
+ const input = String(value || "").trim();
177
+ if (!input) {
178
+ return fallback;
179
+ }
180
+ if (fs.existsSync(input)) {
181
+ return readJsonFile(input);
182
+ }
183
+ return JSON.parse(input);
184
+ }
185
+
186
+ function discoverAssetsFromDir(dir) {
187
+ if (!dir || !fs.existsSync(dir)) {
188
+ return [];
189
+ }
190
+ return fs
191
+ .readdirSync(dir, { withFileTypes: true })
192
+ .filter((entry) => entry.isFile())
193
+ .map((entry, index) => {
194
+ const filePath = path.join(dir, entry.name);
195
+ return normalizeAsset({
196
+ name: entry.name,
197
+ path: filePath,
198
+ size: fs.statSync(filePath).size,
199
+ sha256: sha256File(filePath),
200
+ }, index);
201
+ })
202
+ .sort((a, b) => a.name.localeCompare(b.name));
203
+ }
204
+
205
+ function readPackageVersion(cwd) {
206
+ const packagePath = path.join(cwd, "package.json");
207
+ if (!fs.existsSync(packagePath)) {
208
+ return "";
209
+ }
210
+ try {
211
+ return readJsonFile(packagePath).version || "";
212
+ } catch {
213
+ return "";
214
+ }
215
+ }
216
+
217
+ export function createArtifactEvidence({ assets = [], repository = "", tag = "", sourceSha = "", workflow = {} } = {}) {
218
+ const normalizedAssets = assets.map((asset, index) => normalizeAsset(asset, index));
219
+ return {
220
+ schemaVersion: 1,
221
+ contract: ARTIFACT_EVIDENCE_CONTRACT,
222
+ repository: optionalString(repository),
223
+ release: { tag: optionalString(tag), sourceSha: optionalString(sourceSha) },
224
+ generatedAt: nowIso(),
225
+ runner: {
226
+ kind: optionalString(workflow.runnerKind || workflow.runner?.kind || ""),
227
+ os: optionalString(workflow.runnerOs || workflow.runner?.os || ""),
228
+ arch: optionalString(workflow.runnerArch || workflow.runner?.arch || ""),
229
+ labels: Array.isArray(workflow.runnerLabels) ? workflow.runnerLabels : [],
230
+ image: optionalString(workflow.runnerImage || workflow.runner?.image || ""),
231
+ },
232
+ workflow: {
233
+ name: optionalString(workflow.name),
234
+ runId: optionalString(workflow.runId),
235
+ runAttempt: optionalString(workflow.runAttempt),
236
+ url: optionalString(workflow.url),
237
+ },
238
+ artifacts: normalizedAssets.map((asset) => ({
239
+ name: asset.name,
240
+ kind: asset.kind,
241
+ platform: asset.platform,
242
+ size: asset.size,
243
+ sha256: asset.sha256,
244
+ url: asset.url,
245
+ githubAssetId: asset.githubAssetId,
246
+ attestation: optionalString(asset.attestation || ""),
247
+ })),
248
+ };
249
+ }
250
+
251
+ export function createReleasePassport({
252
+ cwd = process.cwd(),
253
+ repository = "",
254
+ tag = "",
255
+ sourceSha = "",
256
+ line = "",
257
+ packageName = "@kungfu-tech/buildchain",
258
+ packageVersion = "",
259
+ productMechanismPath = "product-mechanism.json",
260
+ artifactEvidencePath = "artifact-evidence.json",
261
+ impactPath = "impact.json",
262
+ agentIndexPath = "agent-index.json",
263
+ checkReportPath = "check-report.json",
264
+ assets = [],
265
+ workflow = {},
266
+ } = {}) {
267
+ const normalizedTag = nonEmptyString(tag, "tag");
268
+ const artifactEvidence = createArtifactEvidence({ assets, repository, tag: normalizedTag, sourceSha, workflow });
269
+ return {
270
+ schemaVersion: 1,
271
+ contract: RELEASE_PASSPORT_CONTRACT,
272
+ generatedAt: nowIso(),
273
+ product: {
274
+ name: "Buildchain",
275
+ repository: optionalString(repository),
276
+ mechanism: productMechanismPath,
277
+ },
278
+ release: {
279
+ tag: normalizedTag,
280
+ line: optionalString(line),
281
+ sourceSha: optionalString(sourceSha),
282
+ package: {
283
+ name: packageName,
284
+ version: packageVersion || readPackageVersion(cwd),
285
+ },
286
+ exactRef: normalizedTag ? `refs/tags/${normalizedTag}` : "",
287
+ },
288
+ workflow: {
289
+ name: optionalString(workflow.name),
290
+ runId: optionalString(workflow.runId),
291
+ runAttempt: optionalString(workflow.runAttempt),
292
+ url: optionalString(workflow.url),
293
+ },
294
+ runnerPolicy: {
295
+ productionDefault: "github-hosted",
296
+ compatibilityFixture: "self-hosted",
297
+ note: "Runner facts are recorded in artifact evidence; the protocol does not require self-hosted runners.",
298
+ },
299
+ artifacts: artifactEvidence.artifacts.map((asset) => ({
300
+ name: asset.name,
301
+ platform: asset.platform,
302
+ sha256: asset.sha256,
303
+ evidence: artifactEvidencePath,
304
+ url: asset.url,
305
+ })),
306
+ evidence: {
307
+ artifactEvidence: artifactEvidencePath,
308
+ impact: impactPath,
309
+ checkReport: checkReportPath,
310
+ agentIndex: agentIndexPath,
311
+ },
312
+ recovery: {
313
+ rollback: "Use the previous exact release tag or previous floating channel ref.",
314
+ verify: `buildchain verify release-passport ${normalizedTag ? "buildchain.release.json" : "<passport>"}`,
315
+ },
316
+ };
317
+ }
318
+
319
+ export function collectGitHubReleasePassport({
320
+ cwd = process.cwd(),
321
+ tag = "",
322
+ repository = process.env.GITHUB_REPOSITORY || "",
323
+ sourceSha = process.env.GITHUB_SHA || "",
324
+ line = "",
325
+ outputDir = ".buildchain/release-passport",
326
+ assetsJson = "",
327
+ assetsDir = "",
328
+ releaseJson = "",
329
+ productName = "Buildchain",
330
+ packageName = "@kungfu-tech/buildchain",
331
+ packageVersion = "",
332
+ workflow = {},
333
+ } = {}) {
334
+ const release = parseJsonInput(releaseJson, {});
335
+ const assetsFromJson = parseJsonInput(assetsJson, []);
336
+ const assets = [
337
+ ...(Array.isArray(release.assets) ? release.assets : []),
338
+ ...(Array.isArray(assetsFromJson) ? assetsFromJson : []),
339
+ ...discoverAssetsFromDir(assetsDir),
340
+ ];
341
+ const resolvedTag = tag || release.tag_name || release.name || "";
342
+ const resolvedOutputDir = path.resolve(cwd, outputDir);
343
+ const productMechanism = defaultProductMechanism({ repository, productName });
344
+ const artifactEvidence = createArtifactEvidence({ assets, repository, tag: resolvedTag, sourceSha, workflow });
345
+ const impact = defaultImpact({ tag: resolvedTag, line, decision: "unknown" });
346
+ const agentIndex = defaultAgentIndex({ tag: resolvedTag });
347
+ const passport = createReleasePassport({
348
+ cwd,
349
+ repository,
350
+ tag: resolvedTag,
351
+ sourceSha,
352
+ line,
353
+ packageName,
354
+ packageVersion,
355
+ assets,
356
+ workflow,
357
+ });
358
+ const checkReport = createReleaseCheckReport({
359
+ passport,
360
+ artifactEvidence,
361
+ impact,
362
+ agentIndex,
363
+ productMechanism,
364
+ checkedAt: nowIso(),
365
+ });
366
+ const files = {
367
+ "product-mechanism.json": productMechanism,
368
+ "artifact-evidence.json": artifactEvidence,
369
+ "impact.json": impact,
370
+ "agent-index.json": agentIndex,
371
+ "buildchain.release.json": passport,
372
+ "check-report.json": checkReport,
373
+ };
374
+ for (const [fileName, value] of Object.entries(files)) {
375
+ writeJsonFile(path.join(resolvedOutputDir, fileName), value);
376
+ }
377
+ writeTextFile(path.join(resolvedOutputDir, "llms.txt"), defaultLlmsText({ tag: resolvedTag }));
378
+ return {
379
+ schemaVersion: 1,
380
+ contract: "kungfu-buildchain-release-passport-collection",
381
+ outputDir: resolvedOutputDir,
382
+ files: Object.keys(files).concat("llms.txt"),
383
+ passport,
384
+ artifactEvidence,
385
+ checkReport,
386
+ };
387
+ }
388
+
389
+ function issue(level, code, message, details = {}) {
390
+ return { level, code, message, details };
391
+ }
392
+
393
+ function validateContract(value, expectedContract, label, issues) {
394
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
395
+ issues.push(issue("error", `${label}.object`, `${label} must be a JSON object`));
396
+ return;
397
+ }
398
+ if (Number(value.schemaVersion) !== 1) {
399
+ issues.push(issue("error", `${label}.schemaVersion`, `${label}.schemaVersion must be 1`));
400
+ }
401
+ if (value.contract !== expectedContract) {
402
+ issues.push(issue("error", `${label}.contract`, `${label}.contract must be ${expectedContract}`));
403
+ }
404
+ }
405
+
406
+ function resolveSiblingJson(basePath, relativePath) {
407
+ if (!basePath || !relativePath || /^https?:\/\//.test(relativePath)) {
408
+ return undefined;
409
+ }
410
+ const candidate = path.resolve(path.dirname(basePath), relativePath);
411
+ if (!fs.existsSync(candidate)) {
412
+ return undefined;
413
+ }
414
+ return readJsonFile(candidate);
415
+ }
416
+
417
+ export function createReleaseCheckReport({
418
+ passport,
419
+ artifactEvidence,
420
+ impact,
421
+ agentIndex,
422
+ productMechanism,
423
+ checkedAt = nowIso(),
424
+ } = {}) {
425
+ const issues = [];
426
+ validateContract(passport, RELEASE_PASSPORT_CONTRACT, "passport", issues);
427
+ validateContract(artifactEvidence, ARTIFACT_EVIDENCE_CONTRACT, "artifactEvidence", issues);
428
+ validateContract(impact, IMPACT_LEDGER_CONTRACT, "impact", issues);
429
+ validateContract(agentIndex, AGENT_INDEX_CONTRACT, "agentIndex", issues);
430
+ validateContract(productMechanism, PRODUCT_MECHANISM_CONTRACT, "productMechanism", issues);
431
+
432
+ const tag = passport?.release?.tag || "";
433
+ if (!tag) {
434
+ issues.push(issue("error", "release.tag", "release.tag is required"));
435
+ }
436
+ if (!passport?.release?.sourceSha) {
437
+ issues.push(issue("warning", "release.sourceSha", "release.sourceSha is recommended"));
438
+ }
439
+ const artifacts = Array.isArray(passport?.artifacts) ? passport.artifacts : [];
440
+ const evidenceArtifacts = Array.isArray(artifactEvidence?.artifacts) ? artifactEvidence.artifacts : [];
441
+ if (artifacts.length === 0) {
442
+ issues.push(issue("error", "artifacts.empty", "release passport must list at least one artifact"));
443
+ }
444
+ const evidenceByName = new Map(evidenceArtifacts.map((artifact) => [artifact.name, artifact]));
445
+ for (const artifact of artifacts) {
446
+ if (!artifact.name) {
447
+ issues.push(issue("error", "artifact.name", "artifact name is required"));
448
+ continue;
449
+ }
450
+ const evidence = evidenceByName.get(artifact.name);
451
+ if (!evidence) {
452
+ issues.push(issue("error", "artifact.evidence.missing", `artifact ${artifact.name} is missing evidence`));
453
+ continue;
454
+ }
455
+ if (!/^[0-9a-f]{64}$/i.test(String(evidence.sha256 || ""))) {
456
+ issues.push(issue("error", "artifact.sha256", `artifact ${artifact.name} must have a sha256 digest`));
457
+ }
458
+ if (artifact.sha256 && evidence.sha256 && artifact.sha256 !== evidence.sha256) {
459
+ issues.push(issue("error", "artifact.sha256.mismatch", `artifact ${artifact.name} digest differs between passport and evidence`));
460
+ }
461
+ }
462
+ if (!passport?.runnerPolicy?.productionDefault) {
463
+ issues.push(issue("warning", "runnerPolicy.productionDefault", "runner policy should record the production default"));
464
+ }
465
+ const ok = issues.every((entry) => entry.level !== "error");
466
+ return {
467
+ schemaVersion: 1,
468
+ contract: RELEASE_CHECK_REPORT_CONTRACT,
469
+ checkedAt,
470
+ ok,
471
+ trust: ok ? "pass" : "fail",
472
+ completeness: {
473
+ artifactCount: artifacts.length,
474
+ evidenceArtifactCount: evidenceArtifacts.length,
475
+ impactPresent: Boolean(impact),
476
+ agentIndexPresent: Boolean(agentIndex),
477
+ productMechanismPresent: Boolean(productMechanism),
478
+ },
479
+ issues,
480
+ };
481
+ }
482
+
483
+ export async function readJsonFromLocation(location) {
484
+ const input = nonEmptyString(location, "location");
485
+ if (/^https?:\/\//.test(input)) {
486
+ const client = input.startsWith("https:") ? https : http;
487
+ return new Promise((resolve, reject) => {
488
+ client
489
+ .get(input, (response) => {
490
+ if (response.statusCode < 200 || response.statusCode >= 300) {
491
+ reject(new Error(`HTTP ${response.statusCode} while reading ${input}`));
492
+ response.resume();
493
+ return;
494
+ }
495
+ let body = "";
496
+ response.setEncoding("utf8");
497
+ response.on("data", (chunk) => {
498
+ body += chunk;
499
+ });
500
+ response.on("end", () => {
501
+ try {
502
+ resolve(JSON.parse(body));
503
+ } catch (error) {
504
+ reject(error);
505
+ }
506
+ });
507
+ })
508
+ .on("error", reject);
509
+ });
510
+ }
511
+ return readJsonFile(input);
512
+ }
513
+
514
+ export async function verifyReleasePassport({
515
+ passportLocation,
516
+ artifactEvidenceLocation = "",
517
+ impactLocation = "",
518
+ agentIndexLocation = "",
519
+ productMechanismLocation = "",
520
+ } = {}) {
521
+ const passport = await readJsonFromLocation(passportLocation);
522
+ const basePath = /^https?:\/\//.test(passportLocation) ? "" : path.resolve(passportLocation);
523
+ const artifactEvidence =
524
+ artifactEvidenceLocation
525
+ ? await readJsonFromLocation(artifactEvidenceLocation)
526
+ : resolveSiblingJson(basePath, passport.evidence?.artifactEvidence) || {};
527
+ const impact =
528
+ impactLocation
529
+ ? await readJsonFromLocation(impactLocation)
530
+ : resolveSiblingJson(basePath, passport.evidence?.impact) || {};
531
+ const agentIndex =
532
+ agentIndexLocation
533
+ ? await readJsonFromLocation(agentIndexLocation)
534
+ : resolveSiblingJson(basePath, passport.evidence?.agentIndex) || {};
535
+ const productMechanism =
536
+ productMechanismLocation
537
+ ? await readJsonFromLocation(productMechanismLocation)
538
+ : resolveSiblingJson(basePath, passport.product?.mechanism) || {};
539
+ return createReleaseCheckReport({
540
+ passport,
541
+ artifactEvidence,
542
+ impact,
543
+ agentIndex,
544
+ productMechanism,
545
+ });
546
+ }
547
+
548
+ export async function explainReleasePassport({ passportLocation, forAudience = "human" } = {}) {
549
+ const report = await verifyReleasePassport({ passportLocation });
550
+ const passport = await readJsonFromLocation(passportLocation);
551
+ const nextAction = report.ok
552
+ ? "install-or-upgrade-after-policy-review"
553
+ : "block-release-and-report-verification-failure";
554
+ return {
555
+ schemaVersion: 1,
556
+ contract: "kungfu-buildchain-release-explanation",
557
+ audience: forAudience,
558
+ release: passport.release,
559
+ trust: report.trust,
560
+ complete: report.ok,
561
+ artifactCount: report.completeness.artifactCount,
562
+ runnerPolicy: passport.runnerPolicy,
563
+ impact: {
564
+ breaking: false,
565
+ migrationRequired: false,
566
+ },
567
+ recovery: passport.recovery,
568
+ nextAction,
569
+ issues: report.issues,
570
+ };
571
+ }
572
+
573
+ export function makeReleasePassportFixtureAssets(dir) {
574
+ fs.mkdirSync(dir, { recursive: true });
575
+ const names = [
576
+ `buildchain-${process.platform}-${process.arch}.tar.gz`,
577
+ "checksums.txt",
578
+ ];
579
+ for (const name of names) {
580
+ fs.writeFileSync(path.join(dir, name), `${name}${os.EOL}`);
581
+ }
582
+ return discoverAssetsFromDir(dir);
583
+ }
584
+
585
+ export function validateKnownReleasePassportContracts() {
586
+ return [...CONTRACTS];
587
+ }