pi-bridge 0.3.3 → 0.3.5
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/bin/pii.js +3 -59
- package/package.json +1 -1
- package/src/local/find-pi.js +127 -0
- package/src/local/fs-patch.js +38 -18
package/bin/pii.js
CHANGED
|
@@ -1,77 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
const { spawnSync
|
|
4
|
+
const { spawnSync } = require('child_process');
|
|
5
5
|
const path = require('path');
|
|
6
|
-
const fs = require('fs');
|
|
7
|
-
const os = require('os');
|
|
8
6
|
|
|
9
7
|
const pkg = require('../package.json');
|
|
10
8
|
const preload = path.join(__dirname, '..', 'src', 'local', 'preload.js');
|
|
9
|
+
const { findPi } = require('../src/local/find-pi.js');
|
|
11
10
|
|
|
12
11
|
if (process.argv.includes('--version') || process.argv.includes('-v') || process.argv.includes('--v')) {
|
|
13
12
|
process.stdout.write('pi-bridge/' + pkg.version + '\n');
|
|
14
13
|
process.exit(0);
|
|
15
14
|
}
|
|
16
15
|
|
|
17
|
-
// List every `pi` on the PATH that pii inherited from its launching shell.
|
|
18
|
-
// We resolve against that PATH (not a fresh `-NoProfile` shell) so that bundled
|
|
19
|
-
// installs added to PATH via the user's profile are actually visible.
|
|
20
|
-
function locateOnPath(cmd) {
|
|
21
|
-
const finder = os.platform() === 'win32' ? 'where.exe' : 'which';
|
|
22
|
-
try {
|
|
23
|
-
const out = execFileSync(finder, [cmd], { encoding: 'utf8' });
|
|
24
|
-
return out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
25
|
-
} catch (_) {
|
|
26
|
-
return [];
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function findPi() {
|
|
31
|
-
// 1. Standard npm global: pii and pi share the same node_modules prefix.
|
|
32
|
-
// Prefer the current @earendil-works scope; keep @mariozechner as a legacy
|
|
33
|
-
// fallback for machines that installed pi before it moved off that scope.
|
|
34
|
-
const sharedCandidates = [
|
|
35
|
-
path.resolve(__dirname, '..', '..', '@earendil-works', 'pi-coding-agent', 'dist', 'cli.js'),
|
|
36
|
-
path.resolve(__dirname, '..', '..', '@mariozechner', 'pi-coding-agent', 'dist', 'cli.js'),
|
|
37
|
-
];
|
|
38
|
-
for (const cli of sharedCandidates) {
|
|
39
|
-
if (fs.existsSync(cli)) return { cli, nodeExe: process.execPath };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// 2. Resolve `pi` from PATH and follow it to its real JS entrypoint. The
|
|
43
|
-
// pi.dev installer (curl https://pi.dev/install.sh | sh) drops a bundled
|
|
44
|
-
// Node plus a bare `bin/pi` symlink that points straight at the package's
|
|
45
|
-
// cli.js — often outside any npm prefix — so we realpath the symlink rather
|
|
46
|
-
// than guessing filenames. Also covers npm shims and dev clones (which
|
|
47
|
-
// expose pi as a symlink to cli.js too), and a bundled node next to pi is
|
|
48
|
-
// preferred for version-matched execution.
|
|
49
|
-
const exe = os.platform() === 'win32' ? '.exe' : '';
|
|
50
|
-
for (const cmd of locateOnPath('pi')) {
|
|
51
|
-
let real;
|
|
52
|
-
try { real = fs.realpathSync(cmd); } catch (_) { real = cmd; }
|
|
53
|
-
|
|
54
|
-
let cli = null;
|
|
55
|
-
if (/\.[cm]?js$/i.test(real) && fs.existsSync(real)) {
|
|
56
|
-
cli = real; // symlink/shim -> cli.js
|
|
57
|
-
} else if (fs.existsSync(path.join(path.dirname(cmd), 'pi.js'))) {
|
|
58
|
-
cli = path.join(path.dirname(cmd), 'pi.js'); // bundled wrapper + sibling
|
|
59
|
-
} else if (!/\.(cmd|ps1|bat|exe)$/i.test(real)) {
|
|
60
|
-
cli = real; // shebang JS with no extension
|
|
61
|
-
}
|
|
62
|
-
if (!cli) continue;
|
|
63
|
-
|
|
64
|
-
const localNode = path.join(path.dirname(cmd), 'node' + exe);
|
|
65
|
-
const nodeExe = fs.existsSync(localNode) ? localNode : process.execPath;
|
|
66
|
-
return { cli, nodeExe };
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
16
|
let pi;
|
|
73
17
|
try {
|
|
74
|
-
pi = findPi();
|
|
18
|
+
pi = findPi({ bridgeBinDir: __dirname });
|
|
75
19
|
} catch (_) {}
|
|
76
20
|
|
|
77
21
|
if (!pi) {
|
package/package.json
CHANGED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Locates the pi CLI entrypoint (a JS file) and the node binary to run it with.
|
|
4
|
+
// Used by bin/pii.js; kept separate so it can be unit-tested.
|
|
5
|
+
|
|
6
|
+
const { execFileSync } = require('child_process');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
|
|
10
|
+
// Prefer the current @earendil-works scope; keep @mariozechner as a legacy
|
|
11
|
+
// fallback for machines that installed pi before it moved off that scope.
|
|
12
|
+
const PI_PACKAGES = [
|
|
13
|
+
path.join('@earendil-works', 'pi-coding-agent'),
|
|
14
|
+
path.join('@mariozechner', 'pi-coding-agent'),
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
// List every `pi` on the PATH that pii inherited from its launching shell.
|
|
18
|
+
// We resolve against that PATH (not a fresh `-NoProfile` shell) so that bundled
|
|
19
|
+
// installs added to PATH via the user's profile are actually visible.
|
|
20
|
+
function locateOnPath(cmd, platform = process.platform) {
|
|
21
|
+
const finder = platform === 'win32' ? 'where.exe' : 'which';
|
|
22
|
+
try {
|
|
23
|
+
const out = execFileSync(finder, [cmd], { encoding: 'utf8' });
|
|
24
|
+
return out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
25
|
+
} catch (_) {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Resolve a pi package's CLI from its package.json `bin` field, so we follow
|
|
31
|
+
// whatever entrypoint the installed version declares (dist/cli.js in older
|
|
32
|
+
// releases, dist/bundle/cli.js since 1.5).
|
|
33
|
+
function cliFromPackage(pkgDir) {
|
|
34
|
+
let rel = null;
|
|
35
|
+
try {
|
|
36
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'));
|
|
37
|
+
rel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin && pkg.bin.pi;
|
|
38
|
+
} catch (_) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const candidates = [rel, 'dist/bundle/cli.js', 'dist/cli.js'].filter(Boolean);
|
|
42
|
+
for (const c of candidates) {
|
|
43
|
+
const cli = path.resolve(pkgDir, c);
|
|
44
|
+
if (fs.existsSync(cli)) return cli;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function cliFromNodeModules(nodeModulesDir) {
|
|
50
|
+
for (const name of PI_PACKAGES) {
|
|
51
|
+
const cli = cliFromPackage(path.join(nodeModulesDir, name));
|
|
52
|
+
if (cli) return cli;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// True if the file is something node can execute: either it has a node
|
|
58
|
+
// shebang or no shebang at all. Rejects npm's extensionless `sh` shims
|
|
59
|
+
// (`#!/bin/sh` + `basedir=$(dirname ...)`), which node would choke on.
|
|
60
|
+
function looksLikeNodeScript(file) {
|
|
61
|
+
let head;
|
|
62
|
+
try {
|
|
63
|
+
const fd = fs.openSync(file, 'r');
|
|
64
|
+
try {
|
|
65
|
+
const buf = Buffer.alloc(256);
|
|
66
|
+
const n = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
67
|
+
head = buf.toString('utf8', 0, n);
|
|
68
|
+
} finally {
|
|
69
|
+
fs.closeSync(fd);
|
|
70
|
+
}
|
|
71
|
+
} catch (_) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
if (!head.startsWith('#!')) return true;
|
|
75
|
+
return /\bnode\b/.test(head.split(/\r?\n/, 1)[0]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function findPi({
|
|
79
|
+
bridgeBinDir,
|
|
80
|
+
locate = locateOnPath,
|
|
81
|
+
platform = process.platform,
|
|
82
|
+
execPath = process.execPath,
|
|
83
|
+
} = {}) {
|
|
84
|
+
// 1. Standard npm global: pii and pi share the same node_modules dir
|
|
85
|
+
// (<node_modules>/pi-bridge/bin -> <node_modules>).
|
|
86
|
+
if (bridgeBinDir) {
|
|
87
|
+
const cli = cliFromNodeModules(path.resolve(bridgeBinDir, '..', '..'));
|
|
88
|
+
if (cli) return { cli, nodeExe: execPath };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 2. Resolve `pi` from PATH. Covers npm globals when pi-bridge itself is
|
|
92
|
+
// linked from a local checkout, the pi.dev installer (bundled Node plus a
|
|
93
|
+
// bare `bin/pi` symlink straight to cli.js, often outside any npm prefix)
|
|
94
|
+
// and dev clones.
|
|
95
|
+
const exe = platform === 'win32' ? '.exe' : '';
|
|
96
|
+
for (const cmd of locate('pi', platform)) {
|
|
97
|
+
const dir = path.dirname(cmd);
|
|
98
|
+
const localNode = path.join(dir, 'node' + exe);
|
|
99
|
+
const nodeExe = fs.existsSync(localNode) ? localNode : execPath;
|
|
100
|
+
|
|
101
|
+
// 2a. npm prefix layouts next to the launcher:
|
|
102
|
+
// Windows: <prefix>/pi(.cmd) + <prefix>/node_modules/...
|
|
103
|
+
// POSIX: <prefix>/bin/pi + <prefix>/lib/node_modules/...
|
|
104
|
+
const npmCli =
|
|
105
|
+
cliFromNodeModules(path.join(dir, 'node_modules')) ||
|
|
106
|
+
cliFromNodeModules(path.join(dir, '..', 'lib', 'node_modules'));
|
|
107
|
+
if (npmCli) return { cli: npmCli, nodeExe };
|
|
108
|
+
|
|
109
|
+
// 2b. Follow the launcher to its real JS entrypoint.
|
|
110
|
+
let real;
|
|
111
|
+
try { real = fs.realpathSync(cmd); } catch (_) { real = cmd; }
|
|
112
|
+
|
|
113
|
+
let cli = null;
|
|
114
|
+
if (/\.[cm]?js$/i.test(real) && fs.existsSync(real)) {
|
|
115
|
+
cli = real; // symlink/shim -> cli.js
|
|
116
|
+
} else if (fs.existsSync(path.join(dir, 'pi.js'))) {
|
|
117
|
+
cli = path.join(dir, 'pi.js'); // bundled wrapper + sibling
|
|
118
|
+
} else if (!/\.(cmd|ps1|bat|exe)$/i.test(real) && looksLikeNodeScript(real)) {
|
|
119
|
+
cli = real; // shebang JS with no extension
|
|
120
|
+
}
|
|
121
|
+
if (cli) return { cli, nodeExe };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { findPi, cliFromPackage, looksLikeNodeScript, locateOnPath };
|
package/src/local/fs-patch.js
CHANGED
|
@@ -4,6 +4,7 @@ const fs = require('fs');
|
|
|
4
4
|
const fsp = require('fs/promises');
|
|
5
5
|
const http = require('http');
|
|
6
6
|
const path = require('path');
|
|
7
|
+
const { fileURLToPath } = require('url');
|
|
7
8
|
const { isFakePath, toRemotePath } = require('./path-mapper');
|
|
8
9
|
const { httpGet, httpPost } = require('./http-client');
|
|
9
10
|
|
|
@@ -127,9 +128,28 @@ function patchFs(fakeRoot, port, token) {
|
|
|
127
128
|
cp: fs.promises.cp ? fs.promises.cp.bind(fs.promises) : undefined,
|
|
128
129
|
};
|
|
129
130
|
|
|
130
|
-
// Resolve any path (relative or absolute) to absolute before checking fake root
|
|
131
|
+
// Resolve any path (relative or absolute) to absolute before checking fake root.
|
|
132
|
+
// file: URLs are accepted too: jiti's bundled ESM resolver probes package
|
|
133
|
+
// mains with fs.statSync(new URL(...)), which must reach the remote.
|
|
134
|
+
// Buffer paths deliberately pass through: Node 20's JS rmSync walks children
|
|
135
|
+
// as Buffers via the public fs.*, and routing those would make removeFakeDir
|
|
136
|
+
// list and delete files on the remote.
|
|
131
137
|
function abs(p) {
|
|
132
|
-
|
|
138
|
+
if (typeof p === 'string') return path.resolve(p);
|
|
139
|
+
if (p instanceof URL) return p.protocol === 'file:' ? path.resolve(fileURLToPath(p)) : null;
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Shared by statSync/lstatSync. Honours { throwIfNoEntry: false }, which the
|
|
144
|
+
// ESM resolver relies on to probe candidate files without try/catch.
|
|
145
|
+
function statRemoteSync(s, opts) {
|
|
146
|
+
try {
|
|
147
|
+
return makeStats(JSON.parse(httpGet(port, token, '/stat?path=' + encodeURIComponent(rpath(s)))));
|
|
148
|
+
} catch (e) {
|
|
149
|
+
const missing = e && e.message && (e.message.includes('ENOENT') || e.message.includes('403'));
|
|
150
|
+
if (missing && opts && opts.throwIfNoEntry === false) return undefined;
|
|
151
|
+
rethrowRemote(e, s);
|
|
152
|
+
}
|
|
133
153
|
}
|
|
134
154
|
|
|
135
155
|
function rpath(fakePath) {
|
|
@@ -215,6 +235,9 @@ function patchFs(fakeRoot, port, token) {
|
|
|
215
235
|
|
|
216
236
|
fs.accessSync = function patchedAccessSync(p, mode) {
|
|
217
237
|
const s = abs(p);
|
|
238
|
+
// Same Windows bash fake as existsSync: newer pi probes the shell with
|
|
239
|
+
// accessSync, which would otherwise hit the local disk and fail.
|
|
240
|
+
if (process.platform === 'win32' && isBashPath(s)) return;
|
|
218
241
|
if (!s || !isFakePath(s, fakeRoot)) return orig.accessSync(p, mode);
|
|
219
242
|
try {
|
|
220
243
|
const exists = httpGet(port, token, '/exists?path=' + encodeURIComponent(rpath(s))).trim() === '1';
|
|
@@ -229,17 +252,13 @@ function patchFs(fakeRoot, port, token) {
|
|
|
229
252
|
fs.statSync = function patchedStatSync(p, opts) {
|
|
230
253
|
const s = abs(p);
|
|
231
254
|
if (!s || !isFakePath(s, fakeRoot)) return orig.statSync(p, opts);
|
|
232
|
-
|
|
233
|
-
return makeStats(JSON.parse(httpGet(port, token, '/stat?path=' + encodeURIComponent(rpath(s)))));
|
|
234
|
-
} catch (e) { rethrowRemote(e, s); }
|
|
255
|
+
return statRemoteSync(s, opts);
|
|
235
256
|
};
|
|
236
257
|
|
|
237
258
|
fs.lstatSync = function patchedLstatSync(p, opts) {
|
|
238
259
|
const s = abs(p);
|
|
239
260
|
if (!s || !isFakePath(s, fakeRoot)) return orig.lstatSync(p, opts);
|
|
240
|
-
|
|
241
|
-
return makeStats(JSON.parse(httpGet(port, token, '/stat?path=' + encodeURIComponent(rpath(s)))));
|
|
242
|
-
} catch (e) { rethrowRemote(e, s); }
|
|
261
|
+
return statRemoteSync(s, opts);
|
|
243
262
|
};
|
|
244
263
|
|
|
245
264
|
fs.writeFileSync = function patchedWriteFileSync(p, data, opts) {
|
|
@@ -359,7 +378,7 @@ function patchFs(fakeRoot, port, token) {
|
|
|
359
378
|
|
|
360
379
|
// createReadStream: read entire file from remote, pipe as stream
|
|
361
380
|
fs.createReadStream = function patchedCreateReadStream(p, opts) {
|
|
362
|
-
const s =
|
|
381
|
+
const s = abs(p);
|
|
363
382
|
if (!s || !isFakePath(s, fakeRoot)) return orig.createReadStream(p, opts);
|
|
364
383
|
const { Readable } = require('stream');
|
|
365
384
|
const stream = new Readable({ read() {} });
|
|
@@ -372,7 +391,7 @@ function patchFs(fakeRoot, port, token) {
|
|
|
372
391
|
|
|
373
392
|
// createWriteStream: buffer writes, POST on finish
|
|
374
393
|
fs.createWriteStream = function patchedCreateWriteStream(p, opts) {
|
|
375
|
-
const s =
|
|
394
|
+
const s = abs(p);
|
|
376
395
|
if (!s || !isFakePath(s, fakeRoot)) return orig.createWriteStream(p, opts);
|
|
377
396
|
const { Writable } = require('stream');
|
|
378
397
|
const chunks = [];
|
|
@@ -390,6 +409,7 @@ function patchFs(fakeRoot, port, token) {
|
|
|
390
409
|
|
|
391
410
|
fs.promises.access = async function patchedAccess(p, mode) {
|
|
392
411
|
const s = abs(p);
|
|
412
|
+
if (process.platform === 'win32' && isBashPath(s)) return;
|
|
393
413
|
if (!s || !isFakePath(s, fakeRoot)) return origP.access(p, mode);
|
|
394
414
|
try {
|
|
395
415
|
const exists = (await httpGetAsync(port, token, '/exists?path=' + encodeURIComponent(rpath(s)))).trim() === '1';
|
|
@@ -402,7 +422,7 @@ function patchFs(fakeRoot, port, token) {
|
|
|
402
422
|
};
|
|
403
423
|
|
|
404
424
|
fs.promises.readFile = async function patchedReadFile(p, opts) {
|
|
405
|
-
const s =
|
|
425
|
+
const s = abs(p);
|
|
406
426
|
if (!s || !isFakePath(s, fakeRoot)) return origP.readFile(p, opts);
|
|
407
427
|
try {
|
|
408
428
|
const body = await httpGetAsync(port, token, '/read?path=' + encodeURIComponent(rpath(s)));
|
|
@@ -416,7 +436,7 @@ function patchFs(fakeRoot, port, token) {
|
|
|
416
436
|
};
|
|
417
437
|
|
|
418
438
|
fs.promises.readdir = async function patchedReaddir(p, opts) {
|
|
419
|
-
const s =
|
|
439
|
+
const s = abs(p) || p.toString();
|
|
420
440
|
if (!isFakePath(s, fakeRoot)) return origP.readdir(p, opts);
|
|
421
441
|
try {
|
|
422
442
|
const withFileTypes = opts && opts.withFileTypes;
|
|
@@ -465,7 +485,7 @@ function patchFs(fakeRoot, port, token) {
|
|
|
465
485
|
};
|
|
466
486
|
|
|
467
487
|
fs.promises.writeFile = async function patchedWriteFile(p, data, opts) {
|
|
468
|
-
const s =
|
|
488
|
+
const s = abs(p);
|
|
469
489
|
if (!s || !isFakePath(s, fakeRoot)) return origP.writeFile(p, data, opts);
|
|
470
490
|
const content = Buffer.isBuffer(data) ? data.toString('utf8') : String(data);
|
|
471
491
|
await new Promise(function(resolve, reject) {
|
|
@@ -517,7 +537,7 @@ function patchFs(fakeRoot, port, token) {
|
|
|
517
537
|
};
|
|
518
538
|
|
|
519
539
|
fs.promises.appendFile = async function patchedAppendFile(p, data, opts) {
|
|
520
|
-
const s =
|
|
540
|
+
const s = abs(p);
|
|
521
541
|
if (!s || !isFakePath(s, fakeRoot)) return origP.appendFile(p, data, opts);
|
|
522
542
|
const content = Buffer.isBuffer(data) ? data.toString('utf8') : String(data);
|
|
523
543
|
const buf = Buffer.from(content, 'utf8');
|
|
@@ -614,7 +634,7 @@ function patchFs(fakeRoot, port, token) {
|
|
|
614
634
|
|
|
615
635
|
// fs.promises.open: return a minimal FileHandle for reading
|
|
616
636
|
fs.promises.open = async function patchedOpen(p, flags, mode) {
|
|
617
|
-
const s =
|
|
637
|
+
const s = abs(p);
|
|
618
638
|
if (!s || !isFakePath(s, fakeRoot)) return origP.open(p, flags, mode);
|
|
619
639
|
// Fetch the file content eagerly; return a minimal FileHandle-like object
|
|
620
640
|
const content = await httpGetAsync(port, token, '/read?path=' + encodeURIComponent(rpath(s)));
|
|
@@ -646,7 +666,7 @@ function patchFs(fakeRoot, port, token) {
|
|
|
646
666
|
const watchFilePollers = new Map();
|
|
647
667
|
|
|
648
668
|
fs.watch = function patchedWatch(filename, opts, listener) {
|
|
649
|
-
const s = abs(
|
|
669
|
+
const s = abs(filename);
|
|
650
670
|
if (!s || !isFakePath(s, fakeRoot)) return origWatch(filename, opts, listener);
|
|
651
671
|
|
|
652
672
|
if (typeof opts === 'function') { listener = opts; opts = {}; }
|
|
@@ -711,7 +731,7 @@ function patchFs(fakeRoot, port, token) {
|
|
|
711
731
|
};
|
|
712
732
|
|
|
713
733
|
fs.watchFile = function patchedWatchFile(filename, opts, listener) {
|
|
714
|
-
const s = abs(
|
|
734
|
+
const s = abs(filename);
|
|
715
735
|
if (!s || !isFakePath(s, fakeRoot)) {
|
|
716
736
|
return origWatchFile ? origWatchFile(filename, opts, listener) : undefined;
|
|
717
737
|
}
|
|
@@ -741,7 +761,7 @@ function patchFs(fakeRoot, port, token) {
|
|
|
741
761
|
};
|
|
742
762
|
|
|
743
763
|
fs.unwatchFile = function patchedUnwatchFile(filename, listener) {
|
|
744
|
-
const s = abs(
|
|
764
|
+
const s = abs(filename);
|
|
745
765
|
if (!s || !isFakePath(s, fakeRoot)) {
|
|
746
766
|
return origUnwatchFile ? origUnwatchFile(filename, listener) : undefined;
|
|
747
767
|
}
|