akm-cli 0.7.5 → 0.8.0-rc.6
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/{.github/CHANGELOG.md → CHANGELOG.md} +113 -2
- package/README.md +20 -4
- package/SECURITY.md +93 -0
- package/dist/cli/config-migrate.js +144 -0
- package/dist/cli/config-validate.js +39 -0
- package/dist/cli/confirm.js +73 -0
- package/dist/cli/parse-args.js +133 -0
- package/dist/cli.js +1995 -551
- package/dist/commands/agent-dispatch.js +110 -0
- package/dist/commands/agent-support.js +68 -0
- package/dist/commands/completions.js +3 -0
- package/dist/commands/config-cli.js +130 -534
- package/dist/commands/consolidate.js +1531 -0
- package/dist/commands/curate.js +44 -3
- package/dist/commands/db-cli.js +23 -0
- package/dist/commands/distill-promotion-policy.js +660 -0
- package/dist/commands/distill.js +990 -75
- package/dist/commands/eval-cases.js +43 -0
- package/dist/commands/events.js +5 -23
- package/dist/commands/graph.js +477 -0
- package/dist/commands/health.js +400 -0
- package/dist/commands/help/help-accept.md +9 -0
- package/dist/commands/help/help-improve.md +77 -0
- package/dist/commands/help/help-proposals.md +15 -0
- package/dist/commands/help/help-propose.md +17 -0
- package/dist/commands/help/help-reject.md +8 -0
- package/dist/commands/history.js +54 -46
- package/dist/commands/improve-profiles.js +146 -0
- package/dist/commands/improve-result-file.js +103 -0
- package/dist/commands/improve.js +2175 -0
- package/dist/commands/info.js +5 -2
- package/dist/commands/init.js +50 -2
- package/dist/commands/installed-stashes.js +102 -139
- package/dist/commands/knowledge.js +136 -0
- package/dist/commands/lint/agent-linter.js +49 -0
- package/dist/commands/lint/base-linter.js +479 -0
- package/dist/commands/lint/command-linter.js +49 -0
- package/dist/commands/lint/default-linter.js +16 -0
- package/dist/commands/lint/index.js +183 -0
- package/dist/commands/lint/knowledge-linter.js +16 -0
- package/dist/commands/lint/markdown-insertion.js +343 -0
- package/dist/commands/lint/memory-linter.js +61 -0
- package/dist/commands/lint/registry.js +36 -0
- package/dist/commands/lint/skill-linter.js +45 -0
- package/dist/commands/lint/task-linter.js +50 -0
- package/dist/commands/lint/types.js +4 -0
- package/dist/commands/lint/vault-key-rules.js +139 -0
- package/dist/commands/lint/workflow-linter.js +56 -0
- package/dist/commands/lint.js +4 -0
- package/dist/commands/migration-help.js +5 -2
- package/dist/commands/proposal.js +66 -12
- package/dist/commands/propose.js +86 -31
- package/dist/commands/reflect.js +1119 -73
- package/dist/commands/registry-search.js +5 -2
- package/dist/commands/remember.js +69 -6
- package/dist/commands/schema-repair.js +203 -0
- package/dist/commands/search.js +115 -14
- package/dist/commands/self-update.js +3 -0
- package/dist/commands/show.js +144 -25
- package/dist/commands/source-add.js +17 -45
- package/dist/commands/source-clone.js +3 -0
- package/dist/commands/source-manage.js +14 -19
- package/dist/commands/tasks.js +438 -0
- package/dist/commands/url-checker.js +42 -0
- package/dist/commands/vault.js +130 -77
- package/dist/core/action-contributors.js +28 -0
- package/dist/core/asset-ref.js +7 -0
- package/dist/core/asset-registry.js +7 -16
- package/dist/core/asset-serialize.js +88 -0
- package/dist/core/asset-spec.js +22 -0
- package/dist/core/common.js +157 -0
- package/dist/core/concurrent.js +25 -0
- package/dist/core/config-io.js +347 -0
- package/dist/core/config-migration.js +625 -0
- package/dist/core/config-schema.js +501 -0
- package/dist/core/config-sources.js +108 -0
- package/dist/core/config-types.js +4 -0
- package/dist/core/config-walker.js +337 -0
- package/dist/core/config.js +327 -987
- package/dist/core/errors.js +40 -19
- package/dist/core/events.js +91 -138
- package/dist/core/file-lock.js +104 -0
- package/dist/core/frontmatter.js +3 -6
- package/dist/core/lesson-lint.js +3 -0
- package/dist/core/markdown.js +20 -0
- package/dist/core/memory-belief.js +62 -0
- package/dist/core/memory-contradiction-detect.js +274 -0
- package/dist/core/memory-improve.js +806 -0
- package/dist/core/parse.js +158 -0
- package/dist/core/paths.js +326 -14
- package/dist/core/proposal-quality-validators.js +364 -0
- package/dist/core/proposal-validators.js +69 -0
- package/dist/core/proposals.js +498 -42
- package/dist/core/state-db.js +927 -0
- package/dist/core/text-truncation.js +107 -0
- package/dist/core/time.js +54 -0
- package/dist/core/warn.js +62 -1
- package/dist/core/write-source.js +3 -0
- package/dist/indexer/db-backup.js +391 -0
- package/dist/indexer/db-search.js +152 -253
- package/dist/indexer/db.js +933 -103
- package/dist/indexer/ensure-index.js +64 -0
- package/dist/indexer/file-context.js +3 -0
- package/dist/indexer/graph-boost.js +376 -101
- package/dist/indexer/graph-db.js +391 -0
- package/dist/indexer/graph-dedup.js +95 -0
- package/dist/indexer/graph-extraction.js +550 -124
- package/dist/indexer/index-context.js +4 -0
- package/dist/indexer/indexer.js +506 -291
- package/dist/indexer/llm-cache.js +47 -0
- package/dist/indexer/manifest.js +3 -0
- package/dist/indexer/matchers.js +148 -160
- package/dist/indexer/memory-inference.js +99 -74
- package/dist/indexer/metadata-contributors.js +29 -0
- package/dist/indexer/metadata.js +255 -196
- package/dist/indexer/path-resolver.js +92 -0
- package/dist/indexer/project-context.js +192 -0
- package/dist/indexer/ranking-contributors.js +331 -0
- package/dist/indexer/ranking.js +81 -0
- package/dist/indexer/search-fields.js +5 -9
- package/dist/indexer/search-hit-enrichers.js +111 -0
- package/dist/indexer/search-source.js +44 -10
- package/dist/indexer/semantic-status.js +5 -16
- package/dist/indexer/staleness-detect.js +447 -0
- package/dist/indexer/usage-events.js +12 -9
- package/dist/indexer/walker.js +28 -0
- package/dist/integrations/agent/builders.js +135 -0
- package/dist/integrations/agent/config.js +122 -230
- package/dist/integrations/agent/detect.js +3 -0
- package/dist/integrations/agent/index.js +7 -13
- package/dist/integrations/agent/model-aliases.js +55 -0
- package/dist/integrations/agent/profiles.js +70 -5
- package/dist/integrations/agent/prompts.js +150 -74
- package/dist/integrations/agent/runner.js +151 -0
- package/dist/integrations/agent/sdk-runner.js +126 -0
- package/dist/integrations/agent/spawn.js +118 -23
- package/dist/integrations/github.js +3 -0
- package/dist/integrations/lockfile.js +32 -69
- package/dist/integrations/session-logs/index.js +68 -0
- package/dist/integrations/session-logs/providers/claude-code.js +59 -0
- package/dist/integrations/session-logs/providers/opencode.js +55 -0
- package/dist/integrations/session-logs/types.js +4 -0
- package/dist/llm/call-ai.js +62 -0
- package/dist/llm/client.js +72 -124
- package/dist/llm/embedder.js +3 -19
- package/dist/llm/embedders/cache.js +3 -7
- package/dist/llm/embedders/local.js +3 -0
- package/dist/llm/embedders/remote.js +20 -8
- package/dist/llm/embedders/types.js +3 -7
- package/dist/llm/feature-gate.js +89 -48
- package/dist/llm/graph-extract.js +676 -70
- package/dist/llm/index-passes.js +9 -23
- package/dist/llm/memory-infer.js +52 -71
- package/dist/llm/metadata-enhance.js +42 -29
- package/dist/llm/prompts/graph-extract-user-prompt.md +35 -0
- package/dist/output/cli-hints-full.md +281 -0
- package/dist/output/cli-hints-short.md +65 -0
- package/dist/output/cli-hints.js +5 -318
- package/dist/output/context.js +3 -0
- package/dist/output/renderers.js +223 -256
- package/dist/output/shapes.js +150 -105
- package/dist/output/text.js +318 -30
- package/dist/registry/build-index.js +3 -0
- package/dist/registry/create-provider-registry.js +3 -0
- package/dist/registry/factory.js +3 -0
- package/dist/registry/origin-resolve.js +3 -0
- package/dist/registry/providers/index.js +3 -0
- package/dist/registry/providers/skills-sh.js +70 -49
- package/dist/registry/providers/static-index.js +53 -48
- package/dist/registry/providers/types.js +3 -24
- package/dist/registry/resolve.js +11 -16
- package/dist/registry/types.js +3 -0
- package/dist/scripts/migrate-storage.js +17307 -0
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +8900 -0
- package/dist/scripts/migrations/v16-to-v17.js +141 -0
- package/dist/setup/detect.js +3 -0
- package/dist/setup/ripgrep-install.js +3 -0
- package/dist/setup/ripgrep-resolve.js +3 -0
- package/dist/setup/setup.js +775 -37
- package/dist/setup/steps.js +3 -15
- package/dist/sources/include.js +3 -0
- package/dist/sources/provider-factory.js +5 -12
- package/dist/sources/provider.js +3 -20
- package/dist/sources/providers/filesystem.js +19 -23
- package/dist/sources/providers/git.js +7 -5
- package/dist/sources/providers/index.js +3 -0
- package/dist/sources/providers/install-types.js +3 -13
- package/dist/sources/providers/npm.js +3 -4
- package/dist/sources/providers/provider-utils.js +3 -0
- package/dist/sources/providers/sync-from-ref.js +3 -11
- package/dist/sources/providers/tar-utils.js +3 -0
- package/dist/sources/providers/website.js +18 -22
- package/dist/sources/resolve.js +3 -0
- package/dist/sources/types.js +3 -0
- package/dist/sources/website-ingest.js +7 -0
- package/dist/tasks/backends/cron.js +203 -0
- package/dist/tasks/backends/exec-utils.js +28 -0
- package/dist/tasks/backends/index.js +24 -0
- package/dist/tasks/backends/launchd-template.xml +19 -0
- package/dist/tasks/backends/launchd.js +187 -0
- package/dist/tasks/backends/schtasks-template.xml +29 -0
- package/dist/tasks/backends/schtasks.js +215 -0
- package/dist/tasks/parser.js +211 -0
- package/dist/tasks/resolveAkmBin.js +87 -0
- package/dist/tasks/runner.js +458 -0
- package/dist/tasks/schedule.js +211 -0
- package/dist/tasks/schema.js +15 -0
- package/dist/tasks/validator.js +62 -0
- package/dist/version.js +3 -0
- package/dist/wiki/index-template.md +12 -0
- package/dist/wiki/ingest-workflow-template.md +54 -0
- package/dist/wiki/log-template.md +8 -0
- package/dist/wiki/schema-template.md +61 -0
- package/dist/wiki/wiki-templates.js +15 -0
- package/dist/wiki/wiki.js +13 -61
- package/dist/workflows/authoring.js +8 -25
- package/dist/workflows/cli.js +3 -0
- package/dist/workflows/db.js +140 -10
- package/dist/workflows/document-cache.js +3 -10
- package/dist/workflows/parser.js +3 -0
- package/dist/workflows/renderer.js +11 -3
- package/dist/workflows/runs.js +62 -91
- package/dist/workflows/schema.js +3 -0
- package/dist/workflows/scope-key.js +3 -0
- package/dist/workflows/validator.js +4 -8
- package/dist/workflows/workflow-template.md +24 -0
- package/docs/README.md +9 -2
- package/docs/data-and-telemetry.md +225 -0
- package/docs/migration/release-notes/0.7.0.md +1 -1
- package/docs/migration/release-notes/0.7.5.md +2 -2
- package/docs/migration/release-notes/0.8.0.md +48 -0
- package/docs/migration/v0.7-to-v0.8.md +1307 -0
- package/package.json +20 -8
- package/.github/LICENSE +0 -374
- package/dist/commands/install-audit.js +0 -381
- package/dist/templates/wiki-templates.js +0 -100
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* Shared text-truncation heuristics used by `distill` and `consolidate`.
|
|
6
|
+
*
|
|
7
|
+
* Both commands need to recognise when an LLM-produced description string was
|
|
8
|
+
* sliced mid-sentence (typically the model hit its output budget). The two
|
|
9
|
+
* implementations historically maintained overlapping-but-not-identical
|
|
10
|
+
* vocabularies of hanging-connector words, which was a maintenance trap.
|
|
11
|
+
*
|
|
12
|
+
* This module is the single source of truth. `distill` continues to layer its
|
|
13
|
+
* own section-heading regex on top (those patterns are distill-specific and
|
|
14
|
+
* intentionally stay local to that module).
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Words that, when ending a sentence, suggest the description was truncated
|
|
18
|
+
* mid-sentence. Prepositions, conjunctions, articles, and auxiliary verbs that
|
|
19
|
+
* almost always have *something* following them in well-formed prose.
|
|
20
|
+
*
|
|
21
|
+
* This is the UNION of the two prior vocabularies used by `distill` and
|
|
22
|
+
* `consolidate` — a superset of both, so behaviour is at least as strict as
|
|
23
|
+
* either previous check.
|
|
24
|
+
*
|
|
25
|
+
* Stored lowercased; callers must lower-case the last word before lookup.
|
|
26
|
+
*/
|
|
27
|
+
export const TRUNCATION_TRAILING_WORDS = new Set([
|
|
28
|
+
"a",
|
|
29
|
+
"after",
|
|
30
|
+
"an",
|
|
31
|
+
"and",
|
|
32
|
+
"are",
|
|
33
|
+
"as",
|
|
34
|
+
"at",
|
|
35
|
+
"be",
|
|
36
|
+
"been",
|
|
37
|
+
"before",
|
|
38
|
+
"being",
|
|
39
|
+
"but",
|
|
40
|
+
"by",
|
|
41
|
+
"can",
|
|
42
|
+
"could",
|
|
43
|
+
"did",
|
|
44
|
+
"do",
|
|
45
|
+
"does",
|
|
46
|
+
"for",
|
|
47
|
+
"from",
|
|
48
|
+
"had",
|
|
49
|
+
"has",
|
|
50
|
+
"have",
|
|
51
|
+
"if",
|
|
52
|
+
"in",
|
|
53
|
+
"into",
|
|
54
|
+
"is",
|
|
55
|
+
"may",
|
|
56
|
+
"might",
|
|
57
|
+
"must",
|
|
58
|
+
"of",
|
|
59
|
+
"on",
|
|
60
|
+
"onto",
|
|
61
|
+
"or",
|
|
62
|
+
"per",
|
|
63
|
+
"shall",
|
|
64
|
+
"should",
|
|
65
|
+
"so",
|
|
66
|
+
"than",
|
|
67
|
+
"that",
|
|
68
|
+
"the",
|
|
69
|
+
"to",
|
|
70
|
+
"upon",
|
|
71
|
+
"via",
|
|
72
|
+
"was",
|
|
73
|
+
"were",
|
|
74
|
+
"when",
|
|
75
|
+
"which",
|
|
76
|
+
"while",
|
|
77
|
+
"will",
|
|
78
|
+
"with",
|
|
79
|
+
"would",
|
|
80
|
+
]);
|
|
81
|
+
/**
|
|
82
|
+
* Returns a reason string when `description` looks truncated mid-sentence;
|
|
83
|
+
* returns `null` if the description appears complete.
|
|
84
|
+
*
|
|
85
|
+
* Heuristics:
|
|
86
|
+
* - Trailing `,`, `;`, `:`, or `+` (operator-style cutoff like `max-width:100% +`)
|
|
87
|
+
* - Trailing ellipsis (`...` or `…`)
|
|
88
|
+
* - Last word matches {@link TRUNCATION_TRAILING_WORDS}
|
|
89
|
+
*
|
|
90
|
+
* Does NOT detect section-heading fragments — that check is distill-specific
|
|
91
|
+
* and lives in `src/commands/distill.ts` (`HEADING_FRAGMENT_PATTERNS`).
|
|
92
|
+
*/
|
|
93
|
+
export function detectTruncatedDescription(description) {
|
|
94
|
+
const trimmed = description.trim();
|
|
95
|
+
if (trimmed.length === 0)
|
|
96
|
+
return null; // empty handled elsewhere
|
|
97
|
+
if (/[,;:+]$/.test(trimmed))
|
|
98
|
+
return "ends with trailing punctuation/operator";
|
|
99
|
+
if (/\.{3,}$/.test(trimmed) || /…$/.test(trimmed))
|
|
100
|
+
return "ends with ellipsis";
|
|
101
|
+
const lastWord = trimmed.split(/\s+/).pop() ?? "";
|
|
102
|
+
const normalized = lastWord.toLowerCase();
|
|
103
|
+
if (TRUNCATION_TRAILING_WORDS.has(normalized)) {
|
|
104
|
+
return `ends with hanging connector "${lastWord}"`;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* Shared time and date utilities.
|
|
6
|
+
*
|
|
7
|
+
* Centralises parsing of user-facing `--since` values so that all consumers
|
|
8
|
+
* interpret the same set of formats (ISO-8601, epoch ms, plain date strings)
|
|
9
|
+
* consistently without private re-implementations drifting apart.
|
|
10
|
+
*/
|
|
11
|
+
import { UsageError } from "./errors";
|
|
12
|
+
// ── Since-flag parsing ───────────────────────────────────────────────────────
|
|
13
|
+
/**
|
|
14
|
+
* Parse a user-supplied `--since` value and return an ISO-8601 timestamp
|
|
15
|
+
* string (e.g. `"2026-01-15T10:30:00.000Z"`).
|
|
16
|
+
*
|
|
17
|
+
* Accepted input formats:
|
|
18
|
+
* - ISO-8601 timestamp (preferred): `"2026-04-01T00:00:00Z"`
|
|
19
|
+
* - Plain date: `"2026-04-01"` (interpreted as start-of-day UTC)
|
|
20
|
+
* - Epoch milliseconds (pure digit string): `"1744329600000"`
|
|
21
|
+
* - Any other value parseable by `new Date()`
|
|
22
|
+
*
|
|
23
|
+
* Callers that need a different wire format (e.g. SQLite `"YYYY-MM-DD HH:MM:SS"`)
|
|
24
|
+
* should convert the returned ISO string themselves.
|
|
25
|
+
*
|
|
26
|
+
* @throws {UsageError} when `since` is empty or cannot be parsed as a date.
|
|
27
|
+
*/
|
|
28
|
+
export function parseSinceToIso(since) {
|
|
29
|
+
const trimmed = since.trim();
|
|
30
|
+
if (!trimmed) {
|
|
31
|
+
throw new UsageError("--since cannot be empty.", "INVALID_FLAG_VALUE");
|
|
32
|
+
}
|
|
33
|
+
// Pure-digit input → epoch milliseconds
|
|
34
|
+
if (/^\d+$/.test(trimmed)) {
|
|
35
|
+
const ms = Number.parseInt(trimmed, 10);
|
|
36
|
+
const d = new Date(ms);
|
|
37
|
+
if (Number.isNaN(d.getTime())) {
|
|
38
|
+
throw new UsageError(`Invalid --since value: ${since}`, "INVALID_FLAG_VALUE");
|
|
39
|
+
}
|
|
40
|
+
return d.toISOString();
|
|
41
|
+
}
|
|
42
|
+
const parsed = new Date(trimmed);
|
|
43
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
44
|
+
throw new UsageError(`Invalid --since value: ${since}. Expected ISO timestamp (e.g. 2026-04-01T00:00:00Z) or epoch ms.`, "INVALID_FLAG_VALUE");
|
|
45
|
+
}
|
|
46
|
+
return parsed.toISOString();
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Convert an ISO-8601 timestamp string to the SQLite datetime format
|
|
50
|
+
* `"YYYY-MM-DD HH:MM:SS"` used by `datetime('now')`.
|
|
51
|
+
*/
|
|
52
|
+
export function isoToSqlite(iso) {
|
|
53
|
+
return iso.replace("T", " ").replace(/\.\d+Z$/, "");
|
|
54
|
+
}
|
package/dist/core/warn.js
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
1
4
|
/**
|
|
2
|
-
* Module-level quiet/verbose flags for stderr
|
|
5
|
+
* Module-level quiet/verbose flags and optional file sink for stderr output.
|
|
3
6
|
*
|
|
4
7
|
* `quiet` is controlled by the CLI `--quiet`/`-q` flag.
|
|
5
8
|
* `verbose` is controlled by the CLI `--verbose` flag, with `AKM_VERBOSE`
|
|
6
9
|
* (env var) winning regardless: env > flag > default (false).
|
|
10
|
+
*
|
|
11
|
+
* Call `setLogFile(path)` to tee all warn/error/info output to a file in
|
|
12
|
+
* addition to stderr. The file sink is written even when `--quiet` suppresses
|
|
13
|
+
* console output, so logs remain available for post-run inspection.
|
|
7
14
|
*/
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
8
17
|
let quiet = false;
|
|
9
18
|
let verbose = false;
|
|
19
|
+
let logFilePath;
|
|
10
20
|
export function setQuiet(value) {
|
|
11
21
|
quiet = value;
|
|
12
22
|
}
|
|
@@ -51,15 +61,66 @@ export function isVerbose() {
|
|
|
51
61
|
return false;
|
|
52
62
|
return verbose;
|
|
53
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Direct all warn/error/info output to `filePath` in addition to stderr.
|
|
66
|
+
* The directory is created if it does not exist. Pass `undefined` to disable.
|
|
67
|
+
* The file is written even when `--quiet` suppresses console output.
|
|
68
|
+
*/
|
|
69
|
+
export function setLogFile(filePath) {
|
|
70
|
+
logFilePath = filePath;
|
|
71
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
72
|
+
}
|
|
73
|
+
export function clearLogFile() {
|
|
74
|
+
logFilePath = undefined;
|
|
75
|
+
}
|
|
76
|
+
export function getLogFile() {
|
|
77
|
+
return logFilePath;
|
|
78
|
+
}
|
|
79
|
+
function appendToLogFile(level, args) {
|
|
80
|
+
if (!logFilePath)
|
|
81
|
+
return;
|
|
82
|
+
const ts = new Date().toISOString();
|
|
83
|
+
const msg = args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ");
|
|
84
|
+
try {
|
|
85
|
+
fs.appendFileSync(logFilePath, `[${ts}] [${level}] ${msg}\n`);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Never throw from a logging function — log failures are silent.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Emit an info/progress line to stderr unless --quiet is active.
|
|
93
|
+
* Always written to the log file if one is active.
|
|
94
|
+
* Use for progress counters and status lines (replaces console.error used for progress).
|
|
95
|
+
*/
|
|
96
|
+
export function info(...args) {
|
|
97
|
+
appendToLogFile("INFO", args);
|
|
98
|
+
if (!quiet) {
|
|
99
|
+
console.warn(...args);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
54
102
|
/**
|
|
55
103
|
* Emit a warning to stderr unless --quiet is active.
|
|
104
|
+
* Always written to the log file if one is active.
|
|
56
105
|
* Drop-in replacement for console.warn() across the codebase.
|
|
57
106
|
*/
|
|
58
107
|
export function warn(...args) {
|
|
108
|
+
appendToLogFile("WARN", args);
|
|
59
109
|
if (!quiet) {
|
|
60
110
|
console.warn(...args);
|
|
61
111
|
}
|
|
62
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Emit an error to stderr unless --quiet is active.
|
|
115
|
+
* Always written to the log file if one is active.
|
|
116
|
+
* Drop-in replacement for console.error() used for diagnostic failures.
|
|
117
|
+
*/
|
|
118
|
+
export function error(...args) {
|
|
119
|
+
appendToLogFile("ERROR", args);
|
|
120
|
+
if (!quiet) {
|
|
121
|
+
console.error(...args);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
63
124
|
/**
|
|
64
125
|
* Emit a warning only when verbose output is requested. Use for noisy
|
|
65
126
|
* per-item diagnostics that should be replaced by a one-line summary at
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
1
4
|
/**
|
|
2
5
|
* write-source — the only place in the codebase that branches on `source.kind`.
|
|
3
6
|
*
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* MVP data-directory backup for AKM.
|
|
6
|
+
*
|
|
7
|
+
* The DB upgrade path in `src/indexer/db.ts` `handleVersionUpgrade()` is
|
|
8
|
+
* intentionally destructive: when `DB_VERSION` bumps and a stored DB is at an
|
|
9
|
+
* older version, ~17 tables are dropped and recreated. Until 0.9.0 ships a
|
|
10
|
+
* full migration framework, this MVP captures a recursive copy of the entire
|
|
11
|
+
* data directory just before that drop happens so an operator can manually
|
|
12
|
+
* recover lost rows by stopping akm and moving the backup contents back over
|
|
13
|
+
* the live data dir (see `scripts/migrations/restore-data-dir.sh`).
|
|
14
|
+
*
|
|
15
|
+
* The helper is intentionally narrow:
|
|
16
|
+
* - No `VACUUM INTO`, no selective table backup — just `fs.cpSync` of the
|
|
17
|
+
* data directory into `<dataDir>/backups/<timestamp>-pre-v<targetVersion>/`.
|
|
18
|
+
* - Skips the `backups/` subdirectory inside the data dir so we never
|
|
19
|
+
* recurse into our own backup history.
|
|
20
|
+
* - Opt-out via `AKM_DB_BACKUP=0`. Backup failures NEVER abort the upgrade —
|
|
21
|
+
* they warn and proceed (the alternative would brick a user trying to
|
|
22
|
+
* start a binary that bumped DB_VERSION on a full disk).
|
|
23
|
+
* - Retention is FIFO with default of 5, configurable via
|
|
24
|
+
* `AKM_DB_BACKUP_RETAIN`.
|
|
25
|
+
* - Disk-space guard: refuses to write when free space on the destination
|
|
26
|
+
* filesystem is less than 1.1× the source size.
|
|
27
|
+
*/
|
|
28
|
+
import fs from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import { warn } from "../core/warn";
|
|
31
|
+
/** Default reason recorded for backups that don't override it. */
|
|
32
|
+
export const DEFAULT_BACKUP_REASON = "version-upgrade";
|
|
33
|
+
/** Reason recorded for backups taken before the embedding-dim drop path. */
|
|
34
|
+
export const EMBEDDING_DIM_CHANGE_REASON = "embedding-dim-change";
|
|
35
|
+
const BACKUPS_DIR_NAME = "backups";
|
|
36
|
+
const BACKUP_METADATA_FILE = "backup.meta.json";
|
|
37
|
+
const DEFAULT_RETAIN = 5;
|
|
38
|
+
const FREE_SPACE_MULTIPLIER = 1.1;
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the configured retention count from the env, with a hard floor of 1.
|
|
41
|
+
*
|
|
42
|
+
* Invalid values (non-integer, negative) fall back to the default and emit a
|
|
43
|
+
* one-line warning so operators notice their env var is wrong.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveRetention(env = process.env) {
|
|
46
|
+
const raw = env.AKM_DB_BACKUP_RETAIN?.trim();
|
|
47
|
+
if (!raw)
|
|
48
|
+
return DEFAULT_RETAIN;
|
|
49
|
+
const parsed = Number.parseInt(raw, 10);
|
|
50
|
+
if (Number.isNaN(parsed) || parsed < 1) {
|
|
51
|
+
warn("[akm] AKM_DB_BACKUP_RETAIN=%s is not a positive integer; falling back to %d", raw, DEFAULT_RETAIN);
|
|
52
|
+
return DEFAULT_RETAIN;
|
|
53
|
+
}
|
|
54
|
+
return parsed;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Returns true when the user has explicitly opted out via `AKM_DB_BACKUP=0`
|
|
58
|
+
* (or `false`/`no`/`off`). Any other value — including unset — opts in.
|
|
59
|
+
*/
|
|
60
|
+
export function isBackupDisabled(env = process.env) {
|
|
61
|
+
const raw = env.AKM_DB_BACKUP?.trim().toLowerCase();
|
|
62
|
+
if (!raw)
|
|
63
|
+
return false;
|
|
64
|
+
return raw === "0" || raw === "false" || raw === "no" || raw === "off";
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Recursively sum the byte size of `dirPath`, skipping the embedded backups
|
|
68
|
+
* directory so the size we report (and check against free space) reflects
|
|
69
|
+
* what we'd actually copy.
|
|
70
|
+
*/
|
|
71
|
+
export function measureDataDirSize(dirPath) {
|
|
72
|
+
if (!fs.existsSync(dirPath))
|
|
73
|
+
return 0;
|
|
74
|
+
let total = 0;
|
|
75
|
+
const stack = [dirPath];
|
|
76
|
+
while (stack.length > 0) {
|
|
77
|
+
const current = stack.pop();
|
|
78
|
+
if (current === undefined)
|
|
79
|
+
break;
|
|
80
|
+
let entries;
|
|
81
|
+
try {
|
|
82
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// Unreadable directory — skip; we don't want measurement to throw.
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
const full = path.join(current, entry.name);
|
|
90
|
+
// Skip the embedded backups directory at the root so we don't
|
|
91
|
+
// double-count prior backups in size calculations.
|
|
92
|
+
if (current === dirPath && entry.name === BACKUPS_DIR_NAME && entry.isDirectory())
|
|
93
|
+
continue;
|
|
94
|
+
if (entry.isDirectory()) {
|
|
95
|
+
stack.push(full);
|
|
96
|
+
}
|
|
97
|
+
else if (entry.isFile()) {
|
|
98
|
+
try {
|
|
99
|
+
total += fs.statSync(full).size;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// File vanished between readdir and stat — ignore.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return total;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Best-effort free-space query for the filesystem hosting `dirPath`. Returns
|
|
111
|
+
* `null` when the runtime cannot report statfs (older Node/Bun, exotic FS) —
|
|
112
|
+
* the caller treats `null` as "skip the disk-space check" rather than
|
|
113
|
+
* "abort the backup".
|
|
114
|
+
*/
|
|
115
|
+
function getFreeSpace(dirPath) {
|
|
116
|
+
try {
|
|
117
|
+
// `fs.statfsSync` is available in Node 18.15+ and Bun 1.0+.
|
|
118
|
+
const stats = fs.statfsSync;
|
|
119
|
+
if (!stats)
|
|
120
|
+
return null;
|
|
121
|
+
const res = stats(dirPath);
|
|
122
|
+
return Number(res.bavail * res.bsize);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Format the current time into a filename-safe timestamp.
|
|
130
|
+
*
|
|
131
|
+
* Example: `2026-05-19T04-59-36`.
|
|
132
|
+
*/
|
|
133
|
+
function formatTimestamp(d) {
|
|
134
|
+
// ISO 8601 without colons/dots so the path is portable to Windows + tarballs.
|
|
135
|
+
return d
|
|
136
|
+
.toISOString()
|
|
137
|
+
.replace(/[:.]/g, "-")
|
|
138
|
+
.replace(/Z$/, "")
|
|
139
|
+
.replace(/-\d{3}$/, "");
|
|
140
|
+
}
|
|
141
|
+
export function listBackups(dataDir) {
|
|
142
|
+
const backupsRoot = path.join(dataDir, BACKUPS_DIR_NAME);
|
|
143
|
+
if (!fs.existsSync(backupsRoot))
|
|
144
|
+
return [];
|
|
145
|
+
const entries = fs.readdirSync(backupsRoot, { withFileTypes: true });
|
|
146
|
+
const results = [];
|
|
147
|
+
for (const entry of entries) {
|
|
148
|
+
if (!entry.isDirectory())
|
|
149
|
+
continue;
|
|
150
|
+
const full = path.join(backupsRoot, entry.name);
|
|
151
|
+
const metaPath = path.join(full, BACKUP_METADATA_FILE);
|
|
152
|
+
let createdAt;
|
|
153
|
+
let sourceVersion = null;
|
|
154
|
+
let sizeBytes;
|
|
155
|
+
let reason = DEFAULT_BACKUP_REASON;
|
|
156
|
+
if (fs.existsSync(metaPath)) {
|
|
157
|
+
try {
|
|
158
|
+
const raw = fs.readFileSync(metaPath, "utf8");
|
|
159
|
+
const parsed = JSON.parse(raw);
|
|
160
|
+
if (typeof parsed.createdAt === "string")
|
|
161
|
+
createdAt = parsed.createdAt;
|
|
162
|
+
if (typeof parsed.sourceVersion === "number")
|
|
163
|
+
sourceVersion = parsed.sourceVersion;
|
|
164
|
+
else if (parsed.sourceVersion === null)
|
|
165
|
+
sourceVersion = null;
|
|
166
|
+
if (typeof parsed.sizeBytes === "number")
|
|
167
|
+
sizeBytes = parsed.sizeBytes;
|
|
168
|
+
if (typeof parsed.reason === "string" && parsed.reason.length > 0)
|
|
169
|
+
reason = parsed.reason;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// Malformed metadata — fall back to filesystem-derived values.
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (!createdAt) {
|
|
176
|
+
try {
|
|
177
|
+
createdAt = fs.statSync(full).mtime.toISOString();
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
createdAt = new Date(0).toISOString();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (sizeBytes === undefined) {
|
|
184
|
+
sizeBytes = measureDataDirSize(full);
|
|
185
|
+
}
|
|
186
|
+
results.push({
|
|
187
|
+
path: full,
|
|
188
|
+
name: entry.name,
|
|
189
|
+
createdAt,
|
|
190
|
+
sizeBytes,
|
|
191
|
+
sourceVersion,
|
|
192
|
+
reason,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
// Sort newest first.
|
|
196
|
+
results.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
|
|
197
|
+
return results;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Drop oldest backups until at most `retain` remain. The newest backup (the
|
|
201
|
+
* one we just created) is always preserved — pruning happens AFTER the new
|
|
202
|
+
* backup is written, so `retain=5` plus a fresh write means we keep the new
|
|
203
|
+
* write and prune down to 5 total entries.
|
|
204
|
+
*/
|
|
205
|
+
function pruneOldBackups(dataDir, retain) {
|
|
206
|
+
const existing = listBackups(dataDir);
|
|
207
|
+
if (existing.length <= retain)
|
|
208
|
+
return;
|
|
209
|
+
const toRemove = existing.slice(retain);
|
|
210
|
+
for (const entry of toRemove) {
|
|
211
|
+
try {
|
|
212
|
+
fs.rmSync(entry.path, { recursive: true, force: true });
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
warn("[akm] failed to prune old backup %s — %s", entry.path, err instanceof Error ? err.message : String(err));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Capture a recursive copy of `dataDir` under `<dataDir>/backups/`, skipping
|
|
221
|
+
* the backups subdirectory itself. Returns the BackupResult on success or
|
|
222
|
+
* `null` when the backup was skipped (opt-out, missing data dir, insufficient
|
|
223
|
+
* disk space, or a copy error — all of which should be non-fatal so the
|
|
224
|
+
* upgrade path can still proceed).
|
|
225
|
+
*/
|
|
226
|
+
export function backupDataDir(opts) {
|
|
227
|
+
const env = opts.env ?? process.env;
|
|
228
|
+
if (isBackupDisabled(env))
|
|
229
|
+
return null;
|
|
230
|
+
const { dataDir, sourceVersion, targetVersion } = opts;
|
|
231
|
+
const reason = opts.reason && opts.reason.length > 0 ? opts.reason : DEFAULT_BACKUP_REASON;
|
|
232
|
+
if (!fs.existsSync(dataDir)) {
|
|
233
|
+
// Fresh install — nothing to back up.
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
const dataDirStat = fs.statSync(dataDir);
|
|
237
|
+
if (!dataDirStat.isDirectory()) {
|
|
238
|
+
warn("[akm] data dir backup skipped — %s is not a directory", dataDir);
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
// Empty data dir (or only a `backups/` subdir) → nothing meaningful to back up.
|
|
242
|
+
const sourceSize = measureDataDirSize(dataDir);
|
|
243
|
+
if (sourceSize === 0)
|
|
244
|
+
return null;
|
|
245
|
+
const backupsRoot = path.join(dataDir, BACKUPS_DIR_NAME);
|
|
246
|
+
try {
|
|
247
|
+
fs.mkdirSync(backupsRoot, { recursive: true });
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
warn("[akm] data dir backup skipped — could not create %s: %s", backupsRoot, err instanceof Error ? err.message : String(err));
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
// Disk-space guard. Skip the check when statfs is unavailable.
|
|
254
|
+
const free = getFreeSpace(backupsRoot);
|
|
255
|
+
if (free !== null && free < sourceSize * FREE_SPACE_MULTIPLIER) {
|
|
256
|
+
warn("[akm] data dir backup skipped — free space %d bytes is less than 1.1× source size %d bytes (need %d)", free, sourceSize, Math.ceil(sourceSize * FREE_SPACE_MULTIPLIER));
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
const now = (opts.now ?? (() => new Date()))();
|
|
260
|
+
const stamp = formatTimestamp(now);
|
|
261
|
+
// Reason tags drive the directory suffix so operators can tell a
|
|
262
|
+
// version-upgrade snapshot apart from an embedding-dim-change snapshot.
|
|
263
|
+
// `version-upgrade` keeps the historical `pre-v<N>` suffix for backward
|
|
264
|
+
// compatibility with `scripts/migrations/restore-data-dir.sh` and existing
|
|
265
|
+
// tests; any other reason is appended verbatim.
|
|
266
|
+
const dirSuffix = reason === DEFAULT_BACKUP_REASON ? `pre-v${targetVersion}` : reason;
|
|
267
|
+
const dirName = `${stamp}-${dirSuffix}`;
|
|
268
|
+
const destPath = path.join(backupsRoot, dirName);
|
|
269
|
+
// If a previous run on the same second tried to write this name, append a
|
|
270
|
+
// short disambiguator. We don't want to overwrite or merge into an existing
|
|
271
|
+
// backup directory.
|
|
272
|
+
let finalDest = destPath;
|
|
273
|
+
let suffix = 1;
|
|
274
|
+
while (fs.existsSync(finalDest)) {
|
|
275
|
+
finalDest = `${destPath}-${suffix}`;
|
|
276
|
+
suffix += 1;
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
// We can't use fs.cpSync directly because the destination
|
|
280
|
+
// (<dataDir>/backups/<stamp>-pre-v<N>/) is inside the source dataDir, and
|
|
281
|
+
// cpSync refuses to copy into a subdirectory of the source. So we do a
|
|
282
|
+
// manual recursive walk that explicitly skips the backups subtree, plus
|
|
283
|
+
// the lockfile/sentinel that would race with any live process.
|
|
284
|
+
copyDataDirExcludingBackups(dataDir, finalDest);
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
warn("[akm] data dir backup failed — %s; upgrade will proceed without a snapshot", err instanceof Error ? err.message : String(err));
|
|
288
|
+
// Best-effort cleanup of the partial copy so we don't litter the data dir.
|
|
289
|
+
try {
|
|
290
|
+
fs.rmSync(finalDest, { recursive: true, force: true });
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
/* ignore */
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
const createdAt = now.toISOString();
|
|
298
|
+
const metadata = {
|
|
299
|
+
schemaVersion: 1,
|
|
300
|
+
createdAt,
|
|
301
|
+
sourceVersion,
|
|
302
|
+
targetVersion,
|
|
303
|
+
sizeBytes: sourceSize,
|
|
304
|
+
reason,
|
|
305
|
+
hostname: tryHostname(),
|
|
306
|
+
notes: reason === DEFAULT_BACKUP_REASON
|
|
307
|
+
? "Created by AKM before a destructive DB version upgrade. Restore manually by stopping akm and copying the contents back over the live data dir."
|
|
308
|
+
: `Created by AKM before a destructive ${reason} operation. Restore manually by stopping akm and copying the contents back over the live data dir.`,
|
|
309
|
+
};
|
|
310
|
+
try {
|
|
311
|
+
fs.writeFileSync(path.join(finalDest, BACKUP_METADATA_FILE), JSON.stringify(metadata, null, 2));
|
|
312
|
+
}
|
|
313
|
+
catch (err) {
|
|
314
|
+
// Metadata is non-essential — warn but keep the copy.
|
|
315
|
+
warn("[akm] data dir backup created at %s but metadata write failed — %s", finalDest, err instanceof Error ? err.message : String(err));
|
|
316
|
+
}
|
|
317
|
+
const retain = resolveRetention(env);
|
|
318
|
+
pruneOldBackups(dataDir, retain);
|
|
319
|
+
return {
|
|
320
|
+
path: finalDest,
|
|
321
|
+
name: path.basename(finalDest),
|
|
322
|
+
createdAt,
|
|
323
|
+
sizeBytes: sourceSize,
|
|
324
|
+
sourceVersion,
|
|
325
|
+
targetVersion,
|
|
326
|
+
reason,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Recursively copy `srcRoot` to `destRoot`, skipping:
|
|
331
|
+
* - `<srcRoot>/backups` (so we don't recurse into our own backup history)
|
|
332
|
+
* - `<srcRoot>/akm.lock` and `<srcRoot>/akm.lock.lck` (per-process state
|
|
333
|
+
* that would race with a live process holding the lock)
|
|
334
|
+
*
|
|
335
|
+
* Implemented manually because `fs.cpSync` refuses to copy a directory into a
|
|
336
|
+
* subdirectory of itself, and our destination (`<dataDir>/backups/<stamp>`)
|
|
337
|
+
* is by design inside the source `<dataDir>`.
|
|
338
|
+
*/
|
|
339
|
+
function copyDataDirExcludingBackups(srcRoot, destRoot) {
|
|
340
|
+
fs.mkdirSync(destRoot, { recursive: true });
|
|
341
|
+
const stack = [{ src: srcRoot, dest: destRoot }];
|
|
342
|
+
while (stack.length > 0) {
|
|
343
|
+
const frame = stack.pop();
|
|
344
|
+
if (frame === undefined)
|
|
345
|
+
break;
|
|
346
|
+
const { src, dest } = frame;
|
|
347
|
+
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
348
|
+
for (const entry of entries) {
|
|
349
|
+
// Skip the embedded backups directory and the lockfile/sentinel — only
|
|
350
|
+
// at the root level. (A `backups` directory deep in a wiki source tree,
|
|
351
|
+
// for instance, must still be copied.)
|
|
352
|
+
if (src === srcRoot) {
|
|
353
|
+
if (entry.name === BACKUPS_DIR_NAME && entry.isDirectory())
|
|
354
|
+
continue;
|
|
355
|
+
if (entry.name === "akm.lock" || entry.name === "akm.lock.lck")
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const srcPath = path.join(src, entry.name);
|
|
359
|
+
const destPath = path.join(dest, entry.name);
|
|
360
|
+
if (entry.isDirectory()) {
|
|
361
|
+
fs.mkdirSync(destPath, { recursive: true });
|
|
362
|
+
stack.push({ src: srcPath, dest: destPath });
|
|
363
|
+
}
|
|
364
|
+
else if (entry.isFile()) {
|
|
365
|
+
fs.copyFileSync(srcPath, destPath);
|
|
366
|
+
}
|
|
367
|
+
else if (entry.isSymbolicLink()) {
|
|
368
|
+
// Preserve symlinks as-is rather than dereferencing them. A stash
|
|
369
|
+
// dir occasionally carries symlinked source roots; following them
|
|
370
|
+
// could explode the backup size unexpectedly.
|
|
371
|
+
const target = fs.readlinkSync(srcPath);
|
|
372
|
+
try {
|
|
373
|
+
fs.symlinkSync(target, destPath);
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
/* ignore — symlink creation can fail on Windows without admin */
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
// Other entry types (block/character/fifo/socket) are silently skipped.
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function tryHostname() {
|
|
384
|
+
try {
|
|
385
|
+
const os = require("node:os");
|
|
386
|
+
return os.hostname();
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return undefined;
|
|
390
|
+
}
|
|
391
|
+
}
|