altalandi 0.1.1 → 0.2.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 CHANGED
@@ -31,6 +31,27 @@ For an application dependency, install locally and import the package root:
31
31
  npm i altalandi
32
32
  ```
33
33
 
34
+ ## Updating
35
+
36
+ ```bash
37
+ altalandi update # check, then ask before updating
38
+ altalandi update --check # only report; change nothing
39
+ altalandi --version
40
+ ```
41
+
42
+ `altalandi update` checks npm for a newer version and asks before installing
43
+ it. It works out how you installed the CLI (global, project dependency or `npx`)
44
+ and runs the matching command. If you use Claude Code, it then offers to install
45
+ or refresh the `/altalandi` skill that ships with each version. A skill you have
46
+ edited is backed up before it is replaced.
47
+
48
+ Nothing changes without your answer. Without a terminal to ask in (agents, CI,
49
+ pipes) it only reports what is available; pass `--yes` when a script already has
50
+ your consent.
51
+
52
+ Exit codes: `0` up to date or updated · `10` an update is available but was not
53
+ applied · `1` the check or the update failed.
54
+
34
55
  ## JavaScript / TypeScript client
35
56
 
36
57
  `refineVerified` requests the meaning gate and throws unless the API returns
package/altalandi.mjs CHANGED
@@ -12,6 +12,48 @@
12
12
  import { refine } from "./client.mjs";
13
13
 
14
14
  const args = process.argv.slice(2);
15
+
16
+ // Subcommands and meta flags are handled before anything that needs an API key
17
+ // or input text. `altalandi update` checks for a newer CLI and skill and asks
18
+ // before changing anything.
19
+ let handled = false;
20
+ if (args[0] === "update") {
21
+ handled = true;
22
+ const { runUpdate } = await import("./update.mjs");
23
+ const rest = args.slice(1);
24
+ if (rest.includes("-h") || rest.includes("--help")) { updateHelp(); process.exit(0); }
25
+ // exitCode, never process.exit(): the registry request can leave a socket
26
+ // mid-close, and exiting then aborts Node on Windows with a libuv assertion
27
+ // (UV_HANDLE_CLOSING). The refine path below exits the same way for the
28
+ // same reason.
29
+ process.exitCode = await runUpdate({
30
+ check: rest.includes("--check"),
31
+ yes: rest.includes("--yes") || rest.includes("-y"),
32
+ skillOnly: rest.includes("--skill-only"),
33
+ });
34
+ }
35
+ if (!handled && (args.includes("-v") || args.includes("--version"))) {
36
+ const { currentVersion } = await import("./update.mjs");
37
+ console.log(await currentVersion());
38
+ process.exit(0);
39
+ }
40
+
41
+ function updateHelp() {
42
+ console.log(`altalandi update — check for a newer version and update if you agree
43
+
44
+ Usage:
45
+ altalandi update check, then ask before updating the CLI and the /altalandi skill
46
+ altalandi update --check only report; never change anything
47
+ altalandi update --yes update without asking (for scripts that already have consent)
48
+
49
+ Nothing changes without consent. Without a terminal to ask in (agents, CI,
50
+ pipes) and without --yes, it only reports what is available.
51
+
52
+ Exit codes: 0 up to date or updated · 10 an update is available but was not
53
+ applied · 1 the check or the update failed.`);
54
+ }
55
+
56
+ if (!handled) {
15
57
  const opts = { domain: "general", register: "neutral", json: false, fast: false };
16
58
  const positional = [];
17
59
  for (let i = 0; i < args.length; i++) {
@@ -34,6 +76,10 @@ Usage:
34
76
  echo "text" | altalandi
35
77
  altalandi --domain marketing --register warm "text"
36
78
 
79
+ Commands:
80
+ altalandi update check for a newer version and update if you agree
81
+ altalandi --version print the installed version
82
+
37
83
  Options:
38
84
  --domain general | marketing | data-analysis | childrens-fiction (default general)
39
85
  --register formal | neutral | warm (default neutral)
@@ -120,3 +166,4 @@ try {
120
166
  console.error("Request failed:", e.message);
121
167
  process.exit(1);
122
168
  }
169
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altalandi",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Hosted Altalandi API client and CLI for native-sounding Icelandic with meaning verification.",
5
5
  "type": "module",
6
6
  "main": "./client.mjs",
@@ -17,13 +17,15 @@
17
17
  },
18
18
  "files": [
19
19
  "altalandi.mjs",
20
+ "update.mjs",
21
+ "skill/SKILL.md",
20
22
  "client.mjs",
21
23
  "client.d.ts",
22
24
  "README.md",
23
25
  "LICENSE"
24
26
  ],
25
27
  "scripts": {
26
- "test": "node --test test/cli.test.mjs test/client.test.mjs"
28
+ "test": "node --test test/cli.test.mjs test/client.test.mjs test/update.test.mjs"
27
29
  },
28
30
  "keywords": [
29
31
  "icelandic",
package/skill/SKILL.md ADDED
@@ -0,0 +1,105 @@
1
+ ---
2
+ name: altalandi
3
+ description: >-
4
+ MANUAL COMMAND — only run when the user explicitly invokes /altalandi. Do NOT
5
+ trigger automatically, and do not use it proactively on Icelandic writing tasks.
6
+ When invoked: refine English or stiff/AI-translated Icelandic into native
7
+ Icelandic via the Altalandi API, with a meaning-preservation check.
8
+ ---
9
+
10
+ # Altalandi — native Icelandic, with a meaning guarantee
11
+
12
+ Altalandi is a guarded pipeline, not a single model call: a frontier LLM rewrites
13
+ to native register, a rule proofreader cleans up, then a validation gate checks
14
+ meaning (back-translation **plus** deterministic invariants — numbers, dates,
15
+ negation, legal force, names) and a **candidate veto** refuses any rewrite the
16
+ pipeline itself has flagged as broken (invented words, a quoted defect, mojibake).
17
+ Every change comes back as a diff with the reason.
18
+
19
+ ## How to call it
20
+
21
+ When the user runs `/altalandi`, refine the target text via the API.
22
+
23
+ 1. **CLI, always with `--json`** — `npx altalandi --json "<text>"`. Flags:
24
+ `--domain`, `--register`. Needs `ALTALANDI_API_KEY` in the env.
25
+ **Use `--json` from an agent.** When a rewrite is refused, the plain-text mode
26
+ prints the reason as a `⚠` warning on stderr, but it also prints "meaning
27
+ preserved" and exits 0 — easy for an agent to misread as success. The JSON
28
+ carries `rewriteAttempted` and `rejectionReason`, which are unambiguous.
29
+ Run `npx altalandi update` now and then to get the latest CLI and this skill.
30
+ 2. **HTTP** — `POST https://altalandi.is/api/v1/refine` with
31
+ `Authorization: Bearer $ALTALANDI_API_KEY` and body `{ "text": "...", ... }`.
32
+
33
+ (Do NOT rely on an MCP tool here — an MCP `refine_icelandic` tool is always available
34
+ to the model, which defeats manual-only invocation. This command calls the CLI/HTTP
35
+ directly so it only runs when you ask.)
36
+
37
+ Auth: set **`ALTALANDI_API_KEY`** once in the shell env (or your project's `.env`).
38
+ Existing `rtv_…` keys keep working — there is no rotation. Mint or view keys at
39
+ https://altalandi.is/portal. Machine-readable docs: https://altalandi.is/llms-full.txt
40
+ and https://altalandi.is/api/openapi.json.
41
+
42
+ ## Parameters
43
+
44
+ - `text` (required) — the text to refine (English or Icelandic; auto-detected).
45
+ - `domain` — `general` | `marketing` | `data-analysis` | `childrens-fiction`.
46
+ - `register` — `formal` | `neutral` | `warm`.
47
+ - `sourceLang` — `auto` (default) | `en` | `is`.
48
+
49
+ ## The non-negotiable contract — read the outcome, not one field
50
+
51
+ An Icelandic→Icelandic call ends in one of three ways. Tell them apart before you
52
+ use the output:
53
+
54
+ | outcome | how to recognise it |
55
+ |---|---|
56
+ | **Rewritten and verified** | `output` differs from your text, `meaningPreserved: true`, `rejectionReason` empty |
57
+ | **Rewrite refused — original returned** | `rewriteAttempted: true` and `rejectionReason` set. Causes: `MEANING DRIFT`, `HARD INVARIANT LOST`, `UNVERIFIED`, or `CANDIDATE VETO` (then `candidateVetoFired: true`, `candidateVetoSignal` says why) |
58
+ | **Already fine — nothing to change** | `rewriteAttempted: false` |
59
+
60
+ - **`meaningPreserved` alone is not enough.** A vetoed rewrite returns your
61
+ original with `meaningPreserved` still `true`: the meaning check passed, but the
62
+ rewrite was broken in another way. Check `rewriteAttempted` and `rejectionReason`.
63
+ - `meaningPreserved: null` means **unverified**, not safe. For Icelandic input the
64
+ original comes back; for English input the translation comes back marked
65
+ unverified — do not present it as verified.
66
+ - **When the original comes back, say so.** Don't report "Altalandi improved
67
+ this" when it declined to.
68
+ - **Show the diffs.** When you apply Altalandi's output, surface what changed and
69
+ why, never silently replace the user's text.
70
+
71
+ ## The nativeness score is a hint, not a verdict
72
+
73
+ `quality.nativenessScore` and `translationeseTells` point at likely problems, but
74
+ the score has been measured to be confidently wrong in both directions: it gave a
75
+ rewrite containing an invented word 82/100, and a perfectly fine rewrite 18/100.
76
+ Treat a low score as "look at the tells", never as proof of bad Icelandic — and
77
+ never as permission to override a refusal. A tell that **quotes** a specific phrase
78
+ is far more trustworthy than the number.
79
+
80
+ ## When to use it
81
+
82
+ - Producing or polishing user-facing Icelandic (web copy, UI strings, emails,
83
+ product text, children's prose).
84
+ - You translated something to Icelandic and want it to not "sound translated".
85
+ - Many strings at once: `POST /refine/batch`.
86
+
87
+ ## When NOT to use it
88
+
89
+ - Non-Icelandic output. Code, identifiers, or data that must stay literal. Quotes
90
+ you must preserve verbatim (it may rephrase — check the diff).
91
+
92
+ ## `--fast` removes the safety net
93
+
94
+ `--fast` (`scoreNativeness:false`, `scoring:"none"`) skips the meaning gate **and**
95
+ leaves the candidate veto blind: with no BÍN morphology check and no nativeness
96
+ tells, it cannot catch an invented or wrong word. Only mojibake is still caught.
97
+ Use it only for throwaway text you will read yourself — never for anything shipped.
98
+
99
+ ## Notes
100
+
101
+ - Usage is metered by a daily **word budget** per key (default 10,000 words/day;
102
+ 429 when spent, resets 00:00 UTC).
103
+ - A full call makes several model passes and is slower; that is the price of the
104
+ checks above.
105
+ - Data is processed and discarded — not stored or trained on.
package/update.mjs ADDED
@@ -0,0 +1,271 @@
1
+ // `altalandi update` — check for a newer CLI, ask before installing it, then
2
+ // offer to install or refresh the /altalandi skill for Claude Code.
3
+ //
4
+ // Nothing is ever changed without consent. Interactive terminals get a y/N
5
+ // prompt; non-interactive callers (agents, CI, pipes) are only told what is
6
+ // available, unless they pass --yes, which is itself the explicit consent.
7
+ //
8
+ // Zero dependencies, like the rest of the CLI.
9
+
10
+ import { spawn } from "node:child_process";
11
+ import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
12
+ import { existsSync, realpathSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import path from "node:path";
15
+ import { createInterface } from "node:readline/promises";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ export const PACKAGE = "altalandi";
19
+ export const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE}/latest`;
20
+
21
+ // Exit codes. 10 is deliberately distinct from the refine command's 1/2/3 so a
22
+ // script can tell "an update exists" apart from "something failed".
23
+ export const EXIT_OK = 0;
24
+ export const EXIT_CHECK_FAILED = 1;
25
+ export const EXIT_UPDATE_AVAILABLE = 10;
26
+
27
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
28
+ export const BUNDLED_SKILL = path.join(HERE, "skill", "SKILL.md");
29
+
30
+ export async function currentVersion() {
31
+ const pkg = JSON.parse(await readFile(path.join(HERE, "package.json"), "utf8"));
32
+ return pkg.version;
33
+ }
34
+
35
+ /** Semver-ish compare: -1 if a<b, 0 if equal, 1 if a>b. A prerelease sorts
36
+ * before its release (0.2.0-beta < 0.2.0). */
37
+ export function compareVersions(a, b) {
38
+ const parse = (v) => {
39
+ const [core, pre = ""] = String(v).trim().replace(/^v/, "").split("-", 2);
40
+ return { nums: core.split(".").map((n) => Number.parseInt(n, 10) || 0), pre };
41
+ };
42
+ const x = parse(a);
43
+ const y = parse(b);
44
+ for (let i = 0; i < 3; i++) {
45
+ const d = (x.nums[i] || 0) - (y.nums[i] || 0);
46
+ if (d !== 0) return d < 0 ? -1 : 1;
47
+ }
48
+ if (x.pre === y.pre) return 0;
49
+ if (!x.pre) return 1;
50
+ if (!y.pre) return -1;
51
+ return x.pre < y.pre ? -1 : 1;
52
+ }
53
+
54
+ export async function fetchLatestVersion({ fetchImpl = globalThis.fetch, timeoutMs = 8000 } = {}) {
55
+ const controller = new AbortController();
56
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
57
+ try {
58
+ const res = await fetchImpl(REGISTRY_URL, {
59
+ signal: controller.signal,
60
+ headers: { accept: "application/json" },
61
+ });
62
+ if (!res.ok) throw new Error(`npm registry answered ${res.status}`);
63
+ const body = JSON.parse(await res.text());
64
+ if (!body || typeof body.version !== "string") throw new Error("npm registry returned no version");
65
+ return body.version;
66
+ } finally {
67
+ clearTimeout(timer);
68
+ }
69
+ }
70
+
71
+ const norm = (p) => {
72
+ let out = path.resolve(p);
73
+ try { out = realpathSync(out); } catch { /* keep the resolved path */ }
74
+ return process.platform === "win32" ? out.toLowerCase() : out;
75
+ };
76
+
77
+ /** How is this copy of the CLI being run? That decides what "update" means.
78
+ *
79
+ * npx — lives in npm's _npx cache; there is nothing to install, but a bare
80
+ * `npx altalandi` can keep reusing an old cached copy
81
+ * global — `npm install -g altalandi`
82
+ * local — a project dependency
83
+ * source — a checkout of the repository; npm cannot update that
84
+ */
85
+ export function detectInstall({ scriptPath, globalRoot }) {
86
+ const p = norm(scriptPath);
87
+ const sep = path.sep === "\\" ? /[\\/]/ : /\//;
88
+ const parts = p.split(sep);
89
+ if (parts.includes("_npx")) return "npx";
90
+ const nm = parts.lastIndexOf("node_modules");
91
+ if (nm === -1) return "source";
92
+ if (globalRoot && p.startsWith(norm(globalRoot) + (process.platform === "win32" ? "\\" : "/"))) {
93
+ return "global";
94
+ }
95
+ return "local";
96
+ }
97
+
98
+ /** The exact command that updates this install. Constants only — never built
99
+ * from user input. */
100
+ export function updatePlan(kind, { projectDir } = {}) {
101
+ switch (kind) {
102
+ case "global":
103
+ return { cmd: "npm", args: ["install", "-g", `${PACKAGE}@latest`], show: `npm install -g ${PACKAGE}@latest` };
104
+ case "local":
105
+ return { cmd: "npm", args: ["install", `${PACKAGE}@latest`], cwd: projectDir, show: `npm install ${PACKAGE}@latest` };
106
+ case "npx":
107
+ return { cmd: "npx", args: ["--yes", `${PACKAGE}@latest`, "--version"], show: `npx ${PACKAGE}@latest` };
108
+ default:
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /** Where Claude Code looks for personal skills. */
114
+ export function skillTarget({ env = process.env, home = homedir() } = {}) {
115
+ const root = env.CLAUDE_CONFIG_DIR || path.join(home, ".claude");
116
+ return { root, file: path.join(root, "skills", PACKAGE, "SKILL.md") };
117
+ }
118
+
119
+ /** missing | current | outdated. `absent` means Claude Code itself is not set
120
+ * up, in which case the skill is not offered at all — never create ~/.claude
121
+ * for someone who does not use it. */
122
+ export async function skillStatus({ bundled = BUNDLED_SKILL, target = skillTarget() } = {}) {
123
+ if (!existsSync(bundled)) return "unbundled";
124
+ if (!existsSync(target.root)) return "absent";
125
+ if (!existsSync(target.file)) return "missing";
126
+ const [a, b] = await Promise.all([readFile(bundled, "utf8"), readFile(target.file, "utf8")]);
127
+ const clean = (s) => s.replace(/\r\n/g, "\n").trim();
128
+ return clean(a) === clean(b) ? "current" : "outdated";
129
+ }
130
+
131
+ /** Install or refresh the skill. An existing copy is always backed up first:
132
+ * people edit their skills, and an update must never silently eat that. */
133
+ export async function writeSkill({ bundled = BUNDLED_SKILL, target = skillTarget(), now = new Date() } = {}) {
134
+ await mkdir(path.dirname(target.file), { recursive: true });
135
+ let backup = null;
136
+ if (existsSync(target.file)) {
137
+ const stamp = now.toISOString().replace(/[:.]/g, "-");
138
+ backup = `${target.file}.bak-${stamp}`;
139
+ await copyFile(target.file, backup);
140
+ }
141
+ await writeFile(target.file, await readFile(bundled, "utf8"), "utf8");
142
+ return { file: target.file, backup };
143
+ }
144
+
145
+ function run(plan) {
146
+ return new Promise((resolve) => {
147
+ // npm and npx are .cmd shims on Windows, which need a shell. The arguments
148
+ // are constants from updatePlan(), so there is nothing to inject.
149
+ const child = spawn(plan.cmd, plan.args, {
150
+ cwd: plan.cwd,
151
+ stdio: "inherit",
152
+ shell: process.platform === "win32",
153
+ });
154
+ child.on("error", () => resolve(1));
155
+ child.on("close", (code) => resolve(code ?? 1));
156
+ });
157
+ }
158
+
159
+ function npmGlobalRoot() {
160
+ return new Promise((resolve) => {
161
+ const child = spawn("npm", ["root", "-g"], { shell: process.platform === "win32" });
162
+ let out = "";
163
+ child.stdout?.setEncoding("utf8").on("data", (c) => { out += c; });
164
+ child.on("error", () => resolve(null));
165
+ child.on("close", () => resolve(out.trim() || null));
166
+ });
167
+ }
168
+
169
+ async function ask(question, { input = process.stdin, output = process.stderr } = {}) {
170
+ const rl = createInterface({ input, output });
171
+ try {
172
+ const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
173
+ return answer === "y" || answer === "yes" || answer === "j" || answer === "já";
174
+ } finally {
175
+ rl.close();
176
+ }
177
+ }
178
+
179
+ /**
180
+ * The whole command. Every side effect is injectable so it can be tested
181
+ * without touching npm, the network or the real home directory.
182
+ */
183
+ export async function runUpdate(flags, io = {}) {
184
+ const log = io.log || ((m) => process.stderr.write(`${m}\n`));
185
+ const interactive = io.interactive ?? Boolean(process.stdin.isTTY && process.stderr.isTTY);
186
+ const confirm = io.confirm || ask;
187
+ const execute = io.run || run;
188
+ const target = io.skillTarget || skillTarget();
189
+ const bundled = io.bundledSkill || BUNDLED_SKILL;
190
+
191
+ // --check never changes anything. Without a terminal and without --yes there
192
+ // is nobody to ask, so the same rule applies: report, don't act.
193
+ const mayAct = !flags.check && (flags.yes || interactive);
194
+ const consent = async (q) => (flags.yes ? true : confirm(q));
195
+
196
+ const installed = io.currentVersion || await currentVersion();
197
+
198
+ if (!flags.skillOnly) {
199
+ let latest;
200
+ try {
201
+ latest = io.latestVersion || await fetchLatestVersion({ fetchImpl: io.fetch });
202
+ } catch (e) {
203
+ log(`Could not check for updates: ${e.message}`);
204
+ return EXIT_CHECK_FAILED;
205
+ }
206
+
207
+ if (compareVersions(installed, latest) < 0) {
208
+ const scriptPath = io.scriptPath || fileURLToPath(new URL("./altalandi.mjs", import.meta.url));
209
+ // `npm root -g` spawns npm, so only ask it when the answer matters: a copy
210
+ // inside some node_modules that is not npx's cache.
211
+ const inNodeModules = /[\\/]node_modules[\\/]/.test(scriptPath) && !/[\\/]_npx[\\/]/.test(scriptPath);
212
+ const globalRoot = io.globalRoot !== undefined ? io.globalRoot
213
+ : (inNodeModules ? await (io.npmGlobalRoot || npmGlobalRoot)() : null);
214
+ const kind = io.installKind || detectInstall({ scriptPath, globalRoot });
215
+ const plan = updatePlan(kind, { projectDir: io.projectDir || process.cwd() });
216
+ log(`A new version of ${PACKAGE} is available: ${installed} → ${latest}`);
217
+
218
+ if (!plan) {
219
+ log("This copy runs from a source checkout, so npm cannot update it. Pull the repository instead.");
220
+ return EXIT_UPDATE_AVAILABLE;
221
+ }
222
+ if (!mayAct) {
223
+ log(`Update with: ${plan.show}`);
224
+ return EXIT_UPDATE_AVAILABLE;
225
+ }
226
+ if (!(await consent(`Update ${PACKAGE} to ${latest} now?`))) {
227
+ log(`Skipped. Update later with: ${plan.show}`);
228
+ return EXIT_UPDATE_AVAILABLE;
229
+ }
230
+ log(`Running: ${plan.show}`);
231
+ const code = await execute(plan);
232
+ if (code !== 0) {
233
+ log(`Update failed (exit ${code}). Try running it yourself: ${plan.show}`);
234
+ return EXIT_CHECK_FAILED;
235
+ }
236
+ log(`Updated to ${latest}.`);
237
+ // The skill that ships with the NEW version is the one worth installing,
238
+ // and this process is still the old one. Hand over to the new CLI.
239
+ const handoff = kind === "npx"
240
+ ? { cmd: "npx", args: ["--yes", `${PACKAGE}@latest`, "update", "--skill-only", ...(flags.yes ? ["--yes"] : [])] }
241
+ : { cmd: PACKAGE, args: ["update", "--skill-only", ...(flags.yes ? ["--yes"] : [])] };
242
+ await execute(handoff);
243
+ return EXIT_OK;
244
+ }
245
+ log(`${PACKAGE} ${installed} is up to date.`);
246
+ }
247
+
248
+ const status = await skillStatus({ bundled, target });
249
+ if (status === "unbundled" || status === "absent") return EXIT_OK;
250
+ if (status === "current") {
251
+ log("The /altalandi skill for Claude Code is up to date.");
252
+ return EXIT_OK;
253
+ }
254
+ const what = status === "missing"
255
+ ? `Install the /altalandi skill for Claude Code (${target.file})?`
256
+ : "Your /altalandi skill for Claude Code differs from this version. Replace it (your copy is backed up first)?";
257
+ if (!mayAct) {
258
+ log(status === "missing"
259
+ ? "The /altalandi skill for Claude Code is not installed. Run `altalandi update` in a terminal to install it."
260
+ : "Your /altalandi skill for Claude Code is out of date. Run `altalandi update` in a terminal to refresh it.");
261
+ return flags.skillOnly ? EXIT_OK : EXIT_UPDATE_AVAILABLE;
262
+ }
263
+ if (!(await consent(what))) {
264
+ log("Skill left unchanged.");
265
+ return EXIT_OK;
266
+ }
267
+ const written = await writeSkill({ bundled, target, now: io.now });
268
+ log(`Skill ${status === "missing" ? "installed" : "updated"}: ${written.file}`);
269
+ if (written.backup) log(`Previous copy saved as: ${written.backup}`);
270
+ return EXIT_OK;
271
+ }