@tikoci/centrs 0.1.9-next.24 → 0.1.9-next.27
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 +57 -0
- package/GLOSSARY.txt +9 -1
- package/README.md +28 -16
- package/commands/api/AGENTS.md +3 -2
- package/commands/api/README.md +8 -1
- package/commands/btest/README.md +9 -0
- package/commands/btest/examples.md +7 -0
- package/commands/devices/README.md +72 -3
- package/commands/devices/examples.md +56 -1
- package/commands/execute/README.md +11 -0
- package/commands/explain/README.md +223 -3
- package/commands/explain/examples.md +8 -1
- package/commands/retrieve/README.md +22 -1
- package/commands/retrieve/examples.md +84 -0
- package/commands/transfer/README.md +25 -0
- package/commands/transfer/examples.md +46 -0
- package/docs/CLI.md +11 -3
- package/docs/MATRIX.md +5 -3
- package/docs/errors/cdb/encrypt-failed.md +10 -0
- package/docs/errors/cdb/password-required.md +5 -4
- package/docs/errors/cdb/plaintext-backups.md +11 -0
- package/docs/errors/input/invalid-routeros-path.md +25 -3
- package/package.json +8 -4
- package/src/api-fanout.ts +2 -0
- package/src/api.ts +12 -0
- package/src/btest.ts +7 -0
- package/src/cli/api.ts +18 -1
- package/src/cli/btest.ts +9 -0
- package/src/cli/devices.ts +76 -8
- package/src/cli/execute.ts +9 -0
- package/src/cli/retrieve.ts +9 -0
- package/src/cli/terminal.ts +9 -0
- package/src/cli/transfer.ts +10 -1
- package/src/core/error-catalog.ts +13 -2
- package/src/core/menu-path.ts +89 -0
- package/src/devices.ts +154 -9
- package/src/execute-fanout.ts +2 -0
- package/src/execute.ts +7 -0
- package/src/explain/blocks.ts +2 -2
- package/src/explain/catalog.ts +22 -12
- package/src/explain/pasted-prompt.ts +198 -0
- package/src/explain/pathresolve.ts +2 -2
- package/src/explain/segment.ts +2 -2
- package/src/explain/symbols.ts +2 -2
- package/src/explain/verbsplit.ts +2 -2
- package/src/explain/write.ts +2 -2
- package/src/explain.ts +2 -0
- package/src/index.ts +4 -0
- package/src/protocols/adapter.ts +2 -0
- package/src/protocols/btest-session.ts +10 -30
- package/src/protocols/ec-srp5.ts +19 -10
- package/src/protocols/mac-telnet-console.ts +2 -2
- package/src/resolver/cdb.ts +58 -2
- package/src/resolver/index.ts +1 -0
- package/src/resolver/quickchr-provider.ts +5 -0
- package/src/resolver/selection.ts +11 -0
- package/src/retrieve-fanout.ts +2 -0
- package/src/retrieve.ts +11 -0
- package/src/settings.ts +1 -0
- package/src/terminal.ts +7 -0
- package/src/transfer-fanout.ts +2 -0
- package/src/transfer.ts +79 -14
package/src/devices.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
decryptWinBoxCdb,
|
|
20
20
|
type EncryptedWinBoxCdbFile,
|
|
21
21
|
encodeOpenWinBoxCdb,
|
|
22
|
+
encryptWinBoxCdb,
|
|
22
23
|
parseWinBoxCdb,
|
|
23
24
|
type WinBoxCdbEntry,
|
|
24
25
|
type WinBoxCdbField,
|
|
@@ -112,6 +113,7 @@ export interface DevicesGroupSummary {
|
|
|
112
113
|
|
|
113
114
|
export type DevicesCommand =
|
|
114
115
|
| "init"
|
|
116
|
+
| "encrypt"
|
|
115
117
|
| "list"
|
|
116
118
|
| "show"
|
|
117
119
|
| "groups"
|
|
@@ -165,7 +167,7 @@ export interface LoadedCdb {
|
|
|
165
167
|
entries: readonly WinBoxCdbEntry[];
|
|
166
168
|
settings: DevicesSettings;
|
|
167
169
|
warnings: readonly DevicesWarning[];
|
|
168
|
-
/** True when the source file on disk was encrypted
|
|
170
|
+
/** True when the source file on disk was encrypted. */
|
|
169
171
|
encrypted: boolean;
|
|
170
172
|
}
|
|
171
173
|
|
|
@@ -232,7 +234,7 @@ function isAlreadyExistsError(cause: unknown): boolean {
|
|
|
232
234
|
}
|
|
233
235
|
|
|
234
236
|
/**
|
|
235
|
-
* Create an empty
|
|
237
|
+
* Create an empty CDB at `path` without clobbering an existing file.
|
|
236
238
|
* The bytes are written to a private temp file first and then hard-linked
|
|
237
239
|
* into place: `link` fails with EEXIST instead of replacing, and `path` never
|
|
238
240
|
* exists half-written, so a concurrent creator that loses the race reads a
|
|
@@ -242,14 +244,20 @@ function isAlreadyExistsError(cause: unknown): boolean {
|
|
|
242
244
|
* file. Mode `0o600` keeps the CDB (device credentials) unreadable by other
|
|
243
245
|
* local users instead of falling back to the process umask default.
|
|
244
246
|
*/
|
|
245
|
-
async function createEmptyCdbNoClobber(
|
|
247
|
+
async function createEmptyCdbNoClobber(
|
|
248
|
+
path: string,
|
|
249
|
+
password?: string,
|
|
250
|
+
): Promise<boolean> {
|
|
246
251
|
await mkdir(dirname(path), { recursive: true });
|
|
247
252
|
const tempPath = `${path}.init.${randomUUID()}`;
|
|
248
253
|
const handle = await open(tempPath, "wx", 0o600);
|
|
249
254
|
try {
|
|
250
255
|
try {
|
|
251
256
|
await handle.chmod(0o600);
|
|
252
|
-
|
|
257
|
+
const bytes = encodeOpenWinBoxCdb([]);
|
|
258
|
+
await handle.write(
|
|
259
|
+
password === undefined ? bytes : encryptWinBoxCdb(bytes, password),
|
|
260
|
+
);
|
|
253
261
|
await handle.sync();
|
|
254
262
|
} finally {
|
|
255
263
|
await handle.close();
|
|
@@ -270,11 +278,19 @@ export interface DevicesInitData {
|
|
|
270
278
|
cdbFile: string;
|
|
271
279
|
/** False when a readable CDB was already there; nothing was written. */
|
|
272
280
|
created: boolean;
|
|
281
|
+
/** Actual mode of the newly created or verified existing CDB. */
|
|
282
|
+
encrypted: boolean;
|
|
273
283
|
recordCount: number;
|
|
274
284
|
}
|
|
275
285
|
|
|
286
|
+
export interface InitCdbOptions extends LoadCdbOptions {
|
|
287
|
+
/** Require encryption; an existing open CDB is refused, never converted. */
|
|
288
|
+
encrypt?: boolean;
|
|
289
|
+
}
|
|
290
|
+
|
|
276
291
|
/**
|
|
277
|
-
* `devices init
|
|
292
|
+
* `devices init`: create an empty CDB (open by default, encrypted with
|
|
293
|
+
* `encrypt: true`) at the resolved path, the
|
|
278
294
|
* explicit counterpart of the default path's first-run auto-create. Never
|
|
279
295
|
* clobbers: like `git init`, re-running on an existing, readable CDB succeeds
|
|
280
296
|
* with a `cdb/file-exists` warning and writes nothing, so a workspace bootstrap
|
|
@@ -282,15 +298,29 @@ export interface DevicesInitData {
|
|
|
282
298
|
* (not a CDB, or encrypted without a password) fails with the load error.
|
|
283
299
|
*/
|
|
284
300
|
export async function initCdb(
|
|
285
|
-
options:
|
|
301
|
+
options: InitCdbOptions,
|
|
286
302
|
): Promise<CentrsSuccessEnvelope<DevicesInitData, DevicesOperationMeta>> {
|
|
287
303
|
const settings = resolveDevicesSettings(options);
|
|
288
304
|
const cdbFile = settings.cdbFile.value;
|
|
305
|
+
if (options.encrypt) requireEncryptionPassword(settings);
|
|
289
306
|
const existingResult = async () => {
|
|
290
307
|
const existing = await loadCdb(options);
|
|
308
|
+
if (options.encrypt && !existing.encrypted) {
|
|
309
|
+
throw new CentrsError({
|
|
310
|
+
code: "input/invalid-command",
|
|
311
|
+
summary: `An open CDB already exists at ${cdbFile}; encrypted initialization wrote nothing.`,
|
|
312
|
+
remediation: `Run \`centrs devices encrypt --cdb-file ${cdbFile}\` to convert the existing CDB explicitly.`,
|
|
313
|
+
context: { cdbFile },
|
|
314
|
+
});
|
|
315
|
+
}
|
|
291
316
|
return {
|
|
292
317
|
ok: true as const,
|
|
293
|
-
data: {
|
|
318
|
+
data: {
|
|
319
|
+
cdbFile,
|
|
320
|
+
created: false,
|
|
321
|
+
encrypted: existing.encrypted,
|
|
322
|
+
recordCount: existing.entries.length,
|
|
323
|
+
},
|
|
294
324
|
warnings: [
|
|
295
325
|
...existing.warnings,
|
|
296
326
|
{
|
|
@@ -310,7 +340,10 @@ export async function initCdb(
|
|
|
310
340
|
}
|
|
311
341
|
let created: boolean;
|
|
312
342
|
try {
|
|
313
|
-
created = await createEmptyCdbNoClobber(
|
|
343
|
+
created = await createEmptyCdbNoClobber(
|
|
344
|
+
cdbFile,
|
|
345
|
+
options.encrypt ? settings.cdbPassword.value : undefined,
|
|
346
|
+
);
|
|
314
347
|
} catch (cause) {
|
|
315
348
|
throw new CentrsError({
|
|
316
349
|
code: "cdb/create-failed",
|
|
@@ -326,13 +359,113 @@ export async function initCdb(
|
|
|
326
359
|
}
|
|
327
360
|
return {
|
|
328
361
|
ok: true,
|
|
329
|
-
data: {
|
|
362
|
+
data: {
|
|
363
|
+
cdbFile,
|
|
364
|
+
created: true,
|
|
365
|
+
encrypted: options.encrypt === true,
|
|
366
|
+
recordCount: 0,
|
|
367
|
+
},
|
|
330
368
|
warnings: [],
|
|
331
369
|
tips: [],
|
|
332
370
|
meta: devicesMeta("init", settings),
|
|
333
371
|
};
|
|
334
372
|
}
|
|
335
373
|
|
|
374
|
+
function requireEncryptionPassword(settings: DevicesSettings): void {
|
|
375
|
+
if (
|
|
376
|
+
!settings.cdbPassword.provided ||
|
|
377
|
+
settings.cdbPassword.value.length === 0
|
|
378
|
+
) {
|
|
379
|
+
throw new CentrsError({
|
|
380
|
+
code: "cdb/password-required",
|
|
381
|
+
summary: "CDB encryption requires a non-empty password.",
|
|
382
|
+
remediation:
|
|
383
|
+
"Pass --cdb-password or set CENTRS_CDB_PASSWORD to a non-empty CDB password.",
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export interface EncryptCdbOptions extends LoadCdbOptions {
|
|
389
|
+
/**
|
|
390
|
+
* Skip the plaintext pre-conversion snapshot. Existing backups are still
|
|
391
|
+
* retained untouched; without the snapshot a forgotten password is final.
|
|
392
|
+
*/
|
|
393
|
+
skipBackup?: boolean;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export interface DevicesEncryptData {
|
|
397
|
+
cdbFile: string;
|
|
398
|
+
encrypted: true;
|
|
399
|
+
/** False when the CDB was already encrypted and the password verified. */
|
|
400
|
+
changed: boolean;
|
|
401
|
+
recordCount: number;
|
|
402
|
+
/** Plaintext pre-conversion snapshot; absent when unchanged or `skipBackup`. */
|
|
403
|
+
backupPath?: string;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Explicitly convert an existing open CDB with a fresh salt and atomic replacement.
|
|
408
|
+
* Retain all backups: converting the active file does not protect old plaintext.
|
|
409
|
+
* Close WinBox and other writers first; this is not a cross-process transaction (#581).
|
|
410
|
+
* Already-encrypted files are verified with the supplied password and left untouched.
|
|
411
|
+
* `skipBackup` omits the new plaintext snapshot; it never deletes older backups.
|
|
412
|
+
*/
|
|
413
|
+
export async function encryptCdb(
|
|
414
|
+
options: EncryptCdbOptions,
|
|
415
|
+
): Promise<CentrsSuccessEnvelope<DevicesEncryptData, DevicesOperationMeta>> {
|
|
416
|
+
const settings = resolveDevicesSettings(options);
|
|
417
|
+
requireEncryptionPassword(settings);
|
|
418
|
+
const cdbFile = settings.cdbFile.value;
|
|
419
|
+
// Pin even the default path so conversion never implicitly creates a file.
|
|
420
|
+
const cdb = await loadCdb({ ...options, cdbFile });
|
|
421
|
+
let backupPath: string | undefined;
|
|
422
|
+
if (!cdb.encrypted) {
|
|
423
|
+
try {
|
|
424
|
+
const written = await writeWinBoxCdb(
|
|
425
|
+
cdbFile,
|
|
426
|
+
cdb.entries.map((entry) => entry.record),
|
|
427
|
+
{
|
|
428
|
+
encryptWith: { password: settings.cdbPassword.value },
|
|
429
|
+
backupRetention: -1,
|
|
430
|
+
skipBackup: options.skipBackup === true,
|
|
431
|
+
},
|
|
432
|
+
);
|
|
433
|
+
backupPath = written.backupPath;
|
|
434
|
+
} catch (cause) {
|
|
435
|
+
throw new CentrsError({
|
|
436
|
+
code: "cdb/encrypt-failed",
|
|
437
|
+
summary: `Could not encrypt the CDB at ${cdbFile}.`,
|
|
438
|
+
remediation:
|
|
439
|
+
"Check directory permissions and available disk space; retain the original and any backups before retrying.",
|
|
440
|
+
context: { cdbFile },
|
|
441
|
+
cause,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
ok: true,
|
|
447
|
+
data: {
|
|
448
|
+
cdbFile,
|
|
449
|
+
encrypted: true,
|
|
450
|
+
changed: !cdb.encrypted,
|
|
451
|
+
recordCount: cdb.entries.length,
|
|
452
|
+
...(backupPath ? { backupPath } : {}),
|
|
453
|
+
},
|
|
454
|
+
warnings: [
|
|
455
|
+
...cdb.warnings.filter(
|
|
456
|
+
(warning) => warning.code !== "cdb/password-not-needed",
|
|
457
|
+
),
|
|
458
|
+
{
|
|
459
|
+
code: "cdb/plaintext-backups",
|
|
460
|
+
message:
|
|
461
|
+
"Backups may contain plaintext credentials. Encryption protects only the active CDB; no backups were deleted or encrypted.",
|
|
462
|
+
},
|
|
463
|
+
],
|
|
464
|
+
tips: [],
|
|
465
|
+
meta: devicesMeta("encrypt", settings),
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
336
469
|
export async function loadCdb(options: LoadCdbOptions): Promise<LoadedCdb> {
|
|
337
470
|
const settings = resolveDevicesSettings(options);
|
|
338
471
|
const warnings: DevicesWarning[] = [];
|
|
@@ -1859,6 +1992,18 @@ function renderText(envelope: DevicesEnvelope<unknown>): string {
|
|
|
1859
1992
|
? `created ${data.cdbFile}`
|
|
1860
1993
|
: `exists ${data.cdbFile} (${data.recordCount} record(s))`,
|
|
1861
1994
|
);
|
|
1995
|
+
lines.push(`encrypted: ${data.encrypted}`);
|
|
1996
|
+
break;
|
|
1997
|
+
}
|
|
1998
|
+
case "encrypt": {
|
|
1999
|
+
const data = envelope.data as DevicesEncryptData;
|
|
2000
|
+
lines.push(
|
|
2001
|
+
data.changed
|
|
2002
|
+
? `encrypted ${data.cdbFile}`
|
|
2003
|
+
: `verified encrypted ${data.cdbFile} (unchanged)`,
|
|
2004
|
+
);
|
|
2005
|
+
lines.push(`records: ${data.recordCount}`);
|
|
2006
|
+
if (data.backupPath) lines.push(`backup: ${data.backupPath}`);
|
|
1862
2007
|
break;
|
|
1863
2008
|
}
|
|
1864
2009
|
case "list":
|
package/src/execute-fanout.ts
CHANGED
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
} from "./execute.ts";
|
|
61
61
|
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
62
62
|
import {
|
|
63
|
+
assertNoMatchInFanout,
|
|
63
64
|
emptyWriteSelectionError,
|
|
64
65
|
expandSelection,
|
|
65
66
|
isDefaultRecordTarget,
|
|
@@ -206,6 +207,7 @@ export async function executeFanout(
|
|
|
206
207
|
internals: ExecuteFanoutInternals = {},
|
|
207
208
|
options: ExecuteFanoutOptions = {},
|
|
208
209
|
): Promise<ExecuteFanoutEnvelope> {
|
|
210
|
+
assertNoMatchInFanout(request.match);
|
|
209
211
|
// Validate + canonicalize the command ONCE, before selection expansion: a bad
|
|
210
212
|
// command shape is an outer error regardless of how many targets resolve, and
|
|
211
213
|
// write-ness is target-independent.
|
package/src/execute.ts
CHANGED
|
@@ -120,6 +120,12 @@ export interface ExecuteRequest {
|
|
|
120
120
|
maxResultsBytes?: number;
|
|
121
121
|
cdbFile?: string;
|
|
122
122
|
cdbPassword?: string;
|
|
123
|
+
/**
|
|
124
|
+
* `--match` selector for a `<router>` several CDB records share
|
|
125
|
+
* (`user=<name>`, `target=<addr>`, or a record type): the same selector
|
|
126
|
+
* `devices show --match` takes. Single-target only (#583).
|
|
127
|
+
*/
|
|
128
|
+
match?: string;
|
|
123
129
|
/** Opt-in host ARP resolution for a MAC target (`none` default, or `arp`). */
|
|
124
130
|
resolve?: string;
|
|
125
131
|
stdinIsTty?: boolean;
|
|
@@ -367,6 +373,7 @@ export async function resolveExecuteRequest(
|
|
|
367
373
|
targetInput: request.targetInput,
|
|
368
374
|
cdbFile: request.cdbFile,
|
|
369
375
|
cdbPassword: request.cdbPassword,
|
|
376
|
+
match: request.match,
|
|
370
377
|
},
|
|
371
378
|
env,
|
|
372
379
|
config,
|
package/src/explain/blocks.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Block / scope-brace classification for `explain` (centrs canonicalizer).
|
|
3
3
|
*
|
|
4
|
-
* Ratified by the phase-0 lab, question Q2 (#185)
|
|
5
|
-
*
|
|
4
|
+
* Ratified by the phase-0 lab, question Q2 (#185); the lab probe it was
|
|
5
|
+
* promoted from is history (see #185), not a runnable method. Q2 asks: can an offline,
|
|
6
6
|
* schema-free brace tracker tell a `{…}` SCOPE (`do=`/`else=`/`on-error=`, which
|
|
7
7
|
* IL lowers to `name=;(evl …)`) from a `name={…}` LITERAL value (braces
|
|
8
8
|
* stripped, body kept as RAW text, never lowered)? Descending into a literal
|
package/src/explain/catalog.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Union of two first-order sources (#228):
|
|
10
10
|
*
|
|
11
11
|
* 1. MikroTik's published CLI Reference (`https://manual.mikrotik.com/docs/cli-reference/`),
|
|
12
|
-
* 1,
|
|
12
|
+
* 1,109 pages, 1,116 entries — first-order about the definition
|
|
13
13
|
* structs and their build-time gates. Since #285 every page is a leaf whose
|
|
14
14
|
* slug is the CLI path, so no spelling has to be rewritten to be looked up.
|
|
15
15
|
* 2. 8 pinned restraml `/console/inspect` trees
|
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
*
|
|
29
29
|
* | Kind | Total | `both` | `inspect` | `published` |
|
|
30
30
|
* | ---- | ----- | ------ | --------- | ----------- |
|
|
31
|
-
* | `menu` |
|
|
32
|
-
* | `command` |
|
|
33
|
-
* | `settings` |
|
|
31
|
+
* | `menu` | 571 | 457 | 67 | 47 |
|
|
32
|
+
* | `command` | 483 | 436 | 0 | 47 |
|
|
33
|
+
* | `settings` | 122 | 112 | 0 | 10 |
|
|
34
34
|
*
|
|
35
35
|
* Zero navigation-vs-command contradictions between the two sources. Generation
|
|
36
36
|
* aborts if that ever stops holding, rather than picking a winner. Where a path
|
|
@@ -49,9 +49,9 @@
|
|
|
49
49
|
*
|
|
50
50
|
* **Gates conjoin down a path, so read them with {@link effectiveGates}, not
|
|
51
51
|
* row by row.** A row states only what the publication stated at that entry.
|
|
52
|
-
* Read row-wise,
|
|
52
|
+
* Read row-wise, 7 published-only paths look ungated; read with
|
|
53
53
|
* ancestry, the residue carrying no published explanation for its absence at
|
|
54
|
-
* all is
|
|
54
|
+
* all is 7.
|
|
55
55
|
*
|
|
56
56
|
* Tree COMMANDS are not enumerated — those are the generic CRUD leaves
|
|
57
57
|
* `verbs.ts` already owns. The command rows here are the published,
|
|
@@ -91,7 +91,7 @@ export interface CatalogEntry {
|
|
|
91
91
|
* `path|kind|provenance|package|conditions|syscap` — with trailing empty
|
|
92
92
|
* columns trimmed. Lower-cased, slash-led and sorted by path.
|
|
93
93
|
*
|
|
94
|
-
* Text rather than ~1,
|
|
94
|
+
* Text rather than ~1,176 object literals so that one path is one line in a
|
|
95
95
|
* review diff, and so the formatter has nothing to re-wrap.
|
|
96
96
|
*/
|
|
97
97
|
const ROWS = `
|
|
@@ -101,6 +101,7 @@ const ROWS = `
|
|
|
101
101
|
/app/remove|command|both|container||app
|
|
102
102
|
/app/restart|command|both|container||app
|
|
103
103
|
/app/settings|settings|both|container||app
|
|
104
|
+
/app/settings/remove-certificate|command|published|container||app
|
|
104
105
|
/app/setup|command|both|container||app
|
|
105
106
|
/app/update|command|both|container||app
|
|
106
107
|
/beep|command|both
|
|
@@ -160,9 +161,9 @@ const ROWS = `
|
|
|
160
161
|
/cmr/alert|menu|both|cmr
|
|
161
162
|
/cmr/alert/show-devices|command|both|cmr
|
|
162
163
|
/cmr/alert/test|command|both|cmr
|
|
163
|
-
/cmr/client|settings|both||!smips
|
|
164
|
-
/cmr/client/forget|command|both||!smips
|
|
165
|
-
/cmr/client/pair|command|both||!smips
|
|
164
|
+
/cmr/client|settings|both||!smips
|
|
165
|
+
/cmr/client/forget|command|both||!smips
|
|
166
|
+
/cmr/client/pair|command|both||!smips
|
|
166
167
|
/cmr/client/push-button|command|both
|
|
167
168
|
/cmr/device|menu|both|cmr
|
|
168
169
|
/cmr/device/apptraffic|command|both|cmr
|
|
@@ -485,6 +486,12 @@ const ROWS = `
|
|
|
485
486
|
/interface/wifi/dashboard/show-logs|command|both
|
|
486
487
|
/interface/wifi/datapath|menu|both
|
|
487
488
|
/interface/wifi/devel|command|both
|
|
489
|
+
/interface/wifi/easymesh|settings|published
|
|
490
|
+
/interface/wifi/easymesh/bss|menu|published
|
|
491
|
+
/interface/wifi/easymesh/dpp-trigger|command|published
|
|
492
|
+
/interface/wifi/easymesh/sta|menu|published
|
|
493
|
+
/interface/wifi/easymesh/topology|menu|published
|
|
494
|
+
/interface/wifi/easymesh/wps-push-button|command|published
|
|
488
495
|
/interface/wifi/flat-snoop|command|both
|
|
489
496
|
/interface/wifi/frequency-scan|command|both
|
|
490
497
|
/interface/wifi/interworking|menu|both
|
|
@@ -1095,6 +1102,9 @@ const ROWS = `
|
|
|
1095
1102
|
/system/resource/cpu|menu|both
|
|
1096
1103
|
/system/resource/hardware|menu|both||!powerpc, !smips
|
|
1097
1104
|
/system/resource/hardware/authorize|command|both||!powerpc, !smips
|
|
1105
|
+
/system/resource/hardware/power-cycle|command|published||!powerpc, !smips|usb-hub
|
|
1106
|
+
/system/resource/hardware/power-reset|command|published||!powerpc, !smips, !i386
|
|
1107
|
+
/system/resource/hardware/reset-overcurrent|command|published||!powerpc, !smips|usb-hub
|
|
1098
1108
|
/system/resource/hardware/usb-power-reset|command|both||!powerpc, !smips, i386
|
|
1099
1109
|
/system/resource/hardware/usb-settings|settings|both||!powerpc, !smips
|
|
1100
1110
|
/system/resource/irq|menu|both
|
|
@@ -1373,8 +1383,8 @@ export interface CatalogGate {
|
|
|
1373
1383
|
* parent's `syscap` applies even though the child entry states none: the gates
|
|
1374
1384
|
* up a path CONJOIN, they do not override.
|
|
1375
1385
|
*
|
|
1376
|
-
* Read row-wise,
|
|
1377
|
-
* ancestry the residue is
|
|
1386
|
+
* Read row-wise, 7 published-only paths look ungated; read with
|
|
1387
|
+
* ancestry the residue is 7. The gap between the two is the
|
|
1378
1388
|
* conjunction, and it is what a caller has to reproduce: a child's silence about
|
|
1379
1389
|
* a gate is not the absence of one. #228's finding — that a published-only path
|
|
1380
1390
|
* almost always explains its own absence — is about the ancestry-aware number.
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { buildTip, type Tip } from "../core/envelope.ts";
|
|
2
|
+
import { type Segment, segmentStatements } from "./segment.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pasted console-prompt repair (#370).
|
|
6
|
+
*
|
|
7
|
+
* Recognizes two grounded prompt shapes at the start of a *statement* (not a
|
|
8
|
+
* raw line, so prompt-looking text inside strings and comments stays legal):
|
|
9
|
+
*
|
|
10
|
+
* - `[user@host] <menu>> <command>` — e.g. `[admin@MikroTik] > /ip/address print`
|
|
11
|
+
* at the root, or `[admin@MikroTik] /ip/address> add …` inside a menu, the
|
|
12
|
+
* shape the manual's Console page shows; a HotLock prompt doubles the `>`
|
|
13
|
+
* - `/menu/path> <command>` — e.g. `/interface/lte> monitor lte1`
|
|
14
|
+
*
|
|
15
|
+
* Segmentation already split the document on real statement boundaries with
|
|
16
|
+
* string/comment awareness, so testing each segment's start is a lexical
|
|
17
|
+
* position, not a line-start regex. A prompt-looking line inside a multiline
|
|
18
|
+
* string is interior to its segment and never at a segment start; comment
|
|
19
|
+
* lines never become segments at all.
|
|
20
|
+
*
|
|
21
|
+
* Severity / envelope behavior (chosen, documented here per #370 scope):
|
|
22
|
+
* the tip is ADVISORY only and never changes `data.verdict`. The bracket
|
|
23
|
+
* shape already fails via `invalid-at-sign` (the `@` is a hard fault); the
|
|
24
|
+
* menu shape stays `warn` (abstention — offline cannot prove the menu
|
|
25
|
+
* exists on any given device). Output banners (`Flags:`, `Columns:`) and
|
|
26
|
+
* bare `>` forms are deliberately NOT detected here; they are separately
|
|
27
|
+
* grounded cases, not automatic extensions.
|
|
28
|
+
*
|
|
29
|
+
* The stripped candidate is offered as repair TEXT only and is never adopted
|
|
30
|
+
* or executed: tips carry no execution semantics. For the menu shape the
|
|
31
|
+
* candidate is the literal text after `>` (it runs in that menu's context
|
|
32
|
+
* on the device, not from the root).
|
|
33
|
+
*
|
|
34
|
+
* The candidate is the prompt-stripped tail of the ORIGINAL input — separators,
|
|
35
|
+
* blocks and newlines preserved verbatim — bounded by the next pasted prompt
|
|
36
|
+
* or the end of input, so each prompt in a transcript gets its own repair.
|
|
37
|
+
* Reconstructing from segment texts would flatten `{ ... }` blocks and drop
|
|
38
|
+
* whatever follows them. The slice needs analyzed-byte offsets as JS indices,
|
|
39
|
+
* which hold only when the segmentation reports `ascii`; otherwise the
|
|
40
|
+
* per-segment candidate stands (narrow, never wrong).
|
|
41
|
+
*
|
|
42
|
+
* No two adjacent quantifiers in BRACKET_PROMPT_RE can match the same bytes
|
|
43
|
+
* (the user part stops at the first `@`; the menu's leading space lives inside
|
|
44
|
+
* its optional group), so a near-miss fails in linear time.
|
|
45
|
+
*/
|
|
46
|
+
const BRACKET_PROMPT_RE =
|
|
47
|
+
/^\[[^[\]\r\n@]*@[^[\]\r\n]*\](?:\s*(\/[A-Za-z0-9_\-/]*))?\s*>>?\s*([\s\S]*)$/;
|
|
48
|
+
const MENU_PROMPT_RE = /^(\/[A-Za-z0-9_\-/]+)\s*>>?\s*([\s\S]*)$/;
|
|
49
|
+
|
|
50
|
+
export interface PastedPromptHit {
|
|
51
|
+
shape: "bracket" | "menu";
|
|
52
|
+
menu?: string;
|
|
53
|
+
candidate: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface StrippedPrompt {
|
|
57
|
+
shape: "bracket" | "menu";
|
|
58
|
+
menu?: string;
|
|
59
|
+
/** Length of the prompt prefix within the segment text. */
|
|
60
|
+
promptEnd: number;
|
|
61
|
+
candidate: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** True when re-segmenting `text` reports an unbalanced delimiter. */
|
|
65
|
+
function hasUnbalancedDelimiters(text: string): boolean {
|
|
66
|
+
try {
|
|
67
|
+
return segmentStatements(text).defects.some(
|
|
68
|
+
(d) => d.code === "unbalanced-close" || d.code === "unclosed",
|
|
69
|
+
);
|
|
70
|
+
} catch {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function detectPastedPrompts(input: string): PastedPromptHit[] {
|
|
76
|
+
const hits: PastedPromptHit[] = [];
|
|
77
|
+
let segments: Segment[];
|
|
78
|
+
let ascii: boolean;
|
|
79
|
+
try {
|
|
80
|
+
const result = segmentStatements(input);
|
|
81
|
+
segments = result.segments;
|
|
82
|
+
ascii = result.ascii;
|
|
83
|
+
} catch {
|
|
84
|
+
return hits;
|
|
85
|
+
}
|
|
86
|
+
// Per-segment prompt match. A bare prompt with no command ("prompt-empty")
|
|
87
|
+
// earns no hit but still bounds the previous hit's region.
|
|
88
|
+
const stripped: (StrippedPrompt | "prompt-empty" | null)[] = segments.map(
|
|
89
|
+
(segment) => {
|
|
90
|
+
const text = segment.text;
|
|
91
|
+
if (text.length === 0) return null;
|
|
92
|
+
const bracket = BRACKET_PROMPT_RE.exec(text);
|
|
93
|
+
if (bracket !== null) {
|
|
94
|
+
const menu = bracket[1];
|
|
95
|
+
const group = bracket[2] ?? "";
|
|
96
|
+
return group.trim().length > 0
|
|
97
|
+
? {
|
|
98
|
+
shape: "bracket",
|
|
99
|
+
...(menu && menu !== "/" ? { menu } : {}),
|
|
100
|
+
promptEnd: text.length - group.length,
|
|
101
|
+
candidate: group.trim(),
|
|
102
|
+
}
|
|
103
|
+
: "prompt-empty";
|
|
104
|
+
}
|
|
105
|
+
const menu = MENU_PROMPT_RE.exec(text);
|
|
106
|
+
if (menu !== null) {
|
|
107
|
+
const group = menu[2] ?? "";
|
|
108
|
+
return group.trim().length > 0
|
|
109
|
+
? {
|
|
110
|
+
shape: "menu",
|
|
111
|
+
menu: menu[1],
|
|
112
|
+
promptEnd: text.length - group.length,
|
|
113
|
+
candidate: group.trim(),
|
|
114
|
+
}
|
|
115
|
+
: "prompt-empty";
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
for (let i = 0; i < segments.length; i++) {
|
|
121
|
+
const hit = stripped[i];
|
|
122
|
+
if (hit === null || hit === undefined || hit === "prompt-empty") continue;
|
|
123
|
+
let candidate = hit.candidate;
|
|
124
|
+
if (ascii) {
|
|
125
|
+
const segment = segments[i];
|
|
126
|
+
let end = input.length;
|
|
127
|
+
for (let j = i + 1; j < segments.length; j++) {
|
|
128
|
+
if (stripped[j] !== null && stripped[j] !== undefined) {
|
|
129
|
+
end = segments[j]?.start ?? end;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const sliced = input
|
|
134
|
+
.slice((segment?.start ?? 0) + hit.promptEnd, end)
|
|
135
|
+
.trim();
|
|
136
|
+
// The slice must not cross the prompt's enclosing container: it is
|
|
137
|
+
// kept only when it re-segments balanced, else the narrow
|
|
138
|
+
// per-segment candidate stands (`{ /menu> cmd }` would otherwise
|
|
139
|
+
// offer `cmd }`).
|
|
140
|
+
if (sliced.length > 0 && !hasUnbalancedDelimiters(sliced))
|
|
141
|
+
candidate = sliced;
|
|
142
|
+
}
|
|
143
|
+
hits.push({
|
|
144
|
+
shape: hit.shape,
|
|
145
|
+
...(hit.menu ? { menu: hit.menu } : {}),
|
|
146
|
+
candidate,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return hits;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A markdown code span that survives backticks in `text`: the fence is one
|
|
154
|
+
* longer than the longest backtick run inside, padded when `text` starts or
|
|
155
|
+
* ends with one (CommonMark code spans).
|
|
156
|
+
*/
|
|
157
|
+
function codeSpan(text: string): string {
|
|
158
|
+
const fence = "`".repeat(longestBacktickRun(text) + 1);
|
|
159
|
+
const pad = text.startsWith("`") || text.endsWith("`") ? " " : "";
|
|
160
|
+
return `${fence}${pad}${text}${pad}${fence}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* A fenced code block for multi-line text: a code span would render its
|
|
165
|
+
* newlines as spaces, and `:put 1` NEWLINE `:put 2` is not `:put 1 :put 2`.
|
|
166
|
+
*/
|
|
167
|
+
function codeBlock(text: string): string {
|
|
168
|
+
const fence = "`".repeat(Math.max(3, longestBacktickRun(text) + 1));
|
|
169
|
+
return `${fence}\n${text}\n${fence}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function longestBacktickRun(text: string): number {
|
|
173
|
+
let longest = 0;
|
|
174
|
+
for (const run of text.match(/`+/g) ?? [])
|
|
175
|
+
longest = Math.max(longest, run.length);
|
|
176
|
+
return longest;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function pastedPromptTips(input: string): Tip[] {
|
|
180
|
+
return detectPastedPrompts(input).map((hit) => {
|
|
181
|
+
const where = hit.menu ? ` (typed at ${codeSpan(hit.menu)})` : "";
|
|
182
|
+
const fix = /[\r\n]/.test(hit.candidate)
|
|
183
|
+
? `Drop the pasted prompt and try this${where} instead — this input was not executed:\n\n${codeBlock(hit.candidate)}`
|
|
184
|
+
: `Drop the pasted prompt and try ${codeSpan(hit.candidate)}${where} instead — this input was not executed.`;
|
|
185
|
+
if (hit.shape === "menu")
|
|
186
|
+
return buildTip(
|
|
187
|
+
"tip/explain-pasted-prompt",
|
|
188
|
+
`This looks like a pasted ${codeSpan(hit.menu ?? "")} menu prompt, not a command to run from the root.`,
|
|
189
|
+
fix,
|
|
190
|
+
);
|
|
191
|
+
const prompt = hit.menu ? `[user@host] ${hit.menu}>` : "[user@host] >";
|
|
192
|
+
return buildTip(
|
|
193
|
+
"tip/explain-pasted-prompt",
|
|
194
|
+
`This looks like a pasted console transcript (${codeSpan(prompt)}), not a command to run.`,
|
|
195
|
+
fix,
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Offline path resolution for `explain` (centrs canonicalizer).
|
|
3
3
|
*
|
|
4
|
-
* Ratified by the phase-0 lab, questions Q3 and Q4 (#185)
|
|
5
|
-
*
|
|
4
|
+
* Ratified by the phase-0 lab, questions Q3 and Q4 (#185); the lab probe
|
|
5
|
+
* it was promoted from is history (see #185), not a runnable method. Two surfaces:
|
|
6
6
|
*
|
|
7
7
|
* resolveStatements (Q4) — the fully-qualified canonical path of every
|
|
8
8
|
* statement in source order, tracking the persistent menu
|
package/src/explain/segment.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Statement segmenter for `explain` (centrs canonicalizer).
|
|
3
3
|
*
|
|
4
|
-
* Ratified by the phase-0 lab, question Q1 (#185)
|
|
5
|
-
*
|
|
4
|
+
* Ratified by the phase-0 lab, question Q1 (#185); the lab probe it was
|
|
5
|
+
* promoted from is history (see #185), not a runnable method. This is the first walker
|
|
6
6
|
* stage: a schema-free, device-free splitter of multi-statement RouterOS input
|
|
7
7
|
* into top-level statements, matching `(evl …)` sibling adjacency in `:parse`
|
|
8
8
|
* IL. It never throws; structural surprises are reported as located
|
package/src/explain/symbols.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Symbol scope resolution for `explain` (centrs canonicalizer).
|
|
3
3
|
*
|
|
4
|
-
* Ratified by the phase-0 lab, question Q13 (#185)
|
|
5
|
-
*
|
|
4
|
+
* Ratified by the phase-0 lab, question Q13 (#185); the lab probe it was
|
|
5
|
+
* promoted from is history (see #185), not a runnable method. Q13 asks whether an
|
|
6
6
|
* offline, schema-free reader can reproduce the class the console assigns to
|
|
7
7
|
* every variable occurrence — `variable-local` / `-global` / `-auto` /
|
|
8
8
|
* `-parameter` / `-undefined` in `/console/inspect request=highlight`.
|
package/src/explain/verbsplit.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Verb / menu boundary resolution for `explain` (centrs canonicalizer).
|
|
3
3
|
*
|
|
4
|
-
* Ratified by the phase-0 lab, question Q6 (#185)
|
|
5
|
-
*
|
|
4
|
+
* Ratified by the phase-0 lab, question Q6 (#185); the lab probe it was
|
|
5
|
+
* promoted from is history (see #185), not a runnable method. Q6 asks the residual the
|
|
6
6
|
* path resolver (Q3/Q4) deliberately left open: within a statement's leading
|
|
7
7
|
* path run, where does the MENU end and the VERB begin — and when must offline
|
|
8
8
|
* refuse to guess? `pathresolve.ts` reports a greedy full-run `path` plus the
|
package/src/explain/write.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Offline write-shape inference for `explain` — `structure.containsWrite`.
|
|
3
3
|
*
|
|
4
|
-
* Ratified by the phase-0 lab, question Q16 (#185)
|
|
5
|
-
*
|
|
4
|
+
* Ratified by the phase-0 lab, question Q16 (#185); the lab probe it was
|
|
5
|
+
* promoted from is history (see #185), not a runnable method. Q16 asked what shape the
|
|
6
6
|
* field should have and answered that the spec's sketched **boolean is unsafe**:
|
|
7
7
|
* a fail-closed `true | false | unknown` reaches 100% precision on what it
|
|
8
8
|
* decides (226/226 holdout, 469/469 dev) with zero false negatives, abstaining
|
package/src/explain.ts
CHANGED
|
@@ -145,6 +145,7 @@ import { escapeSpans } from "./explain/escape-tokens.ts";
|
|
|
145
145
|
import { findGroupFaults, type GroupFault } from "./explain/group-faults.ts";
|
|
146
146
|
import { isKnownMenuPath } from "./explain/is-known-menu.ts";
|
|
147
147
|
import { operatorSpans } from "./explain/operator-tokens.ts";
|
|
148
|
+
import { pastedPromptTips } from "./explain/pasted-prompt.ts";
|
|
148
149
|
import { type PathTokenCandidate, pathSpans } from "./explain/path-tokens.ts";
|
|
149
150
|
import {
|
|
150
151
|
type Resolution,
|
|
@@ -2871,6 +2872,7 @@ export function explainEnvelope(
|
|
|
2871
2872
|
"No router was given, so this is the canonicalizer's reading alone.",
|
|
2872
2873
|
"Add a router (`centrs explain <router> '<input>'`) for read-only parse, highlight and argument-name evidence; `--schema` and `--complete` then describe paths and complete at the cursor.",
|
|
2873
2874
|
),
|
|
2875
|
+
...pastedPromptTips(input),
|
|
2874
2876
|
...(options.tips ?? []),
|
|
2875
2877
|
];
|
|
2876
2878
|
const warnings: Warning[] = [...(options.warnings ?? [])];
|