@phnx-labs/agents-cli 1.20.18 → 1.20.19
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 +4 -0
- package/dist/commands/secrets.js +104 -23
- package/dist/lib/secrets/agent.d.ts +15 -2
- package/dist/lib/secrets/agent.js +51 -52
- package/dist/lib/secrets/bundles.d.ts +37 -7
- package/dist/lib/secrets/bundles.js +226 -80
- package/dist/lib/secrets/filestore.d.ts +82 -0
- package/dist/lib/secrets/filestore.js +295 -0
- package/dist/lib/secrets/linux.d.ts +6 -24
- package/dist/lib/secrets/linux.js +22 -265
- package/package.json +1 -1
|
@@ -7,24 +7,21 @@
|
|
|
7
7
|
* (common on server-class Linux — no graphical login means the keyring
|
|
8
8
|
* passphrase never enters the daemon, so `secret-tool store` fails with
|
|
9
9
|
* "Cannot create an item in a locked collection"), we transparently switch
|
|
10
|
-
* to
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* prompt. The decision is cached per process; one stderr line is emitted
|
|
14
|
-
* the first time the fallback activates.
|
|
10
|
+
* to the AES-256-GCM encrypted-file store in ./filestore.ts. The decision is
|
|
11
|
+
* cached per process; one stderr line is emitted the first time the fallback
|
|
12
|
+
* activates.
|
|
15
13
|
*
|
|
16
14
|
* Secrets stored via secret-tool use:
|
|
17
15
|
* service = "agents-cli"
|
|
18
16
|
* account = username
|
|
19
17
|
* item = the secret identifier
|
|
20
|
-
*
|
|
21
|
-
* File-fallback layout: one `<item>.enc` JSON file per item, mode 0600.
|
|
22
18
|
*/
|
|
23
|
-
import { spawnSync
|
|
24
|
-
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
|
|
25
|
-
import * as fs from 'fs';
|
|
19
|
+
import { spawnSync } from 'child_process';
|
|
26
20
|
import * as os from 'os';
|
|
27
|
-
import
|
|
21
|
+
import { fileStore, fileDir, fileStoreHasItems, machinePassphraseExists, _resetFileStoreForTest, } from './filestore.js';
|
|
22
|
+
// Re-exported so existing importers (and tests) can keep reaching these via
|
|
23
|
+
// './linux.js'. The implementations live in ./filestore.ts.
|
|
24
|
+
export { encryptForFallback, decryptForFallback, fileBackend, } from './filestore.js';
|
|
28
25
|
const SERVICE = 'agents-cli';
|
|
29
26
|
// ---------- secret-tool availability ----------
|
|
30
27
|
function secretToolAvailable() {
|
|
@@ -38,12 +35,6 @@ let isAvailable = false;
|
|
|
38
35
|
// ---------- file fallback state ----------
|
|
39
36
|
let useFileFallback = false;
|
|
40
37
|
let warnedFallback = false;
|
|
41
|
-
let warnedAutoPassphrase = false;
|
|
42
|
-
let fileDirOverride = null;
|
|
43
|
-
let cachedPassphrase = null;
|
|
44
|
-
function fileDir() {
|
|
45
|
-
return fileDirOverride ?? path.join(os.homedir(), '.agents', '.cache', 'secrets');
|
|
46
|
-
}
|
|
47
38
|
function activateFileFallback() {
|
|
48
39
|
if (useFileFallback)
|
|
49
40
|
return;
|
|
@@ -57,18 +48,6 @@ function isLockedCollectionError(stderr) {
|
|
|
57
48
|
return /locked collection/i.test(stderr) ||
|
|
58
49
|
/Prompt was dismissed/i.test(stderr);
|
|
59
50
|
}
|
|
60
|
-
/** True if the fallback dir has any committed encrypted items. Means an
|
|
61
|
-
* earlier process (this one or another) already routed writes to the file
|
|
62
|
-
* store, so this process must keep reading/writing from the same store —
|
|
63
|
-
* otherwise `list` / `get` / `has` would silently miss them. */
|
|
64
|
-
function fileFallbackPreviouslyActivated() {
|
|
65
|
-
try {
|
|
66
|
-
return fs.readdirSync(fileDir()).some((e) => e.endsWith('.enc'));
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
return false;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
51
|
/**
|
|
73
52
|
* Decide which backend a given op should use. Activates file fallback if
|
|
74
53
|
* `secret-tool` is missing and `AGENTS_SECRETS_PASSPHRASE` is set, OR if a
|
|
@@ -79,7 +58,7 @@ function fileFallbackPreviouslyActivated() {
|
|
|
79
58
|
function preflight() {
|
|
80
59
|
if (useFileFallback)
|
|
81
60
|
return 'file';
|
|
82
|
-
if (
|
|
61
|
+
if (fileStoreHasItems()) {
|
|
83
62
|
activateFileFallback();
|
|
84
63
|
return 'file';
|
|
85
64
|
}
|
|
@@ -107,233 +86,12 @@ function preflight() {
|
|
|
107
86
|
}
|
|
108
87
|
return 'secret-tool';
|
|
109
88
|
}
|
|
110
|
-
// ---------- passphrase ----------
|
|
111
|
-
function readPassphraseFromTty() {
|
|
112
|
-
const fd = fs.openSync('/dev/tty', 'r+');
|
|
113
|
-
let echoDisabled = false;
|
|
114
|
-
try {
|
|
115
|
-
fs.writeSync(fd, 'Enter AGENTS_SECRETS_PASSPHRASE: ');
|
|
116
|
-
try {
|
|
117
|
-
execSync('stty -echo < /dev/tty', { stdio: 'ignore' });
|
|
118
|
-
echoDisabled = true;
|
|
119
|
-
}
|
|
120
|
-
catch {
|
|
121
|
-
// stty not available — fall through; passphrase will echo. Better
|
|
122
|
-
// than refusing to function.
|
|
123
|
-
}
|
|
124
|
-
let pass = '';
|
|
125
|
-
const buf = Buffer.alloc(1);
|
|
126
|
-
while (true) {
|
|
127
|
-
const n = fs.readSync(fd, buf, 0, 1, null);
|
|
128
|
-
if (n === 0)
|
|
129
|
-
break;
|
|
130
|
-
const ch = buf.toString('utf8', 0, n);
|
|
131
|
-
if (ch === '\n' || ch === '\r')
|
|
132
|
-
break;
|
|
133
|
-
pass += ch;
|
|
134
|
-
}
|
|
135
|
-
return pass;
|
|
136
|
-
}
|
|
137
|
-
finally {
|
|
138
|
-
if (echoDisabled) {
|
|
139
|
-
try {
|
|
140
|
-
execSync('stty echo < /dev/tty', { stdio: 'ignore' });
|
|
141
|
-
}
|
|
142
|
-
catch { /* best effort */ }
|
|
143
|
-
}
|
|
144
|
-
try {
|
|
145
|
-
fs.writeSync(fd, '\n');
|
|
146
|
-
}
|
|
147
|
-
catch { /* best effort */ }
|
|
148
|
-
fs.closeSync(fd);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
/** Path of the auto-provisioned machine-local passphrase. Lives alongside the
|
|
152
|
-
* encrypted items but is never itself an item (no `.enc` suffix, so it's
|
|
153
|
-
* excluded from list/has/get and from fileFallbackPreviouslyActivated). */
|
|
154
|
-
function passphraseFilePath() {
|
|
155
|
-
return path.join(fileDir(), '.passphrase');
|
|
156
|
-
}
|
|
157
|
-
/** True if a machine-local passphrase has already been provisioned. */
|
|
158
|
-
function machinePassphraseExists() {
|
|
159
|
-
try {
|
|
160
|
-
return fs.readFileSync(passphraseFilePath(), 'utf8').trim().length > 0;
|
|
161
|
-
}
|
|
162
|
-
catch {
|
|
163
|
-
return false;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
function readMachinePassphrase() {
|
|
167
|
-
try {
|
|
168
|
-
const p = fs.readFileSync(passphraseFilePath(), 'utf8').trim();
|
|
169
|
-
return p.length > 0 ? p : null;
|
|
170
|
-
}
|
|
171
|
-
catch {
|
|
172
|
-
return null;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
/**
|
|
176
|
-
* Provision (or read back) a stable machine-local passphrase for the encrypted
|
|
177
|
-
* file store, so `agents secrets` works out of the box on a headless box where
|
|
178
|
-
* the keyring is locked and no AGENTS_SECRETS_PASSPHRASE is set.
|
|
179
|
-
*
|
|
180
|
-
* Security model: this is encryption-at-rest with the key held in a 0600 file —
|
|
181
|
-
* the same posture as an SSH private key, and identical to the common
|
|
182
|
-
* "export AGENTS_SECRETS_PASSPHRASE=… in ~/.zshenv (chmod 600)" workaround. The
|
|
183
|
-
* keyring (key in a daemon's locked memory) is stronger but is unavailable
|
|
184
|
-
* without a graphical/unlocked session. For an off-disk key, set
|
|
185
|
-
* AGENTS_SECRETS_PASSPHRASE (it always takes precedence) or unlock the keyring.
|
|
186
|
-
*/
|
|
187
|
-
function provisionMachinePassphrase() {
|
|
188
|
-
const existing = readMachinePassphrase();
|
|
189
|
-
if (existing)
|
|
190
|
-
return existing;
|
|
191
|
-
ensureFileDir();
|
|
192
|
-
const generated = randomBytes(32).toString('base64');
|
|
193
|
-
const fp = passphraseFilePath();
|
|
194
|
-
try {
|
|
195
|
-
// wx: fail if a concurrent process created it first (then we read theirs).
|
|
196
|
-
fs.writeFileSync(fp, generated, { mode: 0o600, flag: 'wx' });
|
|
197
|
-
}
|
|
198
|
-
catch {
|
|
199
|
-
const raced = readMachinePassphrase();
|
|
200
|
-
if (raced)
|
|
201
|
-
return raced;
|
|
202
|
-
throw new Error(`Failed to provision machine-local passphrase at ${fp}.`);
|
|
203
|
-
}
|
|
204
|
-
if (!warnedAutoPassphrase) {
|
|
205
|
-
warnedAutoPassphrase = true;
|
|
206
|
-
process.stderr.write(`[agents] keyring locked and no AGENTS_SECRETS_PASSPHRASE set; provisioned a ` +
|
|
207
|
-
`machine-local passphrase at ${fp} (mode 0600). Set AGENTS_SECRETS_PASSPHRASE ` +
|
|
208
|
-
`for a key held off disk.\n`);
|
|
209
|
-
}
|
|
210
|
-
return generated;
|
|
211
|
-
}
|
|
212
|
-
function getPassphrase() {
|
|
213
|
-
if (cachedPassphrase !== null)
|
|
214
|
-
return cachedPassphrase;
|
|
215
|
-
const env = process.env.AGENTS_SECRETS_PASSPHRASE;
|
|
216
|
-
if (env && env.length > 0) {
|
|
217
|
-
cachedPassphrase = env;
|
|
218
|
-
return env;
|
|
219
|
-
}
|
|
220
|
-
// A previously-provisioned machine-local passphrase is this machine's stable
|
|
221
|
-
// file-store key — prefer it for both interactive and headless runs so they
|
|
222
|
-
// always agree (a TTY run won't re-prompt once the file exists).
|
|
223
|
-
const onDisk = readMachinePassphrase();
|
|
224
|
-
if (onDisk) {
|
|
225
|
-
cachedPassphrase = onDisk;
|
|
226
|
-
return onDisk;
|
|
227
|
-
}
|
|
228
|
-
// First run, no env, no provisioned key: prompt when interactive, otherwise
|
|
229
|
-
// (headless — the reported bug) auto-provision instead of hard-failing.
|
|
230
|
-
if (process.stdin.isTTY) {
|
|
231
|
-
const p = readPassphraseFromTty();
|
|
232
|
-
if (!p)
|
|
233
|
-
throw new Error('No passphrase entered.');
|
|
234
|
-
cachedPassphrase = p;
|
|
235
|
-
return p;
|
|
236
|
-
}
|
|
237
|
-
cachedPassphrase = provisionMachinePassphrase();
|
|
238
|
-
return cachedPassphrase;
|
|
239
|
-
}
|
|
240
|
-
function deriveKey(passphrase, salt) {
|
|
241
|
-
return scryptSync(passphrase, salt, 32);
|
|
242
|
-
}
|
|
243
|
-
/** Encrypt plaintext under a passphrase using AES-256-GCM with a random
|
|
244
|
-
* scrypt salt and a random 96-bit IV. Exported for tests. */
|
|
245
|
-
export function encryptForFallback(plaintext, passphrase) {
|
|
246
|
-
const salt = randomBytes(16);
|
|
247
|
-
const iv = randomBytes(12);
|
|
248
|
-
const key = deriveKey(passphrase, salt);
|
|
249
|
-
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
250
|
-
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
251
|
-
return {
|
|
252
|
-
salt: salt.toString('hex'),
|
|
253
|
-
iv: iv.toString('hex'),
|
|
254
|
-
authTag: cipher.getAuthTag().toString('hex'),
|
|
255
|
-
ciphertext: ciphertext.toString('hex'),
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
/** Decrypt an EncFile under a passphrase. Throws on wrong key or tampered
|
|
259
|
-
* ciphertext (auth-tag mismatch). Exported for tests. */
|
|
260
|
-
export function decryptForFallback(enc, passphrase) {
|
|
261
|
-
const salt = Buffer.from(enc.salt, 'hex');
|
|
262
|
-
const iv = Buffer.from(enc.iv, 'hex');
|
|
263
|
-
const authTag = Buffer.from(enc.authTag, 'hex');
|
|
264
|
-
const ciphertext = Buffer.from(enc.ciphertext, 'hex');
|
|
265
|
-
const key = deriveKey(passphrase, salt);
|
|
266
|
-
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
267
|
-
decipher.setAuthTag(authTag);
|
|
268
|
-
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
269
|
-
return plaintext.toString('utf8');
|
|
270
|
-
}
|
|
271
|
-
// ---------- file backend ----------
|
|
272
|
-
function fileFor(item) {
|
|
273
|
-
return path.join(fileDir(), `${item}.enc`);
|
|
274
|
-
}
|
|
275
|
-
function ensureFileDir() {
|
|
276
|
-
fs.mkdirSync(fileDir(), { recursive: true, mode: 0o700 });
|
|
277
|
-
}
|
|
278
|
-
function fileHas(item) {
|
|
279
|
-
return fs.existsSync(fileFor(item));
|
|
280
|
-
}
|
|
281
|
-
function fileGet(item) {
|
|
282
|
-
const fp = fileFor(item);
|
|
283
|
-
if (!fs.existsSync(fp)) {
|
|
284
|
-
throw new Error(`Secret '${item}' not found in encrypted store.`);
|
|
285
|
-
}
|
|
286
|
-
const raw = fs.readFileSync(fp, 'utf8');
|
|
287
|
-
let parsed;
|
|
288
|
-
try {
|
|
289
|
-
parsed = JSON.parse(raw);
|
|
290
|
-
}
|
|
291
|
-
catch {
|
|
292
|
-
throw new Error(`Encrypted secret file ${fp} is corrupt (not valid JSON).`);
|
|
293
|
-
}
|
|
294
|
-
try {
|
|
295
|
-
return decryptForFallback(parsed, getPassphrase());
|
|
296
|
-
}
|
|
297
|
-
catch {
|
|
298
|
-
throw new Error(`Failed to decrypt '${item}'. Wrong AGENTS_SECRETS_PASSPHRASE or tampered file.`);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
function fileSet(item, value) {
|
|
302
|
-
ensureFileDir();
|
|
303
|
-
const enc = encryptForFallback(value, getPassphrase());
|
|
304
|
-
fs.writeFileSync(fileFor(item), JSON.stringify(enc), { mode: 0o600 });
|
|
305
|
-
}
|
|
306
|
-
function fileDelete(item) {
|
|
307
|
-
const fp = fileFor(item);
|
|
308
|
-
if (!fs.existsSync(fp))
|
|
309
|
-
return true; // idempotent, matches secret-tool clear
|
|
310
|
-
fs.unlinkSync(fp);
|
|
311
|
-
return true;
|
|
312
|
-
}
|
|
313
|
-
function fileList(prefix) {
|
|
314
|
-
const dir = fileDir();
|
|
315
|
-
if (!fs.existsSync(dir))
|
|
316
|
-
return [];
|
|
317
|
-
return fs.readdirSync(dir)
|
|
318
|
-
.filter((f) => f.endsWith('.enc'))
|
|
319
|
-
.map((f) => f.slice(0, -'.enc'.length))
|
|
320
|
-
.filter((name) => name.startsWith(prefix));
|
|
321
|
-
}
|
|
322
|
-
/** File-only KeychainBackend (exported for tests; the public surface uses
|
|
323
|
-
* the secret-tool-with-fallback `linuxBackend` below). */
|
|
324
|
-
export const fileBackend = {
|
|
325
|
-
has: fileHas,
|
|
326
|
-
get: fileGet,
|
|
327
|
-
set: fileSet,
|
|
328
|
-
delete: fileDelete,
|
|
329
|
-
list: fileList,
|
|
330
|
-
};
|
|
331
89
|
// ---------- secret-tool ops with fallback ----------
|
|
332
90
|
/** secret-tool lookup attributes:
|
|
333
91
|
* service=agents-cli account=<user> item=<itemName> */
|
|
334
92
|
export function hasSecretToolToken(item) {
|
|
335
93
|
if (preflight() === 'file')
|
|
336
|
-
return
|
|
94
|
+
return fileStore.has(item);
|
|
337
95
|
const user = os.userInfo().username;
|
|
338
96
|
const result = spawnSync('secret-tool', [
|
|
339
97
|
'lookup',
|
|
@@ -347,13 +105,13 @@ export function hasSecretToolToken(item) {
|
|
|
347
105
|
const stderr = result.stderr?.toString() ?? '';
|
|
348
106
|
if (isLockedCollectionError(stderr)) {
|
|
349
107
|
activateFileFallback();
|
|
350
|
-
return
|
|
108
|
+
return fileStore.has(item);
|
|
351
109
|
}
|
|
352
110
|
return false;
|
|
353
111
|
}
|
|
354
112
|
export function getSecretToolToken(item) {
|
|
355
113
|
if (preflight() === 'file')
|
|
356
|
-
return
|
|
114
|
+
return fileStore.get(item);
|
|
357
115
|
const user = os.userInfo().username;
|
|
358
116
|
const result = spawnSync('secret-tool', [
|
|
359
117
|
'lookup',
|
|
@@ -370,7 +128,7 @@ export function getSecretToolToken(item) {
|
|
|
370
128
|
const stderr = result.stderr?.toString() ?? '';
|
|
371
129
|
if (isLockedCollectionError(stderr)) {
|
|
372
130
|
activateFileFallback();
|
|
373
|
-
return
|
|
131
|
+
return fileStore.get(item);
|
|
374
132
|
}
|
|
375
133
|
throw new Error(`Secret '${item}' not found in keyring.`);
|
|
376
134
|
}
|
|
@@ -378,7 +136,7 @@ export function setSecretToolToken(item, value) {
|
|
|
378
136
|
if (!value || !value.trim())
|
|
379
137
|
throw new Error('Secret value is empty.');
|
|
380
138
|
if (preflight() === 'file')
|
|
381
|
-
return
|
|
139
|
+
return fileStore.set(item, value);
|
|
382
140
|
const user = os.userInfo().username;
|
|
383
141
|
const label = `agents-cli: ${item}`;
|
|
384
142
|
const result = spawnSync('secret-tool', [
|
|
@@ -393,7 +151,7 @@ export function setSecretToolToken(item, value) {
|
|
|
393
151
|
const stderr = result.stderr?.toString().trim() ?? '';
|
|
394
152
|
if (isLockedCollectionError(stderr)) {
|
|
395
153
|
activateFileFallback();
|
|
396
|
-
|
|
154
|
+
fileStore.set(item, value);
|
|
397
155
|
return;
|
|
398
156
|
}
|
|
399
157
|
throw new Error(`Failed to store secret '${item}': ${stderr || 'unknown error'}\n` +
|
|
@@ -402,7 +160,7 @@ export function setSecretToolToken(item, value) {
|
|
|
402
160
|
}
|
|
403
161
|
export function deleteSecretToolToken(item) {
|
|
404
162
|
if (preflight() === 'file')
|
|
405
|
-
return
|
|
163
|
+
return fileStore.delete(item);
|
|
406
164
|
const user = os.userInfo().username;
|
|
407
165
|
const result = spawnSync('secret-tool', [
|
|
408
166
|
'clear',
|
|
@@ -415,7 +173,7 @@ export function deleteSecretToolToken(item) {
|
|
|
415
173
|
const stderr = result.stderr?.toString() ?? '';
|
|
416
174
|
if (isLockedCollectionError(stderr)) {
|
|
417
175
|
activateFileFallback();
|
|
418
|
-
return
|
|
176
|
+
return fileStore.delete(item);
|
|
419
177
|
}
|
|
420
178
|
// secret-tool clear returns 0 whether the item existed or not.
|
|
421
179
|
// A non-zero exit that isn't a locked-collection error is a real failure;
|
|
@@ -456,7 +214,7 @@ export function parseSecretToolItems(output, prefix) {
|
|
|
456
214
|
*/
|
|
457
215
|
export function listSecretToolItems(prefix) {
|
|
458
216
|
if (preflight() === 'file')
|
|
459
|
-
return
|
|
217
|
+
return fileStore.list(prefix);
|
|
460
218
|
const result = spawnSync('secret-tool', [
|
|
461
219
|
'search',
|
|
462
220
|
'--all',
|
|
@@ -466,7 +224,7 @@ export function listSecretToolItems(prefix) {
|
|
|
466
224
|
const stderr = result.stderr?.toString() ?? '';
|
|
467
225
|
if (isLockedCollectionError(stderr)) {
|
|
468
226
|
activateFileFallback();
|
|
469
|
-
return
|
|
227
|
+
return fileStore.list(prefix);
|
|
470
228
|
}
|
|
471
229
|
return [];
|
|
472
230
|
}
|
|
@@ -495,13 +253,12 @@ export const linuxBackend = {
|
|
|
495
253
|
},
|
|
496
254
|
};
|
|
497
255
|
/** Test-only: reset module state so independent test cases don't bleed
|
|
498
|
-
* passphrase / fallback decisions across each other.
|
|
256
|
+
* passphrase / fallback decisions across each other. File-store state (file
|
|
257
|
+
* dir + cached passphrase) lives in ./filestore.ts and is reset there. */
|
|
499
258
|
export function _resetForTest(opts = {}) {
|
|
500
|
-
|
|
259
|
+
_resetFileStoreForTest({ fileDir: opts.fileDir ?? null, passphrase: opts.passphrase ?? null });
|
|
501
260
|
useFileFallback = opts.forceFileFallback ?? false;
|
|
502
261
|
warnedFallback = false;
|
|
503
|
-
warnedAutoPassphrase = false;
|
|
504
|
-
cachedPassphrase = opts.passphrase ?? null;
|
|
505
262
|
checkedAvailability = false;
|
|
506
263
|
isAvailable = false;
|
|
507
264
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.19",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|