@bitpub/cli 2.0.2 → 2.0.4
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/bitpub.js +1 -0
- package/package.json +2 -2
- package/src/api.js +97 -102
- package/src/commands/browser.js +89 -15
- package/src/commands/group.js +333 -0
- package/src/commands/setup.js +33 -9
- package/src/commands/welcome.js +60 -7
- package/src/config.js +89 -7
- package/static/assets/BITPUB-dark.png +0 -0
- package/static/assets/tollbit-icon.png +0 -0
- package/static/console.html +604 -129
- package/static/welcome.html +574 -0
package/bin/bitpub.js
CHANGED
|
@@ -39,6 +39,7 @@ require('../src/commands/delete')(program);
|
|
|
39
39
|
|
|
40
40
|
// ── Setup (rare, agent-invoked) ─────────────────────────────────────────────
|
|
41
41
|
require('../src/commands/setup')(program);
|
|
42
|
+
require('../src/commands/group')(program);
|
|
42
43
|
require('../src/commands/alias')(program);
|
|
43
44
|
require('../src/commands/seed')(program);
|
|
44
45
|
require('../src/commands/browser')(program);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitpub/cli",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.4",
|
|
4
4
|
"description": "BitPub CLI — local-first shared memory for AI agents. Six daily verbs (save/load/list/find/sync/delete), zero-config private namespace, encrypted client-side.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"bitpub": "./bin/bitpub.js"
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
16
|
"test": "jest --testPathPattern=tests/ --forceExit",
|
|
17
|
-
"prepack": "mkdir -p static && cp ../backend/static/console.html static/console.html",
|
|
17
|
+
"prepack": "mkdir -p static/assets && cp ../backend/static/console.html static/console.html && cp ../backend/static/welcome.html static/welcome.html && cp ../backend/static/assets/tollbit-icon.png ../backend/static/assets/BITPUB-dark.png static/assets/",
|
|
18
18
|
"prepublishOnly": "npm test"
|
|
19
19
|
},
|
|
20
20
|
"engines": {
|
package/src/api.js
CHANGED
|
@@ -2,74 +2,106 @@
|
|
|
2
2
|
|
|
3
3
|
const axios = require('axios');
|
|
4
4
|
|
|
5
|
+
const { groupEntryFor } = require('./config');
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
8
|
+
* Resolve the backend (`url` + `key`) to use for a given HCU.
|
|
9
|
+
*
|
|
10
|
+
* private:<owner>/... → primary api_url + api_key from config
|
|
11
|
+
* group:<slug>/... → matching entry in config.groups[]
|
|
12
|
+
* (falls back to legacy top-level group_key/domain
|
|
13
|
+
* for the single-domain enterprise install)
|
|
14
|
+
* public/... → primary api_url + api_key
|
|
15
|
+
* bare patterns → same as primary (used for sync of mixed scopes)
|
|
16
|
+
*
|
|
17
|
+
* Throws if the HCU points at a group the caller isn't a member of —
|
|
18
|
+
* surfacing this at request time means the user gets a clear message
|
|
19
|
+
* ("Not a member of bitpub://group:foo") instead of an opaque 401.
|
|
14
20
|
*/
|
|
15
|
-
function
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
function resolveBackend(config, hcu) {
|
|
22
|
+
const baseUrl = (config.api_url || 'http://localhost:8080').replace(/\/$/, '');
|
|
23
|
+
const fallback = { url: baseUrl, key: config.api_key, source: 'primary' };
|
|
24
|
+
|
|
25
|
+
if (typeof hcu !== 'string') return fallback;
|
|
26
|
+
|
|
27
|
+
// Extract the group slug if this is a group-scoped HCU.
|
|
28
|
+
const m = hcu.match(/^bitpub:\/\/group:([^/]+)/);
|
|
29
|
+
if (!m) return fallback;
|
|
30
|
+
|
|
31
|
+
const slug = m[1];
|
|
32
|
+
|
|
33
|
+
// New flow: look in config.groups[] for a matching entry.
|
|
34
|
+
const entry = groupEntryFor(config, slug);
|
|
35
|
+
if (entry && entry.key) {
|
|
36
|
+
return {
|
|
37
|
+
url: (entry.api_url || baseUrl).replace(/\/$/, ''),
|
|
38
|
+
key: entry.key,
|
|
39
|
+
source: 'group',
|
|
40
|
+
slug,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Legacy single-domain enterprise flow: pre-v2 deploys had a top-level
|
|
45
|
+
// group_key + domain. The lazy migration in config.js mirrors these
|
|
46
|
+
// into config.groups[], so this branch should rarely fire after the
|
|
47
|
+
// first read — kept for completeness.
|
|
48
|
+
if (config.group_key && config.domain && config.domain === slug) {
|
|
49
|
+
return { url: baseUrl, key: config.group_key, source: 'legacy-group', slug };
|
|
18
50
|
}
|
|
19
|
-
|
|
51
|
+
|
|
52
|
+
// No matching key. Two reasonable behaviors:
|
|
53
|
+
// - fall through to primary (will get a 403 from the server)
|
|
54
|
+
// - throw here with a clearer message
|
|
55
|
+
// We pick the second so the user gets actionable feedback.
|
|
56
|
+
const err = new Error(
|
|
57
|
+
`Not a member of bitpub://group:${slug}/. Ask the group owner for an invite link, then run: bitpub join <link>`
|
|
58
|
+
);
|
|
59
|
+
err.code = 'NOT_A_MEMBER';
|
|
60
|
+
throw err;
|
|
20
61
|
}
|
|
21
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Create an API client bound to a specific config.
|
|
65
|
+
*
|
|
66
|
+
* Each method resolves the backend (url + key) from the HCU it's about
|
|
67
|
+
* to operate on. Group-scoped HCUs route through `config.groups[]` to
|
|
68
|
+
* the host backend with the per-group key; private/public HCUs use the
|
|
69
|
+
* top-level `api_url` + `api_key`.
|
|
70
|
+
*
|
|
71
|
+
* All methods throw on non-2xx responses (axios default behavior, wrapped
|
|
72
|
+
* by the response interceptor so errors carry the server-side message).
|
|
73
|
+
*/
|
|
22
74
|
function createApiClient(config) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
75
|
+
function http(backend) {
|
|
76
|
+
const inst = axios.create({
|
|
77
|
+
baseURL: backend.url,
|
|
78
|
+
timeout: 30_000,
|
|
79
|
+
headers: { 'x-api-key': backend.key },
|
|
80
|
+
});
|
|
81
|
+
inst.interceptors.response.use(
|
|
82
|
+
(res) => res,
|
|
83
|
+
(err) => {
|
|
84
|
+
const msg = err.response?.data?.error || err.message;
|
|
85
|
+
const status = err.response?.status;
|
|
86
|
+
const wrapped = new Error(status ? `[${status}] ${msg}` : msg);
|
|
87
|
+
wrapped.status = status;
|
|
88
|
+
wrapped.response = err.response;
|
|
89
|
+
return Promise.reject(wrapped);
|
|
90
|
+
}
|
|
91
|
+
);
|
|
92
|
+
return inst;
|
|
93
|
+
}
|
|
42
94
|
|
|
43
95
|
return {
|
|
44
|
-
/**
|
|
45
|
-
* Pull context slices from the remote ledger.
|
|
46
|
-
* @param {string} hcu
|
|
47
|
-
* @param {number} [limit]
|
|
48
|
-
* @param {object} [opts]
|
|
49
|
-
* @param {boolean} [opts.includeDeleted] surface tombstoned slices
|
|
50
|
-
* @param {boolean} [opts.mine] filter to slices written by this key
|
|
51
|
-
* @returns {Promise<Array>} array of slice DTOs
|
|
52
|
-
*/
|
|
53
96
|
async pull(hcu, limit = 50, opts = {}) {
|
|
97
|
+
const backend = resolveBackend(config, hcu);
|
|
54
98
|
const params = { hcu, limit };
|
|
55
99
|
if (opts.includeDeleted) params.include_deleted = 'true';
|
|
56
100
|
if (opts.mine) params.mine = 'true';
|
|
57
|
-
const res = await http.get('/v1/context/pull', {
|
|
58
|
-
params,
|
|
59
|
-
headers: { 'x-api-key': keyForHcu(config, hcu) },
|
|
60
|
-
});
|
|
101
|
+
const res = await http(backend).get('/v1/context/pull', { params });
|
|
61
102
|
return res.data;
|
|
62
103
|
},
|
|
63
104
|
|
|
64
|
-
/**
|
|
65
|
-
* Push a context slice to the remote ledger.
|
|
66
|
-
* @param {object} body Full ContextSlice payload
|
|
67
|
-
* @param {object} [opts]
|
|
68
|
-
* @param {boolean} [opts.append] append instead of overwrite
|
|
69
|
-
* @param {number} [opts.expectVersion] reject with 409 on version mismatch
|
|
70
|
-
* @param {boolean} [opts.force] un-tombstone a deleted slice with new content
|
|
71
|
-
* @returns {Promise<{success: boolean, slice: object}>}
|
|
72
|
-
*/
|
|
73
105
|
async push(body, opts = {}) {
|
|
74
106
|
// Back-compat: older callers passed (body, append, expectVersion) as
|
|
75
107
|
// positional args. Detect that shape and translate to the opts object.
|
|
@@ -81,79 +113,42 @@ function createApiClient(config) {
|
|
|
81
113
|
if (append) params.append = 'true';
|
|
82
114
|
if (force) params.force = 'true';
|
|
83
115
|
if (expectVersion != null) params.expect_version = String(expectVersion);
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
headers: { 'x-api-key': keyForHcu(config, body?.hcu) },
|
|
87
|
-
});
|
|
116
|
+
const backend = resolveBackend(config, body?.hcu);
|
|
117
|
+
const res = await http(backend).post('/v1/context/push', body, { params });
|
|
88
118
|
return res.data;
|
|
89
119
|
},
|
|
90
120
|
|
|
91
|
-
/**
|
|
92
|
-
* List immediate children of an HCU path.
|
|
93
|
-
* @param {string} hcu
|
|
94
|
-
* @param {object} [opts]
|
|
95
|
-
* @param {boolean} [opts.includeDeleted] return tombstone provenance
|
|
96
|
-
* @returns {Promise<{children: string[] | Array<{hcu: string, deleted_at: string|null, deleted_by: string|null}>}>}
|
|
97
|
-
*/
|
|
98
121
|
async list(hcu, opts = {}) {
|
|
122
|
+
const backend = resolveBackend(config, hcu);
|
|
99
123
|
const params = { hcu };
|
|
100
124
|
if (opts.includeDeleted) params.include_deleted = 'true';
|
|
101
|
-
const res = await http.get('/v1/context/list', {
|
|
102
|
-
params,
|
|
103
|
-
headers: { 'x-api-key': keyForHcu(config, hcu) },
|
|
104
|
-
});
|
|
125
|
+
const res = await http(backend).get('/v1/context/list', { params });
|
|
105
126
|
return res.data;
|
|
106
127
|
},
|
|
107
128
|
|
|
108
|
-
/**
|
|
109
|
-
* Soft-delete an exact context slice. Bumps version (active(N) →
|
|
110
|
-
* deleted(N+1)). The server preserves the row's payload so a no-content
|
|
111
|
-
* restore can undelete it. Re-dropping an already-tombstoned slice is
|
|
112
|
-
* idempotent.
|
|
113
|
-
*/
|
|
114
129
|
async drop(hcu, opts = {}) {
|
|
130
|
+
const backend = resolveBackend(config, hcu);
|
|
115
131
|
const params = { hcu };
|
|
116
132
|
if (opts.expectVersion != null) params.expect_version = String(opts.expectVersion);
|
|
117
|
-
const res = await http.delete('/v1/context/drop', {
|
|
118
|
-
params,
|
|
119
|
-
headers: { 'x-api-key': keyForHcu(config, hcu) },
|
|
120
|
-
});
|
|
133
|
+
const res = await http(backend).delete('/v1/context/drop', { params });
|
|
121
134
|
return res.data;
|
|
122
135
|
},
|
|
123
136
|
|
|
124
|
-
/**
|
|
125
|
-
* Restore a tombstoned slice in place using the server's preserved
|
|
126
|
-
* payload. Bumps version (deleted(N) → active(N+1)). Returns 409 if
|
|
127
|
-
* the slice is already active; 404 if it never existed.
|
|
128
|
-
*/
|
|
129
137
|
async restore(hcu, opts = {}) {
|
|
138
|
+
const backend = resolveBackend(config, hcu);
|
|
130
139
|
const params = { hcu };
|
|
131
140
|
if (opts.expectVersion != null) params.expect_version = String(opts.expectVersion);
|
|
132
|
-
const res = await http.post('/v1/context/restore', null, {
|
|
133
|
-
params,
|
|
134
|
-
headers: { 'x-api-key': keyForHcu(config, hcu) },
|
|
135
|
-
});
|
|
141
|
+
const res = await http(backend).post('/v1/context/restore', null, { params });
|
|
136
142
|
return res.data;
|
|
137
143
|
},
|
|
138
144
|
|
|
139
|
-
/**
|
|
140
|
-
* Open an SSE heartbeat stream and invoke onSync(evt) for each sync event.
|
|
141
|
-
* Each `evt` is a `{ hcu, deleted? }` object — the optional `deleted: true`
|
|
142
|
-
* hint tells callers the slice was tombstoned (so they can evict from
|
|
143
|
-
* any local cache without a refetch).
|
|
144
|
-
*
|
|
145
|
-
* For backward compatibility with any caller that expected just an HCU
|
|
146
|
-
* string, the `evt` argument is documented as the new shape and callers
|
|
147
|
-
* should detect `evt.deleted` to short-circuit refetch.
|
|
148
|
-
*
|
|
149
|
-
* Returns a cleanup function that closes the connection.
|
|
150
|
-
*/
|
|
151
145
|
watch(hcuPattern, onSync, onError) {
|
|
152
146
|
const EventSource = require('eventsource');
|
|
153
|
-
const
|
|
147
|
+
const backend = resolveBackend(config, hcuPattern);
|
|
148
|
+
const url = `${backend.url}/v1/context/heartbeat?hcu_pattern=${encodeURIComponent(hcuPattern)}`;
|
|
154
149
|
|
|
155
150
|
const es = new EventSource(url, {
|
|
156
|
-
headers: { 'x-api-key':
|
|
151
|
+
headers: { 'x-api-key': backend.key },
|
|
157
152
|
});
|
|
158
153
|
|
|
159
154
|
es.addEventListener('sync', (event) => {
|
|
@@ -174,4 +169,4 @@ function createApiClient(config) {
|
|
|
174
169
|
};
|
|
175
170
|
}
|
|
176
171
|
|
|
177
|
-
module.exports = { createApiClient };
|
|
172
|
+
module.exports = { createApiClient, resolveBackend };
|
package/src/commands/browser.js
CHANGED
|
@@ -46,27 +46,29 @@ function decryptSlice(slice, apiKey) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
function resolveBrowserHtmlPath() {
|
|
49
|
-
//
|
|
50
|
-
// that ships with the *current* CLI version, not whatever got cached in
|
|
51
|
-
// ~/.bitpub/ from a one-time tarball install months ago.
|
|
49
|
+
// Resolution order:
|
|
52
50
|
//
|
|
53
|
-
// 1.
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
51
|
+
// 1. backend/static/console.html — the workspace file, only present
|
|
52
|
+
// in a checked-out clone. Preferred when present so iterating on
|
|
53
|
+
// the UI doesn't require a `prepack` round-trip between edits.
|
|
54
|
+
// Production installs (npm tarball) don't ship the `backend/`
|
|
55
|
+
// folder, so this candidate is absent there and the resolver
|
|
56
|
+
// falls through.
|
|
57
|
+
// 2. cli/static/console.html — populated by `prepack` from
|
|
58
|
+
// `backend/static/console.html` and shipped inside the CLI
|
|
59
|
+
// tarball. This is the canonical location at runtime for
|
|
60
|
+
// installed CLIs.
|
|
59
61
|
// 3. ~/.bitpub/console.html — last-resort cache, mainly for the
|
|
60
|
-
// original install instructions that fetched a single file via
|
|
61
|
-
// We never write here ourselves; it exists only when a
|
|
62
|
-
// manually populated it.
|
|
62
|
+
// original install instructions that fetched a single file via
|
|
63
|
+
// curl. We never write here ourselves; it exists only when a
|
|
64
|
+
// user manually populated it.
|
|
63
65
|
//
|
|
64
66
|
// Filename is still console.html on disk — keeping the filename stable
|
|
65
67
|
// avoids churning the prepack step and lets old `~/.bitpub/console.html`
|
|
66
68
|
// caches keep working. The user-facing command is `browser`.
|
|
67
69
|
const candidates = [
|
|
68
|
-
path.join(__dirname, '../../static/console.html'),
|
|
69
70
|
path.join(__dirname, '../../../backend/static/console.html'),
|
|
71
|
+
path.join(__dirname, '../../static/console.html'),
|
|
70
72
|
path.join(BITPUB_DIR, 'console.html'),
|
|
71
73
|
];
|
|
72
74
|
for (const p of candidates) {
|
|
@@ -75,6 +77,37 @@ function resolveBrowserHtmlPath() {
|
|
|
75
77
|
return null;
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
// Mirror of `resolveBrowserHtmlPath` for the bundled asset directory.
|
|
81
|
+
// Both the dev clone and the npm tarball place assets under
|
|
82
|
+
// `static/assets/` next to `console.html`, so we look in the same order:
|
|
83
|
+
// workspace first (so the browser picks up edits to PNGs without a
|
|
84
|
+
// `prepack` round-trip), then the prepacked CLI copy.
|
|
85
|
+
function resolveAssetsDir() {
|
|
86
|
+
const candidates = [
|
|
87
|
+
path.join(__dirname, '../../../backend/static/assets'),
|
|
88
|
+
path.join(__dirname, '../../static/assets'),
|
|
89
|
+
];
|
|
90
|
+
for (const p of candidates) {
|
|
91
|
+
if (fs.existsSync(p)) return p;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Tiny extension → Content-Type table covering the asset types the
|
|
97
|
+
// console actually uses. Falls back to application/octet-stream so an
|
|
98
|
+
// unknown extension downloads instead of being interpreted.
|
|
99
|
+
const ASSET_MIME = {
|
|
100
|
+
'.png': 'image/png',
|
|
101
|
+
'.jpg': 'image/jpeg',
|
|
102
|
+
'.jpeg': 'image/jpeg',
|
|
103
|
+
'.gif': 'image/gif',
|
|
104
|
+
'.webp': 'image/webp',
|
|
105
|
+
'.svg': 'image/svg+xml',
|
|
106
|
+
'.ico': 'image/x-icon',
|
|
107
|
+
'.mp4': 'video/mp4',
|
|
108
|
+
'.webm': 'video/webm',
|
|
109
|
+
};
|
|
110
|
+
|
|
78
111
|
function openInBrowser(url) {
|
|
79
112
|
const cmd = process.platform === 'darwin' ? 'open' :
|
|
80
113
|
process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
@@ -110,7 +143,21 @@ function startBrowserServer(opts = {}) {
|
|
|
110
143
|
return reject(err);
|
|
111
144
|
}
|
|
112
145
|
|
|
113
|
-
|
|
146
|
+
// Cache the HTML at startup, but if the resolver picked the workspace
|
|
147
|
+
// copy (developer iterating on the UI), re-read on every request so
|
|
148
|
+
// edits show up on a simple browser refresh — no server restart
|
|
149
|
+
// needed. The installed-tarball path stays cached because its file
|
|
150
|
+
// never changes between sessions.
|
|
151
|
+
const workspaceHtml = path.join(__dirname, '../../../backend/static/console.html');
|
|
152
|
+
const isWorkspaceCopy = path.resolve(htmlPath) === path.resolve(workspaceHtml);
|
|
153
|
+
const cachedHtml = fs.readFileSync(htmlPath, 'utf-8');
|
|
154
|
+
function loadHtml() {
|
|
155
|
+
if (!isWorkspaceCopy) return cachedHtml;
|
|
156
|
+
try { return fs.readFileSync(htmlPath, 'utf-8'); }
|
|
157
|
+
catch { return cachedHtml; }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const assetsDir = resolveAssetsDir();
|
|
114
161
|
|
|
115
162
|
const server = http.createServer((req, res) => {
|
|
116
163
|
const url = new URL(req.url, `http://localhost:${port}`);
|
|
@@ -132,7 +179,34 @@ function startBrowserServer(opts = {}) {
|
|
|
132
179
|
|
|
133
180
|
if (url.pathname === '/' || url.pathname === '/index.html') {
|
|
134
181
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
135
|
-
res.end(
|
|
182
|
+
res.end(loadHtml());
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Static assets: serve files from `static/assets/` for any
|
|
187
|
+
// `/assets/<name>` request. We resolve+contain inside the assets
|
|
188
|
+
// dir to defend against `../`-style traversal, fail closed if the
|
|
189
|
+
// asset dir doesn't exist, and only serve known image/video MIME
|
|
190
|
+
// types so this can't accidentally double as a generic file
|
|
191
|
+
// server.
|
|
192
|
+
if (url.pathname.startsWith('/assets/')) {
|
|
193
|
+
if (!assetsDir) { res.statusCode = 404; res.end('Not found'); return; }
|
|
194
|
+
const rel = url.pathname.replace(/^\/assets\//, '');
|
|
195
|
+
const full = path.resolve(assetsDir, rel);
|
|
196
|
+
if (!full.startsWith(path.resolve(assetsDir) + path.sep)) {
|
|
197
|
+
res.statusCode = 403; res.end('Forbidden'); return;
|
|
198
|
+
}
|
|
199
|
+
const ext = path.extname(full).toLowerCase();
|
|
200
|
+
const mime = ASSET_MIME[ext];
|
|
201
|
+
if (!mime) { res.statusCode = 415; res.end('Unsupported Media Type'); return; }
|
|
202
|
+
fs.readFile(full, (err, data) => {
|
|
203
|
+
if (err) { res.statusCode = 404; res.end('Not found'); return; }
|
|
204
|
+
res.setHeader('Content-Type', mime);
|
|
205
|
+
// Short cache so iterating on PNGs (which we re-read from
|
|
206
|
+
// the workspace in dev) doesn't get pinned to a stale copy.
|
|
207
|
+
res.setHeader('Cache-Control', 'public, max-age=60');
|
|
208
|
+
res.end(data);
|
|
209
|
+
});
|
|
136
210
|
return;
|
|
137
211
|
}
|
|
138
212
|
|