portal-agent-cli 1.0.1 → 3.0.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.
@@ -0,0 +1,75 @@
1
+ import { mkdir, readFile, readdir, writeFile, unlink } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ function dir() {
6
+ return join(homedir(), ".portal-agent", "sessions");
7
+ }
8
+
9
+ export function newSessionId() {
10
+ const d = new Date();
11
+ const p = function (n, w) { return String(n).padStart(w || 2, "0"); };
12
+ return [
13
+ d.getFullYear(),
14
+ p(d.getMonth() + 1),
15
+ p(d.getDate()),
16
+ "-",
17
+ p(d.getHours()),
18
+ p(d.getMinutes()),
19
+ p(d.getSeconds())
20
+ ].join("");
21
+ }
22
+
23
+ export async function saveSession(id, data) {
24
+ const d = dir();
25
+ await mkdir(d, { recursive: true });
26
+ const path = join(d, id + ".json");
27
+ await writeFile(path, JSON.stringify(data, null, 2), "utf8");
28
+ }
29
+
30
+ export async function loadSession(id) {
31
+ const path = join(dir(), id + ".json");
32
+ try {
33
+ const raw = await readFile(path, "utf8");
34
+ return JSON.parse(raw);
35
+ } catch (e) {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ export async function listSessions() {
41
+ const d = dir();
42
+ let names;
43
+ try {
44
+ names = await readdir(d);
45
+ } catch (e) {
46
+ return [];
47
+ }
48
+
49
+ const metas = [];
50
+ for (const n of names) {
51
+ if (!n.endsWith(".json")) continue;
52
+ const id = n.slice(0, -5);
53
+ const s = await loadSession(id);
54
+ if (!s) continue;
55
+ metas.push({
56
+ id: id,
57
+ ws: s.ws || "",
58
+ provider: s.provider || "",
59
+ turns: (s.turns || []).length,
60
+ updatedAt: s.updatedAt || 0
61
+ });
62
+ }
63
+ metas.sort(function (a, b) { return b.updatedAt - a.updatedAt; });
64
+ return metas;
65
+ }
66
+
67
+ export async function deleteSession(id) {
68
+ const path = join(dir(), id + ".json");
69
+ try {
70
+ await unlink(path);
71
+ return true;
72
+ } catch (e) {
73
+ return false;
74
+ }
75
+ }
package/lib/system.mjs CHANGED
@@ -1,24 +1,38 @@
1
- import { CATALOG } from "./tools.mjs";
1
+ import { CATALOG } from "./tools/index.mjs";
2
2
 
3
- export function buildSystem(ws) {
3
+ export function buildSystem(ws, instructions) {
4
4
  const shown = ws.split("\\").join("/");
5
- return [
5
+ const parts = [];
6
+
7
+ parts.push([
6
8
  "You are a coding agent working in a terminal.",
7
9
  "Workspace: " + shown,
8
- "Use forward slashes in every path, even on Windows.",
10
+ "",
11
+ "Rules:",
12
+ "1. Use forward slashes in every path, even on Windows.",
13
+ "2. Do not call the same tool with the same arguments twice.",
14
+ "3. Do not explore. Act. Only call tools you actually need.",
15
+ "4. Read a file before editing it. Never guess contents.",
16
+ "5. When a task is done, stop. Reply with plain prose, no tool calls.",
17
+ "6. If the request is ambiguous, ask one short question. Do not guess.",
18
+ "7. Keep replies short. One sentence is fine. No filler.",
19
+ "8. Prefer edit_file over write_file for changes to existing files."
20
+ ].join("\n"));
21
+
22
+ if (instructions) parts.push(instructions);
23
+
24
+ parts.push([
25
+ "Tool call format:",
9
26
  "",
10
27
  "To call a tool, write on its own line:",
11
28
  "portal-tool: {\"tool\":\"name\",\"args\":{\"key\":\"value\"}}",
12
29
  "",
13
- "The JSON must use double quotes around keys and values.",
14
- "Plain text only. Do not wrap the call in a code block.",
15
- "One call per action. Never repeat a call with the same arguments.",
16
- "",
17
- "If a tool returns an empty result, that is final. Report it.",
18
- "If unsure what to do, reply with plain prose and ask.",
19
- "When done, reply with plain prose and no tool calls.",
20
- "",
21
- "Available tools:",
22
- CATALOG
23
- ].join("\n");
30
+ "The JSON must use double quotes. Plain text only, no backticks.",
31
+ "One call per action. Never combine calls inside a code block.",
32
+ "Multiple calls in one reply are allowed, one per line."
33
+ ].join("\n"));
34
+
35
+ parts.push("Available tools:\n" + CATALOG);
36
+
37
+ return parts.join("\n\n");
24
38
  }
package/lib/theme.mjs ADDED
@@ -0,0 +1,56 @@
1
+ const E = String.fromCharCode(27);
2
+
3
+ const MODE = process.env.NO_COLOR ? "plain" : "color";
4
+ const FORCE = process.env.PORTAL_THEME || "auto";
5
+
6
+ function c(code) {
7
+ if (MODE === "plain") return "";
8
+ return E + "[" + code + "m";
9
+ }
10
+
11
+ const R = c(0);
12
+ const B = c(1);
13
+ const D = c(90);
14
+ const RED = c(31);
15
+ const GRN = c(32);
16
+ const YEL = c(33);
17
+ const BLU = c(34);
18
+ const MAG = c(35);
19
+ const CYN = c(36);
20
+ const BGRN = c(92);
21
+ const BRED = c(91);
22
+ const BCYN = c(96);
23
+
24
+ function wrap(prefix, s) {
25
+ return prefix + String(s) + R;
26
+ }
27
+
28
+ export const theme = {
29
+ reset: R,
30
+ bold: function (s) { return wrap(B, s); },
31
+ dim: function (s) { return wrap(D, s); },
32
+ red: function (s) { return wrap(RED, s); },
33
+ error: function (s) { return wrap(BRED, s); },
34
+ green: function (s) { return wrap(GRN, s); },
35
+ ok: function (s) { return wrap(BGRN, s); },
36
+ yellow: function (s) { return wrap(YEL, s); },
37
+ warn: function (s) { return wrap(YEL, s); },
38
+ blue: function (s) { return wrap(BLU, s); },
39
+ magenta: function (s) { return wrap(MAG, s); },
40
+ cyan: function (s) { return wrap(BCYN, s); },
41
+ accent: function (s) { return wrap(CYN, s); },
42
+ user: function (s) { return wrap(BLU, s); },
43
+ tool: function (s) { return wrap(MAG, s); },
44
+ italic: function (s) { return c(3) + String(s) + R; },
45
+ underline: function (s) { return c(4) + String(s) + R; }
46
+ };
47
+
48
+ export function isDark() {
49
+ if (FORCE === "dark") return true;
50
+ if (FORCE === "light") return false;
51
+ return true;
52
+ }
53
+
54
+ export function colorEnabled() {
55
+ return MODE === "color";
56
+ }
@@ -0,0 +1,30 @@
1
+ import { unlink, stat, rename } from "node:fs/promises";
2
+ import { safePath } from "./path.mjs";
3
+
4
+ // Move a file to a trash directory instead of unlinking. Safer default.
5
+ export async function delete_file(args) {
6
+ const abs = safePath(args.path);
7
+ try {
8
+ const st = await stat(abs);
9
+ if (st.isDirectory()) {
10
+ return "delete_file cannot remove directories: " + args.path;
11
+ }
12
+ // Instead of deleting, rename to .portal-trash
13
+ const trash = abs + ".portal-trash";
14
+ await rename(abs, trash);
15
+ return "moved " + args.path + " to " + args.path + ".portal-trash";
16
+ } catch (e) {
17
+ return "delete_file failed: " + (e.message || e);
18
+ }
19
+ }
20
+
21
+ export async function move_file(args) {
22
+ const from = safePath(args.from);
23
+ const to = safePath(args.to);
24
+ try {
25
+ await rename(from, to);
26
+ return "moved " + args.from + " to " + args.to;
27
+ } catch (e) {
28
+ return "move_file failed: " + (e.message || e);
29
+ }
30
+ }
@@ -0,0 +1,51 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { safePath } from "./path.mjs";
3
+
4
+ // Exact string replace. Refuses ambiguous edits, so the model cannot
5
+ // accidentally replace the wrong occurrence.
6
+ export async function edit_file(args) {
7
+ const abs = safePath(args.path);
8
+ const oldStr = String(args.old_string || "");
9
+ const newStr = String(args.new_string || "");
10
+
11
+ if (!oldStr) return "edit_file requires old_string";
12
+ if (oldStr === newStr) return "old_string and new_string are identical";
13
+
14
+ const orig = await readFile(abs, "utf8");
15
+ const count = orig.split(oldStr).length - 1;
16
+
17
+ if (count === 0) {
18
+ return "old_string not found in " + args.path + "\n" +
19
+ "Read the file first with read_file to see exact contents.";
20
+ }
21
+
22
+ if (count > 1 && !args.replace_all) {
23
+ return "old_string appears " + count + " times in " + args.path + "\n" +
24
+ "Add more surrounding context to make it unique, or set replace_all:true.";
25
+ }
26
+
27
+ const next = args.replace_all
28
+ ? orig.split(oldStr).join(newStr)
29
+ : orig.replace(oldStr, newStr);
30
+
31
+ await writeFile(abs + ".portal-bak", orig, "utf8");
32
+ await writeFile(abs, next, "utf8");
33
+
34
+ const delta = next.length - orig.length;
35
+ const noun = args.replace_all ? count + " occurrences" : "1 occurrence";
36
+ return "edited " + args.path + ": replaced " + noun +
37
+ " (" + (delta >= 0 ? "+" : "") + delta + " bytes)";
38
+ }
39
+
40
+ // Restore the last backup for a file, if one exists.
41
+ export async function undo_file(args) {
42
+ const abs = safePath(args.path);
43
+ const backup = abs + ".portal-bak";
44
+ try {
45
+ const content = await readFile(backup, "utf8");
46
+ await writeFile(abs, content, "utf8");
47
+ return "restored " + args.path + " from backup";
48
+ } catch (e) {
49
+ return "no backup found for " + args.path;
50
+ }
51
+ }
@@ -0,0 +1,42 @@
1
+ import { relative, sep } from "node:path";
2
+ import { walk } from "./walk.mjs";
3
+ import { safePath, toPosix, getWorkspace } from "./path.mjs";
4
+
5
+ function globToRegex(glob) {
6
+ let re = "";
7
+ for (let i = 0; i < glob.length; i++) {
8
+ const c = glob[i];
9
+ if (c === "*") {
10
+ if (glob[i + 1] === "*") { re += ".*"; i++; }
11
+ else re += "[^/\\\\]*";
12
+ } else if (c === "?") {
13
+ re += ".";
14
+ } else if (".+^$(){}[]|\\".indexOf(c) >= 0) {
15
+ re += "\\" + c;
16
+ } else {
17
+ re += c;
18
+ }
19
+ }
20
+ return new RegExp("^" + re + "$", "i");
21
+ }
22
+
23
+ export async function find_files(args) {
24
+ const pattern = args.pattern || "*";
25
+ const re = globToRegex(pattern);
26
+ const ws = getWorkspace();
27
+ const hits = [];
28
+ const MAX = 200;
29
+
30
+ for await (const f of walk(ws)) {
31
+ if (hits.length >= MAX) break;
32
+ const rel = toPosix(relative(ws, f));
33
+ const base = f.split(sep).pop();
34
+ if (re.test(rel) || re.test(base)) {
35
+ hits.push(rel);
36
+ }
37
+ }
38
+
39
+ if (!hits.length) return "no files matched: " + pattern;
40
+ hits.sort();
41
+ return hits.length + " file(s):\n" + hits.join("\n");
42
+ }
@@ -0,0 +1,52 @@
1
+ import { spawn } from "node:child_process";
2
+ import { getWorkspace } from "./path.mjs";
3
+
4
+ function run(args) {
5
+ return new Promise(function (done) {
6
+ const child = spawn("git", args, {
7
+ cwd: getWorkspace(),
8
+ shell: false,
9
+ windowsHide: true
10
+ });
11
+ let out = "";
12
+ let err = "";
13
+ if (child.stdout) child.stdout.on("data", function (d) { out += String(d); });
14
+ if (child.stderr) child.stderr.on("data", function (d) { err += String(d); });
15
+ const timer = setTimeout(function () {
16
+ child.kill();
17
+ done({ code: -1, out: out, err: "timed out" });
18
+ }, 15000);
19
+ child.on("error", function (e) {
20
+ clearTimeout(timer);
21
+ done({ code: -1, out: "", err: e.message });
22
+ });
23
+ child.on("close", function (code) {
24
+ clearTimeout(timer);
25
+ done({ code: code, out: out, err: err });
26
+ });
27
+ });
28
+ }
29
+
30
+ export async function git_status() {
31
+ const r = await run(["status", "--short", "--branch"]);
32
+ if (r.code !== 0) return "not a git repository, or git is not installed";
33
+ return r.out.trim() || "working tree clean";
34
+ }
35
+
36
+ export async function git_diff(args) {
37
+ const cmd = ["diff"];
38
+ if (args && args.staged) cmd.push("--cached");
39
+ if (args && args.path) cmd.push("--", args.path);
40
+ const r = await run(cmd);
41
+ if (r.code !== 0) return r.err || "git diff failed";
42
+ const out = r.out.trim();
43
+ if (!out) return "no changes";
44
+ return out.length > 30000 ? out.slice(0, 30000) + "\n[truncated]" : out;
45
+ }
46
+
47
+ export async function git_log(args) {
48
+ const n = (args && args.count) || 10;
49
+ const r = await run(["log", "--oneline", "-" + n]);
50
+ if (r.code !== 0) return "not a git repository";
51
+ return r.out.trim() || "no commits";
52
+ }
@@ -0,0 +1,44 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { relative, sep } from "node:path";
3
+ import { walk } from "./walk.mjs";
4
+ import { safePath, toPosix, getWorkspace } from "./path.mjs";
5
+
6
+ const MAX_RESULTS = 80;
7
+ const MAX_FILE_BYTES = 512000;
8
+
9
+ export async function grep_search(args) {
10
+ const pattern = String(args.pattern || "");
11
+ if (!pattern) return "grep_search requires a pattern";
12
+
13
+ let re;
14
+ try {
15
+ re = new RegExp(pattern, args.case_sensitive ? "" : "i");
16
+ } catch (e) {
17
+ return "bad regex: " + e.message;
18
+ }
19
+
20
+ const ws = getWorkspace();
21
+ const startPath = args.path ? safePath(args.path) : ws;
22
+ const hits = [];
23
+
24
+ for await (const f of walk(startPath)) {
25
+ if (hits.length >= MAX_RESULTS) break;
26
+ let info;
27
+ try { info = await stat(f); } catch (e) { continue; }
28
+ if (info.size > MAX_FILE_BYTES) continue;
29
+
30
+ let text;
31
+ try { text = await readFile(f, "utf8"); } catch (e) { continue; }
32
+ const lines = text.split("\n");
33
+
34
+ for (let i = 0; i < lines.length && hits.length < MAX_RESULTS; i++) {
35
+ if (re.test(lines[i])) {
36
+ const rel = toPosix(relative(ws, f));
37
+ hits.push(rel + ":" + (i + 1) + ":" + lines[i].trim().slice(0, 200));
38
+ }
39
+ }
40
+ }
41
+
42
+ if (!hits.length) return "no matches for: " + pattern;
43
+ return hits.length + " matches:\n" + hits.join("\n");
44
+ }
@@ -0,0 +1,53 @@
1
+ import { read_file } from "./read.mjs";
2
+ import { write_file, append_file } from "./write.mjs";
3
+ import { edit_file, undo_file } from "./edit.mjs";
4
+ import { list_files } from "./list.mjs";
5
+ import { find_files } from "./find.mjs";
6
+ import { grep_search } from "./grep.mjs";
7
+ import { delete_file, move_file } from "./delete.mjs";
8
+ import { shell } from "./shell.mjs";
9
+ import { git_status, git_diff, git_log } from "./git.mjs";
10
+
11
+ export const TOOLS = {
12
+ read_file: read_file,
13
+ write_file: write_file,
14
+ edit_file: edit_file,
15
+ append_file: append_file,
16
+ undo_file: undo_file,
17
+ list_files: list_files,
18
+ find_files: find_files,
19
+ grep_search: grep_search,
20
+ delete_file: delete_file,
21
+ move_file: move_file,
22
+ shell: shell,
23
+ git_status: git_status,
24
+ git_diff: git_diff,
25
+ git_log: git_log
26
+ };
27
+
28
+ export const SAFE_TOOLS = new Set([
29
+ "read_file",
30
+ "list_files",
31
+ "find_files",
32
+ "grep_search",
33
+ "git_status",
34
+ "git_diff",
35
+ "git_log"
36
+ ]);
37
+
38
+ export const CATALOG = [
39
+ "- read_file(path, start_line?, end_line?) read file with line numbers",
40
+ "- write_file(path, content) write or overwrite a whole file",
41
+ "- edit_file(path, old_string, new_string, replace_all?) exact find and replace",
42
+ "- append_file(path, content) add text to the end of a file",
43
+ "- undo_file(path) restore the last backup",
44
+ "- list_files(path?) list a directory with sizes",
45
+ "- find_files(pattern) glob search, e.g. **/*.ts",
46
+ "- grep_search(pattern, path?, case_sensitive?) regex inside files",
47
+ "- delete_file(path) move a file to .portal-trash",
48
+ "- move_file(from, to) rename or relocate a file",
49
+ "- shell(command) run a shell command",
50
+ "- git_status() show git status",
51
+ "- git_diff(path?, staged?) show uncommitted changes",
52
+ "- git_log(count?) recent commits"
53
+ ].join("\n");
@@ -0,0 +1,38 @@
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { safePath } from "./path.mjs";
4
+
5
+ export async function list_files(args) {
6
+ const abs = safePath(args.path || ".");
7
+ const entries = await readdir(abs, { withFileTypes: true });
8
+
9
+ if (!entries.length) {
10
+ return "EMPTY. This directory has no files or subdirectories. " +
11
+ "Do not call list_files again on this path.";
12
+ }
13
+
14
+ const rows = [];
15
+ for (const e of entries) {
16
+ const full = join(abs, e.name);
17
+ let size = "";
18
+ if (e.isFile()) {
19
+ try {
20
+ const st = await stat(full);
21
+ size = formatSize(st.size);
22
+ } catch (err) {
23
+ size = "";
24
+ }
25
+ }
26
+ const tag = e.isDirectory() ? "[dir] " : " ";
27
+ rows.push(tag + e.name.padEnd(40) + " " + size);
28
+ }
29
+
30
+ rows.sort();
31
+ return rows.join("\n");
32
+ }
33
+
34
+ function formatSize(bytes) {
35
+ if (bytes < 1024) return bytes + " B";
36
+ if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + " KB";
37
+ return (bytes / 1024 / 1024).toFixed(1) + " MB";
38
+ }
@@ -0,0 +1,22 @@
1
+ import { resolve, relative, sep } from "node:path";
2
+
3
+ let ws = process.cwd();
4
+
5
+ export function setWorkspace(p) { ws = resolve(p); }
6
+ export function getWorkspace() { return ws; }
7
+
8
+ export function safePath(p) {
9
+ if (!p || typeof p !== "string") {
10
+ throw new Error("path must be a non-empty string");
11
+ }
12
+ const abs = resolve(ws, p);
13
+ const rel = relative(ws, abs);
14
+ if (rel === ".." || rel.slice(0, 3) === "..\\" || rel.slice(0, 3) === "../") {
15
+ throw new Error("path escapes workspace: " + p);
16
+ }
17
+ return abs;
18
+ }
19
+
20
+ export function toPosix(p) {
21
+ return p.split(sep).join("/");
22
+ }
@@ -0,0 +1,33 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { safePath } from "./path.mjs";
3
+
4
+ const MAX_BYTES = 500000;
5
+
6
+ export async function read_file(args) {
7
+ const abs = safePath(args.path);
8
+ const text = await readFile(abs, "utf8");
9
+
10
+ if (text.length > MAX_BYTES) {
11
+ return text.slice(0, MAX_BYTES) + "\n[truncated at " + MAX_BYTES + " bytes]";
12
+ }
13
+
14
+ const lines = text.split("\n");
15
+ const start = Math.max(1, Number(args.start_line) || 1);
16
+ const end = Math.min(lines.length, Number(args.end_line) || lines.length);
17
+
18
+ if (start > end) {
19
+ return "invalid range: start_line " + start + " is after end_line " + end;
20
+ }
21
+
22
+ const width = String(end).length;
23
+ const out = [];
24
+ for (let i = start - 1; i < end; i++) {
25
+ out.push(String(i + 1).padStart(width) + "| " + lines[i]);
26
+ }
27
+
28
+ const header = start === 1 && end === lines.length
29
+ ? lines.length + " lines total"
30
+ : "lines " + start + "-" + end + " of " + lines.length;
31
+
32
+ return "[" + header + "]\n" + out.join("\n");
33
+ }
@@ -0,0 +1,45 @@
1
+ import { spawn } from "node:child_process";
2
+ import { getWorkspace } from "./path.mjs";
3
+
4
+ const MAX_OUTPUT = 65000;
5
+ const TIMEOUT_MS = 60000;
6
+
7
+ export function shell(args) {
8
+ const cmd = String(args.command || "");
9
+ if (!cmd) return Promise.resolve("shell requires a command");
10
+
11
+ return new Promise(function (done) {
12
+ const ws = getWorkspace();
13
+ const isWin = process.platform === "win32";
14
+
15
+ const child = spawn(cmd, {
16
+ cwd: ws,
17
+ shell: isWin ? true : "/bin/sh",
18
+ windowsHide: true
19
+ });
20
+
21
+ let stdout = "";
22
+ let stderr = "";
23
+
24
+ if (child.stdout) child.stdout.on("data", function (d) { stdout += String(d); });
25
+ if (child.stderr) child.stderr.on("data", function (d) { stderr += String(d); });
26
+
27
+ const timer = setTimeout(function () {
28
+ child.kill();
29
+ done("timed out after " + (TIMEOUT_MS / 1000) + "s\n" + stderr);
30
+ }, TIMEOUT_MS);
31
+
32
+ child.on("error", function (err) {
33
+ clearTimeout(timer);
34
+ done("spawn error: " + err.message);
35
+ });
36
+
37
+ child.on("close", function (code) {
38
+ clearTimeout(timer);
39
+ const both = [stdout.trimEnd(), stderr.trimEnd()].filter(Boolean).join("\n");
40
+ let s = "exit " + (code === null ? "null" : code) + "\n" + (both || "(no output)");
41
+ if (s.length > MAX_OUTPUT) s = s.slice(0, MAX_OUTPUT) + "\n[truncated]";
42
+ done(s);
43
+ });
44
+ });
45
+ }
@@ -0,0 +1,32 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ const DEFAULT_IGNORE = new Set([
5
+ "node_modules", ".git", ".hg", ".svn", "dist", "build", "out",
6
+ ".next", ".nuxt", ".turbo", ".cache", "target", "__pycache__",
7
+ ".venv", "venv", "env", "coverage", ".idea", ".vscode", ".DS_Store"
8
+ ]);
9
+
10
+ let ignore = DEFAULT_IGNORE;
11
+
12
+ export function setIgnore(names) {
13
+ ignore = new Set(names || []);
14
+ }
15
+
16
+ // Depth-first file walker. Yields full absolute paths. Skips ignored dirs
17
+ // and hidden files (except .env.example which is often relevant).
18
+ export async function* walk(dir) {
19
+ let items;
20
+ try {
21
+ items = await readdir(dir, { withFileTypes: true });
22
+ } catch (e) {
23
+ return;
24
+ }
25
+ for (const it of items) {
26
+ if (ignore.has(it.name)) continue;
27
+ if (it.name.startsWith(".") && it.name !== ".env.example") continue;
28
+ const full = join(dir, it.name);
29
+ if (it.isDirectory()) yield* walk(full);
30
+ else if (it.isFile()) yield full;
31
+ }
32
+ }
@@ -0,0 +1,46 @@
1
+ import { writeFile, mkdir, readFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { safePath } from "./path.mjs";
4
+
5
+ // Write a whole file. Creates parent directories. If the file already
6
+ // exists, saves a backup first.
7
+ export async function write_file(args) {
8
+ const abs = safePath(args.path);
9
+ const content = args.content || "";
10
+ await mkdir(dirname(abs), { recursive: true });
11
+
12
+ // Backup existing file
13
+ try {
14
+ const existing = await readFile(abs, "utf8");
15
+ if (existing !== content) {
16
+ const backup = abs + ".portal-bak";
17
+ await writeFile(backup, existing, "utf8");
18
+ }
19
+ } catch (e) {
20
+ // new file, no backup needed
21
+ }
22
+
23
+ await writeFile(abs, content, "utf8");
24
+ const lines = content.length === 0 ? 0 : content.split("\n").length;
25
+ return "wrote " + content.length + " bytes (" + lines + " lines) to " + args.path;
26
+ }
27
+
28
+ // Append to end of a file, creating it if missing.
29
+ export async function append_file(args) {
30
+ const abs = safePath(args.path);
31
+ await mkdir(dirname(abs), { recursive: true });
32
+
33
+ let existing = "";
34
+ try {
35
+ existing = await readFile(abs, "utf8");
36
+ } catch (e) {
37
+ existing = "";
38
+ }
39
+
40
+ const content = args.content || "";
41
+ const sep = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
42
+ const add = content.endsWith("\n") ? content : content + "\n";
43
+
44
+ await writeFile(abs, existing + sep + add, "utf8");
45
+ return "appended " + content.length + " bytes to " + args.path;
46
+ }