@madewithremy/admin 0.1.24 → 0.1.26
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/README.md +2 -1
- package/dist/index.d.ts +150 -7
- package/dist/index.js +135 -8
- package/dist/index.js.map +1 -1
- package/dist/prod.js +257 -33
- package/package.json +7 -4
package/dist/prod.js
CHANGED
|
@@ -987,6 +987,7 @@ import { execFileSync } from "child_process";
|
|
|
987
987
|
|
|
988
988
|
// src/config.ts
|
|
989
989
|
var WORKSPACE_DIR = process.env["WORKSPACE_DIR"] || "/home/remy/workspace";
|
|
990
|
+
var SIDECAR_URL = process.env["REMY_SIDECAR_URL"] || "http://127.0.0.1:4388";
|
|
990
991
|
|
|
991
992
|
// src/ops/releases.ts
|
|
992
993
|
function list3(ctx, params = {}) {
|
|
@@ -2323,6 +2324,9 @@ Examples:
|
|
|
2323
2324
|
import fs from "fs";
|
|
2324
2325
|
|
|
2325
2326
|
// src/ops/issues.ts
|
|
2327
|
+
function authorship(ctx) {
|
|
2328
|
+
return ctx.originAppId ? { originAppId: ctx.originAppId } : {};
|
|
2329
|
+
}
|
|
2326
2330
|
function listQuery(params) {
|
|
2327
2331
|
return qs({
|
|
2328
2332
|
status: params.status,
|
|
@@ -2364,8 +2368,9 @@ function create(ctx, params) {
|
|
|
2364
2368
|
if (params.labels !== void 0) {
|
|
2365
2369
|
requestBody.labels = params.labels;
|
|
2366
2370
|
}
|
|
2367
|
-
|
|
2368
|
-
|
|
2371
|
+
const originAppId = params.originAppId ?? ctx.originAppId;
|
|
2372
|
+
if (originAppId !== void 0) {
|
|
2373
|
+
requestBody.originAppId = originAppId;
|
|
2369
2374
|
}
|
|
2370
2375
|
return call(
|
|
2371
2376
|
ctx,
|
|
@@ -2379,13 +2384,14 @@ function comment(ctx, number, body) {
|
|
|
2379
2384
|
ctx,
|
|
2380
2385
|
"POST",
|
|
2381
2386
|
`/_internal/v2/apps/${ctx.appId}/issues/${seg(number)}/comments`,
|
|
2382
|
-
{ body, authorKind: "agent" }
|
|
2387
|
+
{ body, authorKind: "agent", ...authorship(ctx) }
|
|
2383
2388
|
);
|
|
2384
2389
|
}
|
|
2385
2390
|
function close(ctx, number, comment2) {
|
|
2386
2391
|
const requestBody = {
|
|
2387
2392
|
status: "closed",
|
|
2388
|
-
authorKind: "agent"
|
|
2393
|
+
authorKind: "agent",
|
|
2394
|
+
...authorship(ctx)
|
|
2389
2395
|
};
|
|
2390
2396
|
if (comment2 !== void 0) {
|
|
2391
2397
|
requestBody.comment = comment2;
|
|
@@ -2402,11 +2408,14 @@ function reopen(ctx, number) {
|
|
|
2402
2408
|
ctx,
|
|
2403
2409
|
"POST",
|
|
2404
2410
|
`/_internal/v2/apps/${ctx.appId}/issues/${seg(number)}/update`,
|
|
2405
|
-
{ status: "open", authorKind: "agent" }
|
|
2411
|
+
{ status: "open", authorKind: "agent", ...authorship(ctx) }
|
|
2406
2412
|
);
|
|
2407
2413
|
}
|
|
2408
2414
|
function edit(ctx, number, params) {
|
|
2409
|
-
const requestBody = {
|
|
2415
|
+
const requestBody = {
|
|
2416
|
+
authorKind: "agent",
|
|
2417
|
+
...authorship(ctx)
|
|
2418
|
+
};
|
|
2410
2419
|
if (params.title !== void 0) {
|
|
2411
2420
|
requestBody.title = params.title;
|
|
2412
2421
|
}
|
|
@@ -8255,6 +8264,205 @@ Billing and the log:
|
|
|
8255
8264
|
--wait exit codes: 0 reached the target phase \xB7 1 the platform reported failure \xB7
|
|
8256
8265
|
2 still transitioning when --timeout elapsed (default 600s).`;
|
|
8257
8266
|
|
|
8267
|
+
// src/ops/qaRecordings.ts
|
|
8268
|
+
var RECORDING_STORE = "qa-recordings";
|
|
8269
|
+
async function ls2(ctx, params = {}) {
|
|
8270
|
+
const page = await ls(ctx, {
|
|
8271
|
+
store: RECORDING_STORE,
|
|
8272
|
+
access: "private",
|
|
8273
|
+
...params.sessionId ? { prefix: `${params.sessionId}/` } : {},
|
|
8274
|
+
...params.cursor ? { cursor: params.cursor } : {},
|
|
8275
|
+
...params.limit ? { limit: params.limit } : {}
|
|
8276
|
+
});
|
|
8277
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
8278
|
+
for (const entry of page.files) {
|
|
8279
|
+
const parts = entry.key.split("/");
|
|
8280
|
+
if (parts.length !== 3) {
|
|
8281
|
+
continue;
|
|
8282
|
+
}
|
|
8283
|
+
const [sessionId, runId] = parts;
|
|
8284
|
+
let session = sessions.get(sessionId);
|
|
8285
|
+
if (!session) {
|
|
8286
|
+
session = { sessionId, chunks: 0, bytes: 0, runs: [] };
|
|
8287
|
+
sessions.set(sessionId, session);
|
|
8288
|
+
}
|
|
8289
|
+
let run3 = session.runs.find((r) => r.runId === runId);
|
|
8290
|
+
if (!run3) {
|
|
8291
|
+
run3 = { runId, chunks: 0, bytes: 0, keys: [] };
|
|
8292
|
+
session.runs.push(run3);
|
|
8293
|
+
}
|
|
8294
|
+
const size = entry.size ?? 0;
|
|
8295
|
+
session.chunks += 1;
|
|
8296
|
+
session.bytes += size;
|
|
8297
|
+
run3.chunks += 1;
|
|
8298
|
+
run3.bytes += size;
|
|
8299
|
+
run3.keys.push(entry.key);
|
|
8300
|
+
if (entry.updatedAt) {
|
|
8301
|
+
if (!session.startedAt || entry.updatedAt < session.startedAt) {
|
|
8302
|
+
session.startedAt = entry.updatedAt;
|
|
8303
|
+
}
|
|
8304
|
+
if (!session.endedAt || entry.updatedAt > session.endedAt) {
|
|
8305
|
+
session.endedAt = entry.updatedAt;
|
|
8306
|
+
}
|
|
8307
|
+
}
|
|
8308
|
+
}
|
|
8309
|
+
for (const session of sessions.values()) {
|
|
8310
|
+
for (const run3 of session.runs) {
|
|
8311
|
+
run3.keys.sort((a, b) => seqOf(a) - seqOf(b));
|
|
8312
|
+
}
|
|
8313
|
+
}
|
|
8314
|
+
return {
|
|
8315
|
+
store: RECORDING_STORE,
|
|
8316
|
+
sessions: [...sessions.values()].sort(
|
|
8317
|
+
(a, b) => (b.endedAt ?? "").localeCompare(a.endedAt ?? "")
|
|
8318
|
+
),
|
|
8319
|
+
...page.cursor ? { cursor: page.cursor } : {}
|
|
8320
|
+
};
|
|
8321
|
+
}
|
|
8322
|
+
function seqOf(key) {
|
|
8323
|
+
const n = Number(
|
|
8324
|
+
key.split("/").pop()?.replace(/\.json$/, "")
|
|
8325
|
+
);
|
|
8326
|
+
return Number.isFinite(n) ? n : 0;
|
|
8327
|
+
}
|
|
8328
|
+
var DEFAULT_EXPORT_TIMEOUT_MS = 66e4;
|
|
8329
|
+
async function exportVideo(_ctx, params) {
|
|
8330
|
+
const body = {
|
|
8331
|
+
recordingSessionId: params.sessionId,
|
|
8332
|
+
startTs: params.from,
|
|
8333
|
+
endTs: params.to,
|
|
8334
|
+
...params.store ? { store: params.store } : {},
|
|
8335
|
+
...params.access ? { access: params.access } : {}
|
|
8336
|
+
};
|
|
8337
|
+
let res;
|
|
8338
|
+
try {
|
|
8339
|
+
res = await fetch(`${SIDECAR_URL}/export-recording`, {
|
|
8340
|
+
method: "POST",
|
|
8341
|
+
headers: { "Content-Type": "application/json" },
|
|
8342
|
+
body: JSON.stringify(body),
|
|
8343
|
+
signal: AbortSignal.timeout(
|
|
8344
|
+
params.timeoutMs ?? DEFAULT_EXPORT_TIMEOUT_MS
|
|
8345
|
+
)
|
|
8346
|
+
});
|
|
8347
|
+
} catch (err) {
|
|
8348
|
+
throw new Error(
|
|
8349
|
+
`Could not reach the sandbox at ${SIDECAR_URL} (${err instanceof Error ? err.message : String(err)}). Rendering a replay needs the dev box that recorded it \u2014 this command only works inside a Remy sandbox.`
|
|
8350
|
+
);
|
|
8351
|
+
}
|
|
8352
|
+
const payload = await res.json().catch(() => ({}));
|
|
8353
|
+
if (!res.ok) {
|
|
8354
|
+
throw new Error(
|
|
8355
|
+
typeof payload.error === "string" ? payload.error : `The sandbox refused the export (HTTP ${res.status}).`
|
|
8356
|
+
);
|
|
8357
|
+
}
|
|
8358
|
+
const status2 = payload.export;
|
|
8359
|
+
if (!status2) {
|
|
8360
|
+
throw new Error(
|
|
8361
|
+
"The sandbox accepted the export but reported no result. Check the box's tunnel logs."
|
|
8362
|
+
);
|
|
8363
|
+
}
|
|
8364
|
+
return status2;
|
|
8365
|
+
}
|
|
8366
|
+
|
|
8367
|
+
// src/commands/qaRecordings.ts
|
|
8368
|
+
var qaRecordingsSpecs = {
|
|
8369
|
+
"qa-recordings ls": {
|
|
8370
|
+
usage: "Usage: remy-admin qa-recordings ls [--session <id>] [--cursor <cursor>] [--limit <n>]",
|
|
8371
|
+
flags: {
|
|
8372
|
+
session: { type: "string" },
|
|
8373
|
+
cursor: { type: "string" },
|
|
8374
|
+
limit: { type: "number", min: 1 }
|
|
8375
|
+
}
|
|
8376
|
+
},
|
|
8377
|
+
"qa-recordings export": {
|
|
8378
|
+
usage: "Usage: remy-admin qa-recordings export --session <id> --from <epochMs> --to <epochMs> [--store <name>] [--private]",
|
|
8379
|
+
flags: {
|
|
8380
|
+
session: { type: "string" },
|
|
8381
|
+
from: { type: "number" },
|
|
8382
|
+
to: { type: "number" },
|
|
8383
|
+
store: { type: "string" },
|
|
8384
|
+
private: { type: "boolean" }
|
|
8385
|
+
}
|
|
8386
|
+
}
|
|
8387
|
+
};
|
|
8388
|
+
async function qaRecordingsLs(ctx, a) {
|
|
8389
|
+
out(
|
|
8390
|
+
await ls2(ctx, {
|
|
8391
|
+
sessionId: a.str("session"),
|
|
8392
|
+
cursor: a.str("cursor"),
|
|
8393
|
+
limit: a.num("limit")
|
|
8394
|
+
})
|
|
8395
|
+
);
|
|
8396
|
+
}
|
|
8397
|
+
async function qaRecordingsExport(ctx, a) {
|
|
8398
|
+
const sessionId = a.str("session");
|
|
8399
|
+
const from = a.num("from");
|
|
8400
|
+
const to = a.num("to");
|
|
8401
|
+
if (!sessionId) {
|
|
8402
|
+
fatal("--session is required (see `remy-admin qa-recordings ls`).");
|
|
8403
|
+
}
|
|
8404
|
+
if (from === void 0 || to === void 0) {
|
|
8405
|
+
fatal(
|
|
8406
|
+
"--from and --to are required (epoch milliseconds). A QA run reports both on its result."
|
|
8407
|
+
);
|
|
8408
|
+
}
|
|
8409
|
+
const result = await exportVideo(ctx, {
|
|
8410
|
+
sessionId,
|
|
8411
|
+
from,
|
|
8412
|
+
to,
|
|
8413
|
+
store: a.str("store"),
|
|
8414
|
+
access: a.bool("private") ? "private" : void 0
|
|
8415
|
+
});
|
|
8416
|
+
if (result.status !== "completed") {
|
|
8417
|
+
fatal(
|
|
8418
|
+
result.errorCode === "FFMPEG_UNAVAILABLE" ? "This sandbox has no ffmpeg \u2014 video export needs a box on the current devbox image." : result.error ?? `Export ${result.status}.`
|
|
8419
|
+
);
|
|
8420
|
+
}
|
|
8421
|
+
out(result);
|
|
8422
|
+
}
|
|
8423
|
+
var qaRecordingsHandlers = {
|
|
8424
|
+
"qa-recordings ls": qaRecordingsLs,
|
|
8425
|
+
"qa-recordings export": qaRecordingsExport
|
|
8426
|
+
};
|
|
8427
|
+
var qaRecordingsHelp = `remy-admin qa-recordings \u2014 Browser-QA session replays.
|
|
8428
|
+
|
|
8429
|
+
Every automated browser test records what the page did, as rrweb (a DOM
|
|
8430
|
+
recording, not video). The chunks are ordinary private files in the
|
|
8431
|
+
'qa-recordings' store, so 'remy-admin files' works on them directly: sign one
|
|
8432
|
+
for a link, download one, or copy one into a public store to embed it.
|
|
8433
|
+
|
|
8434
|
+
'export' turns a replay into an mp4 \u2014 the shareable artifact, for a changelog
|
|
8435
|
+
entry, a release note or a bug report. It renders on the dev box (real browser,
|
|
8436
|
+
real ffmpeg, no AI-generated frames), so it only works inside a sandbox, it
|
|
8437
|
+
blocks for the length of the clip, and it won't start while a video export is
|
|
8438
|
+
already running.
|
|
8439
|
+
|
|
8440
|
+
Subcommands:
|
|
8441
|
+
ls Recording sessions in this app, grouped into runs
|
|
8442
|
+
export Render a window of a session to an mp4 and print where it landed
|
|
8443
|
+
|
|
8444
|
+
Usage:
|
|
8445
|
+
remy-admin qa-recordings ls [--session <id>] [--limit <n>]
|
|
8446
|
+
remy-admin qa-recordings export --session <id> --from <epochMs> --to <epochMs> [--store <name>] [--private]
|
|
8447
|
+
|
|
8448
|
+
Options:
|
|
8449
|
+
--session Recording session id (32 hex characters)
|
|
8450
|
+
--from/--to The window to render, as epoch milliseconds. A QA run prints the
|
|
8451
|
+
session and both bounds on its result.
|
|
8452
|
+
--store Destination store for the mp4 (default: assets)
|
|
8453
|
+
--private Write the mp4 privately instead of publicly. No URL is returned;
|
|
8454
|
+
use 'files sign' to make one.
|
|
8455
|
+
|
|
8456
|
+
Examples:
|
|
8457
|
+
# What was recorded recently
|
|
8458
|
+
remy-admin qa-recordings ls --limit 200
|
|
8459
|
+
|
|
8460
|
+
# Render a run and get a public URL to embed
|
|
8461
|
+
remy-admin qa-recordings export --session 9f2c\u2026 --from 1758000000000 --to 1758000042000
|
|
8462
|
+
|
|
8463
|
+
# The raw rrweb chunks for a session (download, inspect, or rehost)
|
|
8464
|
+
remy-admin files ls --private --store qa-recordings --prefix 9f2c\u2026/`;
|
|
8465
|
+
|
|
8258
8466
|
// src/commands/index.ts
|
|
8259
8467
|
var SPECS = {
|
|
8260
8468
|
...requestsSpecs,
|
|
@@ -8283,7 +8491,8 @@ var SPECS = {
|
|
|
8283
8491
|
...settingsSpecs,
|
|
8284
8492
|
...emailSpecs,
|
|
8285
8493
|
...cronSpecs,
|
|
8286
|
-
...infraSpecs
|
|
8494
|
+
...infraSpecs,
|
|
8495
|
+
...qaRecordingsSpecs
|
|
8287
8496
|
};
|
|
8288
8497
|
var HANDLERS = {
|
|
8289
8498
|
...requestsHandlers,
|
|
@@ -8312,7 +8521,8 @@ var HANDLERS = {
|
|
|
8312
8521
|
...settingsHandlers,
|
|
8313
8522
|
...emailHandlers,
|
|
8314
8523
|
...cronHandlers,
|
|
8315
|
-
...infraHandlers
|
|
8524
|
+
...infraHandlers,
|
|
8525
|
+
...qaRecordingsHandlers
|
|
8316
8526
|
};
|
|
8317
8527
|
var GROUP_HELP = {
|
|
8318
8528
|
requests: requestsHelp,
|
|
@@ -8337,7 +8547,8 @@ var GROUP_HELP = {
|
|
|
8337
8547
|
settings: settingsHelp,
|
|
8338
8548
|
email: emailHelp,
|
|
8339
8549
|
cron: cronHelp,
|
|
8340
|
-
infra: infraHelp
|
|
8550
|
+
infra: infraHelp,
|
|
8551
|
+
"qa-recordings": qaRecordingsHelp
|
|
8341
8552
|
};
|
|
8342
8553
|
|
|
8343
8554
|
// src/ctx.ts
|
|
@@ -8397,29 +8608,30 @@ var HELP = `remy-admin \u2014 Manage your production Remy app.
|
|
|
8397
8608
|
Usage: remy-admin <command> <subcommand> [options]
|
|
8398
8609
|
|
|
8399
8610
|
Commands:
|
|
8400
|
-
requests
|
|
8401
|
-
crashes
|
|
8402
|
-
analytics
|
|
8403
|
-
releases
|
|
8404
|
-
diagnostics
|
|
8405
|
-
domains
|
|
8406
|
-
users
|
|
8407
|
-
db
|
|
8408
|
-
secrets
|
|
8409
|
-
methods
|
|
8410
|
-
data
|
|
8411
|
-
issues
|
|
8412
|
-
prerender
|
|
8413
|
-
files
|
|
8414
|
-
datasources
|
|
8415
|
-
voice
|
|
8416
|
-
agent
|
|
8417
|
-
events
|
|
8418
|
-
jewels
|
|
8419
|
-
settings
|
|
8420
|
-
email
|
|
8421
|
-
cron
|
|
8422
|
-
infra
|
|
8611
|
+
requests View request logs and metrics
|
|
8612
|
+
crashes View frontend (browser) crash groups and events
|
|
8613
|
+
analytics View traffic, top-N, geo, and AI-referral insights
|
|
8614
|
+
releases View and monitor releases
|
|
8615
|
+
diagnostics View the post-deploy Lighthouse audit (scores, issues, raw report)
|
|
8616
|
+
domains Manage custom subdomain
|
|
8617
|
+
users Manage app users and roles
|
|
8618
|
+
db Query the production database
|
|
8619
|
+
secrets Manage app secrets (env vars)
|
|
8620
|
+
methods List and invoke methods
|
|
8621
|
+
data Sync databases between dev and live (lift-from-dev / lift-from-live)
|
|
8622
|
+
issues File and manage issues, in this app or another app in the workspace
|
|
8623
|
+
prerender Manage + verify prerendered snapshots served to bots/crawlers
|
|
8624
|
+
files Store, retrieve, share (signed links) + manage files in the app's stores
|
|
8625
|
+
datasources Build and query searchable document corpora (RAG)
|
|
8626
|
+
voice Phone numbers, voice call log + transcripts, voice policy settings
|
|
8627
|
+
agent Agent (chat) conversation log + transcripts
|
|
8628
|
+
events Realtime events \u2014 live tail, test publish, channel activity
|
|
8629
|
+
jewels Monitor jewel shadowing; review + approve the proposal queue
|
|
8630
|
+
settings App settings \u2014 signup allowlist, test accounts, embed origins, toggles
|
|
8631
|
+
email Email \u2014 delivery log, blast stats, unsubscribes, inbox, custom domains
|
|
8632
|
+
cron Scheduled jobs \u2014 schedules, run history, manual trigger
|
|
8633
|
+
infra Dedicated infrastructure \u2014 provision, hibernate, resize + destroy retrieval capacity
|
|
8634
|
+
qa-recordings Browser-QA session replays \u2014 list them, render one as an mp4
|
|
8423
8635
|
|
|
8424
8636
|
Run 'remy-admin <command> --help' for details on each command.
|
|
8425
8637
|
|
|
@@ -8452,7 +8664,8 @@ var GROUPS = [
|
|
|
8452
8664
|
"settings",
|
|
8453
8665
|
"email",
|
|
8454
8666
|
"cron",
|
|
8455
|
-
"infra"
|
|
8667
|
+
"infra",
|
|
8668
|
+
"qa-recordings"
|
|
8456
8669
|
];
|
|
8457
8670
|
var KEYS = new Set(Object.keys(SPECS));
|
|
8458
8671
|
function resolveRoute(argv) {
|
|
@@ -8529,9 +8742,20 @@ async function main() {
|
|
|
8529
8742
|
fatal("MINDSTUDIO_API_KEY environment variable is not set");
|
|
8530
8743
|
}
|
|
8531
8744
|
const appIdOverride = a.str("app");
|
|
8745
|
+
let originAppId;
|
|
8746
|
+
if (appIdOverride) {
|
|
8747
|
+
try {
|
|
8748
|
+
const workspaceAppId = loadWorkspaceAppId();
|
|
8749
|
+
if (workspaceAppId !== appIdOverride) {
|
|
8750
|
+
originAppId = workspaceAppId;
|
|
8751
|
+
}
|
|
8752
|
+
} catch {
|
|
8753
|
+
}
|
|
8754
|
+
}
|
|
8532
8755
|
const ctx = {
|
|
8533
8756
|
apiKey,
|
|
8534
8757
|
appId: appIdOverride ?? loadWorkspaceAppId(),
|
|
8758
|
+
originAppId,
|
|
8535
8759
|
baseUrl: process.env["API_BASE_URL"] || DEFAULT_BASE_URL
|
|
8536
8760
|
};
|
|
8537
8761
|
return HANDLERS[route.key](ctx, a);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@madewithremy/admin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.26",
|
|
4
4
|
"description": "Admin SDK + CLI for managing production Remy apps",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -21,9 +21,11 @@
|
|
|
21
21
|
"scripts": {
|
|
22
22
|
"build": "tsup",
|
|
23
23
|
"dev": "tsup --watch",
|
|
24
|
-
"typecheck": "tsc --noEmit",
|
|
24
|
+
"typecheck": "npm run tsc -- --noEmit",
|
|
25
25
|
"lint:fix": "prettier --write ./src",
|
|
26
|
-
"prepublishOnly": "npm run build"
|
|
26
|
+
"prepublishOnly": "npm run build",
|
|
27
|
+
"tsc": "node node_modules/@typescript/native/bin/tsc",
|
|
28
|
+
"//tsc": "The native TypeScript 7 compiler by explicit path. It (typescript@7 under the @typescript/native alias) and the JS compiler API behind the `typescript` alias — which tsup's dts step needs — both declare a `tsc` bin, so node_modules/.bin/tsc is whichever npm linked last."
|
|
27
29
|
},
|
|
28
30
|
"engines": {
|
|
29
31
|
"node": ">=22.0.0"
|
|
@@ -53,9 +55,10 @@
|
|
|
53
55
|
},
|
|
54
56
|
"devDependencies": {
|
|
55
57
|
"@types/node": "^25.3.2",
|
|
58
|
+
"@typescript/native": "npm:typescript@^7.0.2",
|
|
56
59
|
"prettier": "^3.8.1",
|
|
57
60
|
"tsup": "^8",
|
|
58
61
|
"tsx": "^4",
|
|
59
|
-
"typescript": "
|
|
62
|
+
"typescript": "npm:@typescript/typescript6@^6.0.2"
|
|
60
63
|
}
|
|
61
64
|
}
|