@takodotid/circuit 0.18.5 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/dist/chunks/index-jtzfw679.js +936 -0
- package/dist/chunks/{index-ex1wcst7.js → index-xm9nma2t.js} +20 -1
- package/dist/chunks/server-5shmsmse.js +246 -0
- package/dist/cli/index.js +701 -106
- package/dist/index.js +1 -1
- package/dist/presets/index.js +1 -1
- package/dist/types/adapters/devices/derive.d.ts +2 -0
- package/dist/types/core/across.d.ts +4 -0
- package/dist/types/core/addr.d.ts +10 -0
- package/dist/types/core/addresses.d.ts +46 -0
- package/dist/types/core/files.d.ts +6 -0
- package/dist/types/core/graph.d.ts +74 -0
- package/dist/types/core/schema-docs.d.ts +16 -0
- package/package.json +1 -1
- package/src/adapters/devices/derive.ts +32 -0
- package/src/adapters/devices/raisecom-ros/index.ts +23 -3
- package/src/adapters/devices/routeros/index.ts +10 -0
- package/src/adapters/devices/vrp/index.ts +10 -1
- package/src/cli/graph.ts +89 -0
- package/src/cli/index.ts +205 -2
- package/src/cli/rename.ts +129 -0
- package/src/core/across.ts +322 -0
- package/src/core/addr.ts +29 -0
- package/src/core/addresses.ts +316 -0
- package/src/core/files.ts +30 -0
- package/src/core/graph.ts +634 -0
- package/src/core/schema-docs.ts +166 -0
- package/src/core/validate.ts +2 -52
- package/src/studio/data.ts +255 -0
- package/src/studio/git.ts +119 -0
- package/src/studio/public/app.js +147 -0
- package/src/studio/public/compare.js +243 -0
- package/src/studio/public/diff.js +97 -0
- package/src/studio/public/dom.js +108 -0
- package/src/studio/public/evaluate.js +648 -0
- package/src/studio/public/export.js +122 -0
- package/src/studio/public/favicon.svg +6 -0
- package/src/studio/public/graph.js +914 -0
- package/src/studio/public/index.html +67 -0
- package/src/studio/public/net.js +128 -0
- package/src/studio/public/style.css +693 -0
- package/src/studio/public/tables.js +1223 -0
- package/src/studio/public/tools.js +578 -0
- package/src/studio/server.ts +218 -0
- package/templates/project/AGENTS.md +12 -9
- package/templates/project/README.md +1 -0
package/src/cli/index.ts
CHANGED
|
@@ -3,18 +3,24 @@
|
|
|
3
3
|
|
|
4
4
|
import { createPrivateKey, createPublicKey } from "node:crypto";
|
|
5
5
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
6
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
7
7
|
import { adapters } from "../adapters/devices";
|
|
8
8
|
import type { ApplyOptions, DeviceAdapter, Plan } from "../adapters/devices/types";
|
|
9
9
|
import { registries } from "../adapters/registries";
|
|
10
10
|
import { planLibreNms, sendLibreNms } from "../adapters/registries/librenms";
|
|
11
11
|
import { planPeeringDb, sendPeeringDb } from "../adapters/registries/peeringdb";
|
|
12
12
|
import { prometheusTargets, snmpExporterAuths } from "../adapters/registries/prometheus";
|
|
13
|
-
import { network as prefixOf } from "../core/addr";
|
|
13
|
+
import { isCidr, network as prefixOf } from "../core/addr";
|
|
14
|
+
import { addressesOf, freeOfLength, type Segment } from "../core/addresses";
|
|
14
15
|
import type { Network } from "../core/define";
|
|
16
|
+
import { type Annotated, type Drift, graphOf, LAYERS, only, type Layer } from "../core/graph";
|
|
15
17
|
import { isSecret, referenceOf, resolve as reveal, sourceOf, useRoot } from "../core/secrets";
|
|
16
18
|
import { validate } from "../core/validate";
|
|
17
19
|
import type { Device } from "../schema";
|
|
20
|
+
import { dot, mermaid } from "./graph";
|
|
21
|
+
import { applyRename, planRename, type RenamePlan } from "./rename";
|
|
22
|
+
import { deviceFiles, projectSources } from "../core/files";
|
|
23
|
+
import { studioData } from "../studio/data";
|
|
18
24
|
|
|
19
25
|
const HELP = `usage: circuit <command> [device...] [options]
|
|
20
26
|
|
|
@@ -35,6 +41,22 @@ const HELP = `usage: circuit <command> [device...] [options]
|
|
|
35
41
|
prometheus every device with SNMP, as Prometheus targets for snmp_exporter
|
|
36
42
|
--auths snmp_exporter's auths for them instead, with secrets as \${NAME}
|
|
37
43
|
secrets every secret the config uses, and whether each can be read
|
|
44
|
+
rename <device> <new-name>
|
|
45
|
+
give a device a new name in every file that names it, with --confirm
|
|
46
|
+
--file also rename the file that declares it, to <new-name>.ts
|
|
47
|
+
addresses [prefix] every address the config uses, by prefix, and what is still free
|
|
48
|
+
--length=N free prefixes of this length, instead of the fewest that cover the free space
|
|
49
|
+
--format=json as JSON
|
|
50
|
+
graph [device...] the network as a graph, from the config alone, without contacting a device
|
|
51
|
+
--layer=l1 l1, l2, l3, bgp, ospf or tunnels, several with commas, or all
|
|
52
|
+
--format=mermaid mermaid, dot or json
|
|
53
|
+
studio Circuit Studio: the network in the browser, drawn again whenever the config changes
|
|
54
|
+
--port=4600 the port it listens on
|
|
55
|
+
--no-open do not open the browser
|
|
56
|
+
--host=0.0.0.0 listen on a server's address instead, with --allow-host
|
|
57
|
+
--allow-host=studio.example.com
|
|
58
|
+
the names it is reached by, several with commas
|
|
59
|
+
--json print what Studio shows, as JSON, instead
|
|
38
60
|
|
|
39
61
|
--config <path> the network's config file, circuit.config.ts in the working directory by default`;
|
|
40
62
|
|
|
@@ -395,6 +417,183 @@ ${references.length} secret(s), ${missing} missing.`);
|
|
|
395
417
|
process.exit(missing ? 1 : 0);
|
|
396
418
|
}
|
|
397
419
|
|
|
420
|
+
/** The value of `--name=value`, or undefined when it is not given. */
|
|
421
|
+
const flagValue = (name: string) => flags.find((flag) => flag.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
422
|
+
|
|
423
|
+
/** How a device compares with its last snapshot, offline, as `diff` plans it. */
|
|
424
|
+
function driftOf(device: Device): Drift {
|
|
425
|
+
const path = statePath(device);
|
|
426
|
+
if (!existsSync(path)) return { status: "no snapshot", lines: [] };
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
const adapter = adapterOf(device);
|
|
430
|
+
const plan = adapter.plan(adapter.render(device), readFileSync(path, "utf8"), options);
|
|
431
|
+
|
|
432
|
+
const lines = [
|
|
433
|
+
...plan.steps.flatMap((step) => [step.title, ...step.show.map((line) => ` ${line}`)]),
|
|
434
|
+
...plan.problems.map((problem) => `PROBLEM ${problem}`),
|
|
435
|
+
];
|
|
436
|
+
|
|
437
|
+
return { status: lines.length ? "differs" : "matches", lines };
|
|
438
|
+
} catch (error) {
|
|
439
|
+
return { status: "cannot plan", lines: [(error as Error).message] };
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** The graph on these layers, around these devices when any are named, with what validate found and how each device drawn compares with its snapshot. */
|
|
444
|
+
function annotatedGraph(layers: readonly Layer[], deviceNames: readonly string[]): Annotated {
|
|
445
|
+
const graph = only(graphOf(network.devices), layers, deviceNames);
|
|
446
|
+
const shown = new Set(graph.nodes.map((node) => node.label));
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
...graph,
|
|
450
|
+
findings: validate(network).filter((finding) => !finding.device || shown.has(finding.device)),
|
|
451
|
+
drift: Object.fromEntries(
|
|
452
|
+
network.devices.filter((device) => shown.has(device.name)).map((device) => [device.name, driftOf(device)])
|
|
453
|
+
),
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function graphCommand(): void {
|
|
458
|
+
const requested = (flagValue("layer") ?? "l1").split(",");
|
|
459
|
+
const layers = requested.includes("all") ? [...LAYERS] : (requested as Layer[]);
|
|
460
|
+
|
|
461
|
+
const unknown = layers.filter((layer) => !LAYERS.includes(layer));
|
|
462
|
+
if (unknown.length) fail(`no layer ${unknown.join(", ")}; known: ${LAYERS.join(", ")}, all`);
|
|
463
|
+
|
|
464
|
+
const format = flagValue("format") ?? "mermaid";
|
|
465
|
+
const devices = selected(true);
|
|
466
|
+
const annotated = annotatedGraph(layers, names.length ? devices.map((device) => device.name) : []);
|
|
467
|
+
|
|
468
|
+
if (format === "mermaid") process.stdout.write(mermaid(annotated));
|
|
469
|
+
else if (format === "dot") process.stdout.write(dot(annotated));
|
|
470
|
+
else if (format === "json") console.log(JSON.stringify(annotated, null, 4));
|
|
471
|
+
else fail(`no format ${format}; known: mermaid, dot, json`);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** One segment and what uses each address in it. */
|
|
475
|
+
function printSegment(segment: Segment, indent: string): void {
|
|
476
|
+
const next = segment.next ? `, next ${segment.next}` : "";
|
|
477
|
+
console.log(`${indent}${segment.prefix}, ${segment.free} free${next}`);
|
|
478
|
+
|
|
479
|
+
const width = Math.max(...segment.uses.map((use) => use.address.length)) + 2;
|
|
480
|
+
for (const use of segment.uses) console.log(`${indent} ${use.address.padEnd(width)}${use.what}`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function addressesCommand(): void {
|
|
484
|
+
const [within] = names;
|
|
485
|
+
if (within && !isCidr(within)) fail(`${within} is not a prefix, such as 10.0.0.0/16`);
|
|
486
|
+
|
|
487
|
+
const lengthText = flagValue("length");
|
|
488
|
+
const length = lengthText === undefined ? undefined : Number(lengthText);
|
|
489
|
+
if (length !== undefined && !(Number.isInteger(length) && length >= 0 && length <= 128))
|
|
490
|
+
fail("--length takes a prefix length, such as 24");
|
|
491
|
+
|
|
492
|
+
const report = addressesOf(network.devices, within);
|
|
493
|
+
|
|
494
|
+
if (flagValue("format") === "json") {
|
|
495
|
+
console.log(JSON.stringify(report, null, 4));
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
for (const space of report.spaces) {
|
|
500
|
+
console.log(`${space.prefix}, ${space.owner}`);
|
|
501
|
+
for (const segment of space.segments) printSegment(segment, " ");
|
|
502
|
+
for (const block of space.blocks) console.log(` ${block.prefix} ${block.what}`);
|
|
503
|
+
|
|
504
|
+
if (length === undefined) {
|
|
505
|
+
console.log(` free: ${space.free.join(", ") || "none"}`);
|
|
506
|
+
} else {
|
|
507
|
+
const { prefixes, total } = freeOfLength(space.free, length, 16);
|
|
508
|
+
const more = total > BigInt(prefixes.length) ? `, and ${total - BigInt(prefixes.length)} more` : "";
|
|
509
|
+
console.log(` free /${length}: ${prefixes.join(", ") || "none"}${more}`);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
console.log("");
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const { segments, blocks } = report.elsewhere;
|
|
516
|
+
if (segments.length || blocks.length) {
|
|
517
|
+
console.log("Outside the space the network originates");
|
|
518
|
+
for (const segment of segments) printSegment(segment, " ");
|
|
519
|
+
for (const block of blocks) console.log(` ${block.prefix} ${block.what}`);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (!report.spaces.length && !segments.length && !blocks.length) console.log("The config holds no addresses.");
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function renameCommand(): void {
|
|
526
|
+
const [from, to] = names;
|
|
527
|
+
if (!from || !to) fail("usage: circuit rename <device> <new-name> [--file] [--confirm]");
|
|
528
|
+
|
|
529
|
+
const device = network.devices.find((candidate) => candidate.name === from);
|
|
530
|
+
if (!device) fail(`no device ${from}; known: ${network.devices.map((candidate) => candidate.name).join(", ")}`);
|
|
531
|
+
if (network.devices.some((candidate) => candidate.name === to)) fail(`${to} is already a device`);
|
|
532
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(to)) fail(`${to} is not a hostname: letters, digits, dots, dashes and underscores`);
|
|
533
|
+
|
|
534
|
+
const snapshot = statePath(device);
|
|
535
|
+
const snapshots = existsSync(snapshot) ? [relative(root, snapshot)] : [];
|
|
536
|
+
|
|
537
|
+
let plan: RenamePlan;
|
|
538
|
+
try {
|
|
539
|
+
plan = planRename(root, from, to, { file: flags.includes("--file"), snapshots });
|
|
540
|
+
} catch (error) {
|
|
541
|
+
fail((error as Error).message);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
for (const change of plan.changes) {
|
|
545
|
+
console.log(`${change.file}:${change.line}`);
|
|
546
|
+
console.log(` - ${change.before.trim()}`);
|
|
547
|
+
console.log(` + ${change.after.trim()}`);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
for (const move of plan.moves) console.log(`move ${move.from} to ${move.to}`);
|
|
551
|
+
|
|
552
|
+
if (plan.mentions.length) {
|
|
553
|
+
console.log(`\nStill naming ${from}, left as they are: change them by hand if they mean the device.`);
|
|
554
|
+
for (const mention of plan.mentions) console.log(` ${mention.file}:${mention.line} ${mention.text.slice(0, 160)}`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (!confirmed) {
|
|
558
|
+
console.log("\nNothing written. Re-run with --confirm.");
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
applyRename(root, plan);
|
|
563
|
+
console.log(`\nRenamed. Run circuit validate, then circuit apply ${to}: the device keeps its old hostname until then.`);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function studioCommand(): Promise<void> {
|
|
567
|
+
if (flags.includes("--json")) {
|
|
568
|
+
const sources = projectSources(root);
|
|
569
|
+
const data = studioData(network.devices, annotatedGraph(LAYERS, []), {
|
|
570
|
+
render: (device) => adapterOf(device).render(device),
|
|
571
|
+
snapshot: (device) => (existsSync(statePath(device)) ? readFileSync(statePath(device), "utf8") : undefined),
|
|
572
|
+
files: (device) => ({
|
|
573
|
+
// Written with forward slashes on every system, as git and the page take them.
|
|
574
|
+
source: deviceFiles(root, device.name, sources)[0]?.split(sep).join("/"),
|
|
575
|
+
snapshot: existsSync(statePath(device)) ? relative(root, statePath(device)).split(sep).join("/") : undefined,
|
|
576
|
+
}),
|
|
577
|
+
});
|
|
578
|
+
console.log(JSON.stringify(data));
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const port = Number(flagValue("port") ?? 4600);
|
|
583
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) fail("--port takes a number from 1 to 65535");
|
|
584
|
+
|
|
585
|
+
const { isLoopback, studio } = await import("../studio/server");
|
|
586
|
+
const host = flagValue("host") ?? "127.0.0.1";
|
|
587
|
+
const allowHosts = (flagValue("allow-host") ?? "").split(",").filter(Boolean);
|
|
588
|
+
|
|
589
|
+
// Off this machine, Studio must be told the names it is reached by: every other name is refused.
|
|
590
|
+
if (!isLoopback(host) && !allowHosts.length) {
|
|
591
|
+
fail(`Studio on ${host} needs --allow-host=<name>, the name people reach it by, such as studio.example.com`);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
await studio({ configPath, root, port, open: !flags.includes("--no-open"), host, allowHosts });
|
|
595
|
+
}
|
|
596
|
+
|
|
398
597
|
const commands: Record<string, () => void | Promise<void>> = {
|
|
399
598
|
validate: validateCommand,
|
|
400
599
|
build: buildCommand,
|
|
@@ -408,6 +607,10 @@ const commands: Record<string, () => void | Promise<void>> = {
|
|
|
408
607
|
librenms: librenmsCommand,
|
|
409
608
|
prometheus: prometheusCommand,
|
|
410
609
|
secrets: secretsCommand,
|
|
610
|
+
rename: renameCommand,
|
|
611
|
+
addresses: addressesCommand,
|
|
612
|
+
graph: graphCommand,
|
|
613
|
+
studio: studioCommand,
|
|
411
614
|
};
|
|
412
615
|
|
|
413
616
|
const run = commands[command];
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// `circuit rename`: a device's new name everywhere the project's files name it, its snapshot renamed to match, and with `--file` its file too. It plans first and writes only with --confirm.
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
5
|
+
import { declaresDevice, projectSources } from "../core/files";
|
|
6
|
+
|
|
7
|
+
/** One line that changes. */
|
|
8
|
+
export type LineChange = { file: string; line: number; before: string; after: string };
|
|
9
|
+
|
|
10
|
+
export type RenamePlan = {
|
|
11
|
+
changes: LineChange[];
|
|
12
|
+
/** Files that move, from and to, relative to the project. */
|
|
13
|
+
moves: { from: string; to: string }[];
|
|
14
|
+
/** Lines that still name the device inside a longer text, such as a description or a comment, which the rename leaves alone. */
|
|
15
|
+
mentions: { file: string; line: number; text: string }[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Plan renaming a device from `from` to `to` in the project at `root`.
|
|
22
|
+
*
|
|
23
|
+
* A string that is exactly the old name becomes the new name: `name: "edge-01"`, `link: { device: "edge-01", ... }`, a check's list of routers, and a key such as a tenant's ports by device, which would otherwise no longer be found. A BGP neighbor named after the device is renamed too, which renames its session on the device: `circuit diff` shows it.
|
|
24
|
+
*/
|
|
25
|
+
export function planRename(root: string, from: string, to: string, options: { file: boolean; snapshots: string[] }): RenamePlan {
|
|
26
|
+
const changes: LineChange[] = [];
|
|
27
|
+
const moves: RenamePlan["moves"] = [];
|
|
28
|
+
|
|
29
|
+
const exact = new RegExp(`(["'\`])${escape(from)}\\1`, "g");
|
|
30
|
+
const anywhere = new RegExp(`(^|[^A-Za-z0-9_.-])${escape(from)}(?![A-Za-z0-9_-])`);
|
|
31
|
+
|
|
32
|
+
const sources = projectSources(root);
|
|
33
|
+
const declaring = new Set<string>();
|
|
34
|
+
|
|
35
|
+
for (const file of sources) {
|
|
36
|
+
const text = readFileSync(join(root, file), "utf8");
|
|
37
|
+
const lines = text.split("\n");
|
|
38
|
+
|
|
39
|
+
// The device's own file calls defineDevice and gives it the name.
|
|
40
|
+
if (declaresDevice(text, from)) declaring.add(file);
|
|
41
|
+
|
|
42
|
+
for (const [index, before] of lines.entries()) {
|
|
43
|
+
const after = before.replace(exact, (_match, quote: string) => `${quote}${to}${quote}`);
|
|
44
|
+
if (after !== before) changes.push({ file, line: index + 1, before, after });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!declaring.size) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`no file declares name: "${from}"; the name may be built from parts, such as a template string, so rename it by hand`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const snapshot of options.snapshots) {
|
|
55
|
+
moves.push({ from: snapshot, to: join(dirname(snapshot), `${to}${extname(snapshot)}`) });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (options.file) {
|
|
59
|
+
if (declaring.size > 1) throw new Error(`${[...declaring].join(" and ")} both declare name: "${from}"; rename the file by hand`);
|
|
60
|
+
|
|
61
|
+
const [file] = [...declaring] as [string];
|
|
62
|
+
const target = join(dirname(file), `${to}${extname(file)}`);
|
|
63
|
+
if (existsSync(join(root, target))) throw new Error(`${target} already exists`);
|
|
64
|
+
|
|
65
|
+
moves.push({ from: file, to: target });
|
|
66
|
+
changes.push(...importChanges(root, sources, file, target, changes));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// What still names the device once every change is made.
|
|
70
|
+
const mentions = sources.flatMap((file) =>
|
|
71
|
+
readFileSync(join(root, file), "utf8")
|
|
72
|
+
.split("\n")
|
|
73
|
+
.flatMap((original, index) => {
|
|
74
|
+
const text = changes.find((change) => change.file === file && change.line === index + 1)?.after ?? original;
|
|
75
|
+
return anywhere.test(text) ? [{ file, line: index + 1, text: text.trim() }] : [];
|
|
76
|
+
})
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return { changes, moves, mentions };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Imports of a file that moves, pointed at its new name. Lines already changed are changed further. */
|
|
83
|
+
function importChanges(root: string, sources: string[], file: string, target: string, earlier: LineChange[]): LineChange[] {
|
|
84
|
+
const result: LineChange[] = [];
|
|
85
|
+
const withoutExtension = (path: string) => path.replace(/\.(ts|mts|cts|js|mjs|cjs)$/, "");
|
|
86
|
+
const moved = withoutExtension(resolve(root, file));
|
|
87
|
+
|
|
88
|
+
for (const source of sources) {
|
|
89
|
+
const lines = readFileSync(join(root, source), "utf8").split("\n");
|
|
90
|
+
|
|
91
|
+
for (const [index, original] of lines.entries()) {
|
|
92
|
+
const line = index + 1;
|
|
93
|
+
const changed = earlier.find((change) => change.file === source && change.line === line);
|
|
94
|
+
const before = changed?.after ?? original;
|
|
95
|
+
|
|
96
|
+
// A relative specifier that resolves to the moved file keeps its own form, extension or not.
|
|
97
|
+
const after = before.replace(
|
|
98
|
+
/(\bfrom\s+|\bimport\s*\(\s*)(["'])(\.{1,2}\/[^"']+)\2/g,
|
|
99
|
+
(match, lead: string, quote: string, specifier: string) => {
|
|
100
|
+
if (withoutExtension(resolve(root, dirname(source), specifier)) !== moved) return match;
|
|
101
|
+
|
|
102
|
+
const extension = specifier.match(/\.(ts|mts|cts|js|mjs|cjs)$/)?.[0] ?? "";
|
|
103
|
+
const renamed = `${dirname(specifier)}/${basename(withoutExtension(target))}${extension}`;
|
|
104
|
+
return `${lead}${quote}${renamed}${quote}`;
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
if (after === before) continue;
|
|
109
|
+
|
|
110
|
+
if (changed) changed.after = after;
|
|
111
|
+
else result.push({ file: source, line, before: original, after });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Write the plan: each changed line, then each move. */
|
|
119
|
+
export function applyRename(root: string, plan: RenamePlan): void {
|
|
120
|
+
const files = new Set(plan.changes.map((change) => change.file));
|
|
121
|
+
|
|
122
|
+
for (const file of files) {
|
|
123
|
+
const lines = readFileSync(join(root, file), "utf8").split("\n");
|
|
124
|
+
for (const change of plan.changes.filter((each) => each.file === file)) lines[change.line - 1] = change.after;
|
|
125
|
+
writeFileSync(join(root, file), lines.join("\n"));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const move of plan.moves) renameSync(join(root, move.from), join(root, move.to));
|
|
129
|
+
}
|