@catalyst-cloud/cli 0.14.6 → 0.14.8
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/CHANGELOG.md +14 -0
- package/README.md +7 -5
- package/dist/args.js +68 -18
- package/dist/browser.js +37 -3
- package/dist/cli.js +58 -12
- package/dist/config.js +17 -5
- package/dist/consent-browser.js +58 -0
- package/dist/daemon-authority-lifetime.js +171 -0
- package/dist/daemon-authority.js +216 -0
- package/dist/daemon-cache.js +348 -0
- package/dist/daemon-store.js +370 -0
- package/dist/daemon-supervisor.js +460 -0
- package/dist/env/onboard-draft-store.js +250 -0
- package/dist/env/onboard-draft.js +33 -0
- package/dist/env/selected-import-writer.js +163 -0
- package/dist/env/settings-authority.js +205 -0
- package/dist/events-cloud-tail.js +146 -0
- package/dist/events-cloud.js +619 -0
- package/dist/events.js +186 -31
- package/dist/oauth.js +129 -34
- package/dist/onboard-accounts.js +259 -0
- package/dist/onboard-automation-management.js +15 -0
- package/dist/onboard-bootstrap.js +162 -0
- package/dist/onboard-capabilities.js +15 -7
- package/dist/onboard-capacity.js +226 -0
- package/dist/onboard-existing.js +106 -32
- package/dist/onboard-file-snapshot.js +45 -0
- package/dist/onboard-github.js +14 -3
- package/dist/onboard-login-candidate.js +261 -0
- package/dist/onboard-next.js +139 -0
- package/dist/onboard-personal.js +14 -3
- package/dist/onboard-projects.js +350 -0
- package/dist/onboard-repositories.js +146 -37
- package/dist/onboard-runtime.js +108 -13
- package/dist/onboard-settings-context.js +141 -0
- package/dist/onboard-settings-import.js +127 -0
- package/dist/onboard-settings.js +234 -0
- package/dist/onboard-ui.js +85 -59
- package/dist/onboard-workflow.js +316 -0
- package/dist/onboard-workspace.js +14 -3
- package/dist/onboard.js +422 -39
- package/dist/owned-fetch.js +182 -0
- package/dist/pagination.js +13 -1
- package/dist/query.js +104 -31
- package/dist/ready.js +143 -35
- package/dist/replica.js +11 -4
- package/dist/repository-id.js +5 -0
- package/dist/sdk.js +33 -0
- package/dist/watch.js +2 -2
- package/package.json +3 -2
- package/skills/catalyst-github/SKILL.md +4 -2
- package/skills/catalyst-github/references/is-it-mergeable.md +1 -1
- package/skills/catalyst-github/references/local-replica.md +78 -0
- package/skills/catalyst-github/references/reading-from-the-cloud.md +24 -0
- package/skills/catalyst-github/references/what-a-pr-accumulates.md +1 -1
- package/skills/catalyst-github/scripts/is-it-mergeable.mjs +210 -45
- package/skills/catalyst-github/scripts/read-pr.mjs +51 -15
- package/skills/catalyst-linear/SKILL.md +5 -3
- package/skills/catalyst-linear/references/local-replica.md +91 -0
- package/skills/catalyst-linear/references/reading-a-ticket.md +3 -3
- package/skills/catalyst-linear/references/reading-from-the-cloud.md +38 -0
- package/skills/catalyst-linear/scripts/read-ticket.mjs +37 -12
- package/skills/catalyst-onboard/SKILL.md +6 -4
- package/skills/catalyst-onboard/references/local-replica.md +40 -0
- package/skills/catalyst-onboard/references/local-sync.md +8 -4
- package/skills/catalyst-onboard/scripts/local-sync.mjs +2 -2
- package/skills/catalyst-onboard/scripts/where-am-i.mjs +1 -1
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +4 -2
- package/skills/whats-happening/references/local-replica.md +51 -0
- package/skills/whats-happening/references/reading-from-the-cloud.md +21 -0
- package/skills/whats-happening/references/status-reply.md +1 -1
- package/skills/whats-happening/scripts/snapshot.mjs +104 -26
- package/vendor/settings/index.d.ts +4 -0
- package/vendor/settings/index.js +2937 -0
- package/vendor/settings/provenance.json +148 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { configPathFor, loadConfig, normalizeBaseUrl } from "./config.js";
|
|
4
|
+
import { verifyOnboardRoutes } from "./onboard-capabilities.js";
|
|
5
|
+
import { selectedOnboardTeam } from "./onboard-existing.js";
|
|
6
|
+
import { ownedFetch } from "./owned-fetch.js";
|
|
7
|
+
import { loadHttpSdk } from "./sdk.js";
|
|
8
|
+
import { fetchMe } from "./transport.js";
|
|
9
|
+
const path = "/api/v1/agent/team-workflow";
|
|
10
|
+
const slots = [
|
|
11
|
+
"dispatch",
|
|
12
|
+
"intake",
|
|
13
|
+
"research",
|
|
14
|
+
"plan",
|
|
15
|
+
"implement",
|
|
16
|
+
"remediate",
|
|
17
|
+
"verify",
|
|
18
|
+
"review",
|
|
19
|
+
"pr",
|
|
20
|
+
"done",
|
|
21
|
+
"canceled",
|
|
22
|
+
];
|
|
23
|
+
const requiredTypes = {
|
|
24
|
+
dispatch: ["unstarted", "backlog"],
|
|
25
|
+
intake: ["unstarted", "backlog"],
|
|
26
|
+
pr: ["started"],
|
|
27
|
+
done: ["completed"],
|
|
28
|
+
canceled: ["canceled"],
|
|
29
|
+
};
|
|
30
|
+
const waiting = (reason) => ({
|
|
31
|
+
state: "waiting",
|
|
32
|
+
reason,
|
|
33
|
+
});
|
|
34
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
35
|
+
? value
|
|
36
|
+
: null;
|
|
37
|
+
const id = (value) => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value);
|
|
38
|
+
const integer = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
39
|
+
/** Existing mappings can satisfy this step by observation. This never means a preview was applied. */
|
|
40
|
+
export function observeOnboardWorkflow(value, team, now) {
|
|
41
|
+
const row = object(value), config = object(row?.config), readiness = object(row?.readiness);
|
|
42
|
+
if (!row ||
|
|
43
|
+
!config ||
|
|
44
|
+
!readiness ||
|
|
45
|
+
config.teamId !== team ||
|
|
46
|
+
readiness.teamId !== team ||
|
|
47
|
+
typeof config.mode !== "string" ||
|
|
48
|
+
!["mapped-existing", "adopted-recommended", "mixed"].includes(config.mode) ||
|
|
49
|
+
(config.gitAutomation !== "off" && config.gitAutomation !== "managed") ||
|
|
50
|
+
!integer(config.workflowRev) ||
|
|
51
|
+
!integer(readiness.workflowRev) ||
|
|
52
|
+
typeof readiness.status !== "string" ||
|
|
53
|
+
!["ready", "degraded", "blocked"].includes(readiness.status) ||
|
|
54
|
+
!integer(readiness.checkedAt) ||
|
|
55
|
+
readiness.checkedAt > now ||
|
|
56
|
+
now - readiness.checkedAt >= 300_000 ||
|
|
57
|
+
(row.stageSource !== "linear" && row.stageSource !== "mirror") ||
|
|
58
|
+
typeof row.mappingHash !== "string" ||
|
|
59
|
+
!/^[a-f0-9]{64}$/.test(row.mappingHash) ||
|
|
60
|
+
!Array.isArray(row.rows) ||
|
|
61
|
+
row.rows.length > slots.length ||
|
|
62
|
+
!Array.isArray(row.stages) ||
|
|
63
|
+
row.stages.length > 1_000 ||
|
|
64
|
+
!Array.isArray(readiness.checks) ||
|
|
65
|
+
readiness.checks.length > 100)
|
|
66
|
+
return null;
|
|
67
|
+
const stages = new Map();
|
|
68
|
+
for (const candidate of row.stages) {
|
|
69
|
+
const stage = object(candidate);
|
|
70
|
+
if (!stage ||
|
|
71
|
+
!id(stage.id) ||
|
|
72
|
+
stages.has(stage.id) ||
|
|
73
|
+
typeof stage.type !== "string" ||
|
|
74
|
+
![
|
|
75
|
+
"triage",
|
|
76
|
+
"backlog",
|
|
77
|
+
"unstarted",
|
|
78
|
+
"started",
|
|
79
|
+
"completed",
|
|
80
|
+
"canceled",
|
|
81
|
+
].includes(stage.type))
|
|
82
|
+
return null;
|
|
83
|
+
stages.set(stage.id, stage.type);
|
|
84
|
+
}
|
|
85
|
+
const mapping = new Map();
|
|
86
|
+
for (const candidate of row.rows) {
|
|
87
|
+
const item = object(candidate);
|
|
88
|
+
if (!item ||
|
|
89
|
+
typeof item.slot !== "string" ||
|
|
90
|
+
!slots.includes(item.slot) ||
|
|
91
|
+
mapping.has(item.slot) ||
|
|
92
|
+
(item.linearStateId !== null && !id(item.linearStateId)) ||
|
|
93
|
+
(item.source !== undefined &&
|
|
94
|
+
item.source !== "created" &&
|
|
95
|
+
item.source !== "matched" &&
|
|
96
|
+
item.source !== "chosen") ||
|
|
97
|
+
(item.linearStateId !== null &&
|
|
98
|
+
(item.stateStillExists !== true || !stages.has(item.linearStateId))))
|
|
99
|
+
return null;
|
|
100
|
+
mapping.set(item.slot, item.linearStateId);
|
|
101
|
+
}
|
|
102
|
+
for (const [slot, types] of Object.entries(requiredTypes)) {
|
|
103
|
+
const target = mapping.get(slot);
|
|
104
|
+
if (!target ||
|
|
105
|
+
!types.includes(stages.get(target) ?? ""))
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const checks = new Map();
|
|
109
|
+
for (const candidate of readiness.checks) {
|
|
110
|
+
const check = object(candidate);
|
|
111
|
+
if (!check ||
|
|
112
|
+
typeof check.id !== "string" ||
|
|
113
|
+
checks.has(check.id) ||
|
|
114
|
+
(check.state !== "pass" &&
|
|
115
|
+
check.state !== "fail" &&
|
|
116
|
+
check.state !== "unknown"))
|
|
117
|
+
return null;
|
|
118
|
+
checks.set(check.id, check.state);
|
|
119
|
+
}
|
|
120
|
+
// Adoption does not establish coding accounts, automation rule safety or first-ticket readiness.
|
|
121
|
+
if ([
|
|
122
|
+
"team_visible",
|
|
123
|
+
"mapped_states_exist",
|
|
124
|
+
"mapping_total",
|
|
125
|
+
"types_compatible",
|
|
126
|
+
"labels_present",
|
|
127
|
+
].some((check) => checks.get(check) !== "pass"))
|
|
128
|
+
return null;
|
|
129
|
+
return {
|
|
130
|
+
mappingHash: row.mappingHash,
|
|
131
|
+
mappingRevision: config.workflowRev,
|
|
132
|
+
readinessRevision: readiness.workflowRev,
|
|
133
|
+
checkedAt: readiness.checkedAt,
|
|
134
|
+
mappedSlots: [...mapping.values()].filter((value) => value !== null).length,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/** HTTP only, without rotating credentials or calling adopt/apply. Every entered fetch/body joins. */
|
|
138
|
+
export function onboardWorkflowVerificationAdapter(input = {}) {
|
|
139
|
+
return {
|
|
140
|
+
check: async (ctx, journal, external) => {
|
|
141
|
+
let cfg;
|
|
142
|
+
try {
|
|
143
|
+
cfg = loadConfig(ctx.home);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return waiting("workflow_identity_unverified");
|
|
147
|
+
}
|
|
148
|
+
const team = selectedOnboardTeam(journal);
|
|
149
|
+
if (!team ||
|
|
150
|
+
!cfg?.user ||
|
|
151
|
+
(cfg.user.role !== "owner" && cfg.user.role !== "admin") ||
|
|
152
|
+
cfg.account !== (journal.account ?? journal.tenant) ||
|
|
153
|
+
cfg.user.id !== journal.membershipId ||
|
|
154
|
+
!journal.baseUrl ||
|
|
155
|
+
normalizeBaseUrl(cfg.baseUrl) !== normalizeBaseUrl(journal.baseUrl))
|
|
156
|
+
return waiting("workflow_identity_unverified");
|
|
157
|
+
const origin = normalizeBaseUrl(cfg.baseUrl), person = cfg.user.id, role = cfg.user.role;
|
|
158
|
+
let url;
|
|
159
|
+
try {
|
|
160
|
+
url = new URL(origin);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return waiting("workflow_identity_unverified");
|
|
164
|
+
}
|
|
165
|
+
if (url.protocol !== "https:" ||
|
|
166
|
+
url.origin !== origin ||
|
|
167
|
+
url.username ||
|
|
168
|
+
url.password)
|
|
169
|
+
return waiting("workflow_identity_unverified");
|
|
170
|
+
const credential = cfg.key ||
|
|
171
|
+
(cfg.auth &&
|
|
172
|
+
Date.parse(cfg.auth.expiresAt) - ctx.now().getTime() > 30_000
|
|
173
|
+
? cfg.auth.accessToken
|
|
174
|
+
: undefined);
|
|
175
|
+
if (!credential)
|
|
176
|
+
return waiting("workflow_login_refresh_required");
|
|
177
|
+
const configWitness = () => createHash("sha256")
|
|
178
|
+
.update(readFileSync(configPathFor(ctx.home)))
|
|
179
|
+
.digest("hex");
|
|
180
|
+
let before;
|
|
181
|
+
try {
|
|
182
|
+
before = configWitness();
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return waiting("workflow_identity_unverified");
|
|
186
|
+
}
|
|
187
|
+
const current = () => {
|
|
188
|
+
try {
|
|
189
|
+
const now = loadConfig(ctx.home);
|
|
190
|
+
return (now?.user?.id === person &&
|
|
191
|
+
now.user.role === role &&
|
|
192
|
+
now.account === cfg.account &&
|
|
193
|
+
normalizeBaseUrl(now.baseUrl) === origin &&
|
|
194
|
+
configWitness() === before &&
|
|
195
|
+
selectedOnboardTeam(journal) === team &&
|
|
196
|
+
(!!cfg.key ||
|
|
197
|
+
(!!cfg.auth &&
|
|
198
|
+
Date.parse(cfg.auth.expiresAt) > ctx.now().getTime())));
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
const deadline = new AbortController();
|
|
205
|
+
const timer = setTimeout(() => deadline.abort(), 30_000);
|
|
206
|
+
const signal = external
|
|
207
|
+
? AbortSignal.any([external, deadline.signal])
|
|
208
|
+
: deadline.signal;
|
|
209
|
+
const owned = ownedFetch(ctx.fetch, signal, { maxBodyBytes: 524_288 });
|
|
210
|
+
const read = (request, init) => {
|
|
211
|
+
if (!current() || signal.aborted)
|
|
212
|
+
return Promise.reject(new Error("workflow_read_stopped"));
|
|
213
|
+
const requested = new URL(request instanceof Request ? request.url : String(request));
|
|
214
|
+
const method = init?.method ?? (request instanceof Request ? request.method : "GET");
|
|
215
|
+
if (method !== "GET" ||
|
|
216
|
+
requested.origin !== origin ||
|
|
217
|
+
requested.username ||
|
|
218
|
+
requested.password ||
|
|
219
|
+
requested.hash ||
|
|
220
|
+
!["/api/v1/me", "/api/v1/agent/contract", path].includes(requested.pathname) ||
|
|
221
|
+
(requested.pathname === path
|
|
222
|
+
? requested.search !== `?team=${encodeURIComponent(team)}`
|
|
223
|
+
: requested.search !== ""))
|
|
224
|
+
return Promise.reject(new Error("workflow_read_scope"));
|
|
225
|
+
return owned.fetch(request, { ...init, signal, redirect: "error" });
|
|
226
|
+
};
|
|
227
|
+
const live = async () => {
|
|
228
|
+
const me = await fetchMe(origin, credential, read);
|
|
229
|
+
return (current() &&
|
|
230
|
+
!signal.aborted &&
|
|
231
|
+
me.account === cfg.account &&
|
|
232
|
+
me.user?.id === person &&
|
|
233
|
+
me.user.role === role);
|
|
234
|
+
};
|
|
235
|
+
const result = await (async () => {
|
|
236
|
+
try {
|
|
237
|
+
if (!(await live()))
|
|
238
|
+
return waiting("workflow_identity_unverified");
|
|
239
|
+
const support = await verifyOnboardRoutes({ ...ctx, fetch: read }, journal, [{ method: "GET", path }], signal);
|
|
240
|
+
if ("reason" in support)
|
|
241
|
+
return waiting(support.reason);
|
|
242
|
+
if (!current() || signal.aborted)
|
|
243
|
+
return waiting("workflow_identity_unverified");
|
|
244
|
+
const sdk = await loadHttpSdk();
|
|
245
|
+
if (!current() || signal.aborted)
|
|
246
|
+
return waiting("workflow_identity_unverified");
|
|
247
|
+
const client = sdk.createTenantClient({
|
|
248
|
+
baseUrl: origin,
|
|
249
|
+
key: credential,
|
|
250
|
+
fetch: read,
|
|
251
|
+
timeoutMs: 30_000,
|
|
252
|
+
now: () => ctx.now().getTime(),
|
|
253
|
+
});
|
|
254
|
+
const first = await client.teamWorkflow.get(team);
|
|
255
|
+
const observed = first.outcome === "ok"
|
|
256
|
+
? observeOnboardWorkflow(first, team, ctx.now().getTime())
|
|
257
|
+
: null;
|
|
258
|
+
if (!observed || !current() || signal.aborted) {
|
|
259
|
+
input.message?.("Workflow adoption is waiting for a verified mapping or full-plan server support. Setup made no workflow change.");
|
|
260
|
+
return waiting("workflow_mapping_unverified");
|
|
261
|
+
}
|
|
262
|
+
if (!(await live()))
|
|
263
|
+
return waiting("workflow_identity_unverified");
|
|
264
|
+
const last = await client.teamWorkflow.get(team);
|
|
265
|
+
const verified = last.outcome === "ok"
|
|
266
|
+
? observeOnboardWorkflow(last, team, ctx.now().getTime())
|
|
267
|
+
: null;
|
|
268
|
+
if (!verified ||
|
|
269
|
+
verified.mappingHash !== observed.mappingHash ||
|
|
270
|
+
verified.mappingRevision !== observed.mappingRevision ||
|
|
271
|
+
verified.readinessRevision !== observed.readinessRevision ||
|
|
272
|
+
!current() ||
|
|
273
|
+
signal.aborted)
|
|
274
|
+
return waiting("workflow_mapping_changed");
|
|
275
|
+
if (!(await live()))
|
|
276
|
+
return waiting("workflow_identity_unverified");
|
|
277
|
+
if (!current() || signal.aborted)
|
|
278
|
+
return waiting("workflow_identity_unverified");
|
|
279
|
+
return {
|
|
280
|
+
state: "done",
|
|
281
|
+
evidence: {
|
|
282
|
+
team,
|
|
283
|
+
revision: verified.readinessRevision,
|
|
284
|
+
mappingRevision: verified.mappingRevision,
|
|
285
|
+
checkedAt: verified.checkedAt,
|
|
286
|
+
mappingHash: verified.mappingHash,
|
|
287
|
+
count: verified.mappedSlots,
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return waiting(external?.aborted ? "interrupted" : "workflow_unavailable");
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
try {
|
|
296
|
+
owned.abort(new Error("workflow_read_complete"));
|
|
297
|
+
await owned.settle();
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
clearTimeout(timer);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
})();
|
|
304
|
+
if (!current())
|
|
305
|
+
return waiting("workflow_identity_unverified");
|
|
306
|
+
if (signal.aborted)
|
|
307
|
+
return waiting(external?.aborted ? "interrupted" : "workflow_unavailable");
|
|
308
|
+
if (result.state === "done") {
|
|
309
|
+
input.message?.("The selected team's existing workflow mapping and labels are verified. Setup made no workflow change.");
|
|
310
|
+
if (!current() || signal.aborted)
|
|
311
|
+
return waiting("workflow_identity_unverified");
|
|
312
|
+
}
|
|
313
|
+
return result;
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { finishOnTheWeb } from "./consent-browser.js";
|
|
1
2
|
import { loadConfig, normalizeBaseUrl } from "./config.js";
|
|
2
3
|
import { pollConsent } from "./onboard-consent.js";
|
|
3
4
|
const STATUS = "/api/v1/me/connections/linear/workspace";
|
|
@@ -252,11 +253,15 @@ export function linearWorkspaceAdapter(options) {
|
|
|
252
253
|
return { state: "refused", reason: "workspace_admin_required" };
|
|
253
254
|
ctx.stderr("Approve the organization's Linear connection in your browser. Your personal approval follows.");
|
|
254
255
|
// The signed URL is transient credential material: only the browser receives it.
|
|
256
|
+
let browserUnavailable = false;
|
|
255
257
|
try {
|
|
256
|
-
options.openBrowser(url);
|
|
258
|
+
await options.openBrowser(url, signal);
|
|
257
259
|
}
|
|
258
260
|
catch {
|
|
259
|
-
|
|
261
|
+
if (signal?.aborted)
|
|
262
|
+
return waiting("interrupted");
|
|
263
|
+
browserUnavailable = true;
|
|
264
|
+
ctx.stderr(finishOnTheWeb(current.baseUrl, "connections", "connect Linear"));
|
|
260
265
|
}
|
|
261
266
|
let latest = waiting("workspace_status_unavailable");
|
|
262
267
|
const run = () => pollConsent({
|
|
@@ -271,6 +276,8 @@ export function linearWorkspaceAdapter(options) {
|
|
|
271
276
|
outcome: "waiting",
|
|
272
277
|
reason: "cloud_capability_unavailable",
|
|
273
278
|
};
|
|
279
|
+
if (latest.reason === "workspace_login_refresh_required")
|
|
280
|
+
return { outcome: "waiting", reason: latest.reason };
|
|
274
281
|
if (latest.state === "done")
|
|
275
282
|
return { outcome: "connected" };
|
|
276
283
|
if (latest.state === "refused" || latest.state === "failed")
|
|
@@ -283,7 +290,11 @@ export function linearWorkspaceAdapter(options) {
|
|
|
283
290
|
const result = options.wait
|
|
284
291
|
? await options.wait("Waiting for workspace approval", run)
|
|
285
292
|
: await run();
|
|
286
|
-
|
|
293
|
+
if (result.state === "done")
|
|
294
|
+
return latest;
|
|
295
|
+
return browserUnavailable && result.reason === "consent_timeout"
|
|
296
|
+
? waiting("workspace_browser_unavailable")
|
|
297
|
+
: result;
|
|
287
298
|
},
|
|
288
299
|
};
|
|
289
300
|
}
|