linkany 0.0.1

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,149 @@
1
+ import path from 'path';
2
+ import { nodeFS } from './fs.js';
3
+ function rand() {
4
+ return Math.random().toString(16).slice(2);
5
+ }
6
+ export function tmpPathForTarget(targetAbs) {
7
+ return `${targetAbs}.tmp.${rand()}`;
8
+ }
9
+ export function backupPathForTarget(targetAbs) {
10
+ return `${targetAbs}.bak.${Date.now()}.${rand()}`;
11
+ }
12
+ export async function detectKind(p) {
13
+ return detectKindWithFS(nodeFS, p);
14
+ }
15
+ export async function detectKindWithFS(fs, p) {
16
+ const st = await fs.lstat(p);
17
+ if (st.isDirectory())
18
+ return 'dir';
19
+ return 'file';
20
+ }
21
+ export async function isSymlinkTo(targetAbs, sourceAbs) {
22
+ return isSymlinkToWithFS(nodeFS, targetAbs, sourceAbs);
23
+ }
24
+ export async function isSymlinkToWithFS(fs, targetAbs, sourceAbs) {
25
+ try {
26
+ const st = await fs.lstat(targetAbs);
27
+ if (!st.isSymbolicLink())
28
+ return false;
29
+ const link = await fs.readlink(targetAbs);
30
+ const resolved = path.resolve(path.dirname(targetAbs), link);
31
+ return path.normalize(resolved) === path.normalize(sourceAbs);
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ export async function planEnsureSource(spec) {
38
+ return planEnsureSourceWithFS(nodeFS, spec);
39
+ }
40
+ export async function planEnsureSourceWithFS(fs, spec) {
41
+ const steps = [];
42
+ if (await fs.pathExists(spec.sourceAbs))
43
+ return steps;
44
+ steps.push({
45
+ kind: 'mkdirp',
46
+ message: `Ensure parent dir for source exists`,
47
+ paths: { dir: path.dirname(spec.sourceAbs) },
48
+ });
49
+ if (spec.kind === 'dir') {
50
+ steps.push({
51
+ kind: 'mkdirp',
52
+ message: `Create source directory`,
53
+ paths: { dir: spec.sourceAbs },
54
+ });
55
+ }
56
+ else {
57
+ steps.push({
58
+ kind: 'touch',
59
+ message: `Create empty source file`,
60
+ paths: { file: spec.sourceAbs },
61
+ });
62
+ }
63
+ return steps;
64
+ }
65
+ export async function planEnsureLink(spec) {
66
+ return planEnsureLinkWithFS(nodeFS, spec);
67
+ }
68
+ export async function planEnsureLinkWithFS(fs, spec) {
69
+ const steps = [];
70
+ if (await isSymlinkToWithFS(fs, spec.targetAbs, spec.sourceAbs)) {
71
+ return { steps, reason: 'noop' };
72
+ }
73
+ const targetExists = await fs.pathExists(spec.targetAbs);
74
+ if (targetExists) {
75
+ const st = await fs.lstat(spec.targetAbs);
76
+ if (!st.isSymbolicLink()) {
77
+ return { steps, reason: 'conflict' };
78
+ }
79
+ steps.push({
80
+ kind: 'unlink',
81
+ message: 'Remove existing symlink before re-link',
82
+ paths: { target: spec.targetAbs },
83
+ });
84
+ }
85
+ steps.push({
86
+ kind: 'mkdirp',
87
+ message: 'Ensure target parent directory exists',
88
+ paths: { dir: path.dirname(spec.targetAbs) },
89
+ });
90
+ const tmp = spec.atomic ? tmpPathForTarget(spec.targetAbs) : spec.targetAbs;
91
+ steps.push({
92
+ kind: 'symlink',
93
+ message: spec.atomic ? 'Create symlink at temp path' : 'Create symlink',
94
+ paths: { source: spec.sourceAbs, target: tmp, kind: spec.kind },
95
+ });
96
+ if (spec.atomic) {
97
+ steps.push({
98
+ kind: 'move',
99
+ message: 'Atomically move temp symlink into place',
100
+ paths: { from: tmp, to: spec.targetAbs },
101
+ });
102
+ }
103
+ return { steps, reason: targetExists ? 'replace_symlink' : 'create' };
104
+ }
105
+ export async function planUnlink(spec) {
106
+ return planUnlinkWithFS(nodeFS, spec);
107
+ }
108
+ export async function planUnlinkWithFS(fs, spec) {
109
+ const steps = [];
110
+ if (!await fs.pathExists(spec.targetAbs))
111
+ return steps;
112
+ const st = await fs.lstat(spec.targetAbs);
113
+ if (!st.isSymbolicLink()) {
114
+ steps.push({
115
+ kind: 'noop',
116
+ message: 'Target exists but is not a symlink; skipping unlink for safety',
117
+ paths: { target: spec.targetAbs },
118
+ });
119
+ return steps;
120
+ }
121
+ steps.push({
122
+ kind: 'unlink',
123
+ message: 'Remove target symlink',
124
+ paths: { target: spec.targetAbs },
125
+ });
126
+ return steps;
127
+ }
128
+ export async function planCopy(spec) {
129
+ const steps = [];
130
+ steps.push({
131
+ kind: 'mkdirp',
132
+ message: 'Ensure destination parent directory exists',
133
+ paths: { dir: path.dirname(spec.toAbs) },
134
+ });
135
+ const tmp = spec.atomic ? tmpPathForTarget(spec.toAbs) : spec.toAbs;
136
+ steps.push({
137
+ kind: 'copy',
138
+ message: spec.atomic ? 'Copy to temp destination' : 'Copy to destination',
139
+ paths: { from: spec.fromAbs, to: tmp, kind: spec.kind },
140
+ });
141
+ if (spec.atomic) {
142
+ steps.push({
143
+ kind: 'move',
144
+ message: 'Atomically move copied temp into place',
145
+ paths: { from: tmp, to: spec.toAbs },
146
+ });
147
+ }
148
+ return steps;
149
+ }
@@ -0,0 +1,13 @@
1
+ import { CommonOptions, Result, Step } from '../types.js';
2
+ export interface RunOperationInput {
3
+ operation: Result['operation'];
4
+ manifestPath: string;
5
+ steps: Step[];
6
+ opts?: CommonOptions;
7
+ /**
8
+ * Called after apply (or dry-run) but before audit is appended.
9
+ * Allows callers to push extra steps (e.g., write_manifest) and mark failure.
10
+ */
11
+ finalize?: (result: Result) => Promise<Result> | Result;
12
+ }
13
+ export declare function runOperation(input: RunOperationInput): Promise<Result>;
@@ -0,0 +1,29 @@
1
+ import { applyPlan } from './apply.js';
2
+ import { tryAppendAuditStep } from './audit.js';
3
+ import { formatPlan } from './format-plan.js';
4
+ function nowIso() {
5
+ return new Date().toISOString();
6
+ }
7
+ export async function runOperation(input) {
8
+ const startedAt = nowIso();
9
+ const startedMs = Date.now();
10
+ const logger = input.opts?.logger;
11
+ let res = await applyPlan(input.operation, input.steps, {
12
+ logger,
13
+ dryRun: input.opts?.dryRun,
14
+ });
15
+ res.operation = input.operation;
16
+ res.manifestPath = input.manifestPath;
17
+ res.startedAt = startedAt;
18
+ res.durationMs = Date.now() - startedMs;
19
+ res.finishedAt = nowIso();
20
+ if (input.opts?.includePlanText) {
21
+ res.planText = formatPlan(input.steps);
22
+ }
23
+ if (input.finalize) {
24
+ res = await input.finalize(res);
25
+ }
26
+ res = await tryAppendAuditStep(res, input.manifestPath, input.opts);
27
+ logger?.info?.(`[linkany] ${input.operation} ${res.ok ? 'ok' : 'fail'} (${res.durationMs}ms)`);
28
+ return res;
29
+ }
@@ -0,0 +1,7 @@
1
+ export type { Manifest, InstallEntry, InstallKind } from './manifest/types.js';
2
+ export type { Mapping } from './api/add.js';
3
+ export type { RemoveOptions } from './api/remove.js';
4
+ export type { Result, Step, Logger, CommonOptions, LinkKind } from './types.js';
5
+ export { loadManifest } from './manifest/types.js';
6
+ export { loadOrCreateManifest, saveManifest, upsertEntry, removeEntry } from './manifest/io.js';
7
+ export { add, remove, install, uninstall } from './api/index.js';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { loadManifest } from './manifest/types.js';
2
+ export { loadOrCreateManifest, saveManifest, upsertEntry, removeEntry } from './manifest/io.js';
3
+ export { add, remove, install, uninstall } from './api/index.js';
@@ -0,0 +1,17 @@
1
+ import { InstallEntry, Manifest } from './types.js';
2
+ export declare function loadOrCreateManifest(manifestPath: string): Promise<Manifest>;
3
+ export interface SaveManifestOptions {
4
+ spaces?: number;
5
+ }
6
+ export declare function saveManifest(manifestPath: string, manifest: Manifest, opts?: SaveManifestOptions): Promise<void>;
7
+ export interface UpsertEntryResult {
8
+ updated: boolean;
9
+ created: boolean;
10
+ key: string;
11
+ }
12
+ export declare function upsertEntry(manifest: Manifest, entry: InstallEntry): UpsertEntryResult;
13
+ export interface RemoveEntryResult {
14
+ removed: boolean;
15
+ key: string;
16
+ }
17
+ export declare function removeEntry(manifest: Manifest, key: string): RemoveEntryResult;
@@ -0,0 +1,52 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { normalizeManifest } from './types.js';
4
+ function defaultManifest() {
5
+ return {
6
+ version: 1,
7
+ installs: [],
8
+ };
9
+ }
10
+ export async function loadOrCreateManifest(manifestPath) {
11
+ const abs = path.resolve(manifestPath);
12
+ if (!await fs.pathExists(abs)) {
13
+ return defaultManifest();
14
+ }
15
+ const json = await fs.readJson(abs);
16
+ return normalizeManifest(json);
17
+ }
18
+ export async function saveManifest(manifestPath, manifest, opts = {}) {
19
+ const abs = path.resolve(manifestPath);
20
+ await fs.ensureDir(path.dirname(abs));
21
+ const spaces = opts.spaces ?? 2;
22
+ const content = JSON.stringify(manifest, null, spaces) + '\n';
23
+ const tmp = `${abs}.tmp.${Date.now()}.${Math.random().toString(16).slice(2)}`;
24
+ await fs.writeFile(tmp, content, 'utf8');
25
+ await fs.rename(tmp, abs);
26
+ }
27
+ function keyOf(entry) {
28
+ return entry.id || entry.target;
29
+ }
30
+ export function upsertEntry(manifest, entry) {
31
+ const key = keyOf(entry);
32
+ if (!key)
33
+ throw new Error('Entry must have "target" (or "id")');
34
+ const installs = manifest.installs ?? (manifest.installs = []);
35
+ const idx = installs.findIndex(e => keyOf(e) === key);
36
+ if (idx >= 0) {
37
+ installs[idx] = { ...installs[idx], ...entry };
38
+ return { updated: true, created: false, key };
39
+ }
40
+ installs.push(entry);
41
+ return { updated: false, created: true, key };
42
+ }
43
+ export function removeEntry(manifest, key) {
44
+ if (!key)
45
+ throw new Error('removeEntry requires a key');
46
+ const installs = manifest.installs ?? (manifest.installs = []);
47
+ const idx = installs.findIndex(e => keyOf(e) === key || e.target === key);
48
+ if (idx < 0)
49
+ return { removed: false, key };
50
+ installs.splice(idx, 1);
51
+ return { removed: true, key };
52
+ }
@@ -0,0 +1,29 @@
1
+ export type ManifestVersion = 1;
2
+ export type InstallKind = 'file' | 'dir';
3
+ export interface InstallEntry {
4
+ id?: string;
5
+ source: string;
6
+ target: string;
7
+ kind?: InstallKind;
8
+ atomic?: boolean;
9
+ }
10
+ /**
11
+ * Users can add any extra fields.
12
+ */
13
+ export interface Manifest {
14
+ version: ManifestVersion;
15
+ installs: InstallEntry[];
16
+ [k: string]: any;
17
+ }
18
+ export interface ResolvedInstallEntry extends InstallEntry {
19
+ sourceAbs: string;
20
+ targetAbs: string;
21
+ manifestKey: string;
22
+ atomic: boolean;
23
+ }
24
+ export declare function getManifestBaseDir(manifestPath: string): string;
25
+ export declare function resolveMaybeRelative(baseDir: string, p: string): string;
26
+ export declare function getEntryKey(entry: InstallEntry): string;
27
+ export declare function resolveEntry(baseDir: string, entry: InstallEntry): ResolvedInstallEntry;
28
+ export declare function normalizeManifest(raw: unknown): Manifest;
29
+ export declare function loadManifest(manifestPath: string): Promise<Manifest>;
@@ -0,0 +1,44 @@
1
+ import path from 'path';
2
+ import fs from 'fs-extra';
3
+ export function getManifestBaseDir(manifestPath) {
4
+ return path.dirname(path.resolve(manifestPath));
5
+ }
6
+ export function resolveMaybeRelative(baseDir, p) {
7
+ if (!p)
8
+ return p;
9
+ return path.isAbsolute(p) ? path.normalize(p) : path.normalize(path.resolve(baseDir, p));
10
+ }
11
+ export function getEntryKey(entry) {
12
+ return entry.id || entry.target;
13
+ }
14
+ export function resolveEntry(baseDir, entry) {
15
+ const sourceAbs = resolveMaybeRelative(baseDir, entry.source);
16
+ const targetAbs = resolveMaybeRelative(baseDir, entry.target);
17
+ return {
18
+ ...entry,
19
+ sourceAbs,
20
+ targetAbs,
21
+ manifestKey: getEntryKey(entry),
22
+ atomic: entry.atomic ?? true,
23
+ };
24
+ }
25
+ export function normalizeManifest(raw) {
26
+ const obj = (raw ?? {});
27
+ const version = obj.version;
28
+ const installs = obj.installs;
29
+ if (version !== 1) {
30
+ throw new Error(`Unsupported manifest version: ${String(version)} (expected 1)`);
31
+ }
32
+ if (!Array.isArray(installs)) {
33
+ throw new Error('Invalid manifest: "installs" must be an array');
34
+ }
35
+ return { ...obj, version: 1, installs: installs };
36
+ }
37
+ export async function loadManifest(manifestPath) {
38
+ const abs = path.resolve(manifestPath);
39
+ if (!await fs.pathExists(abs)) {
40
+ throw new Error(`Manifest not found: ${abs}`);
41
+ }
42
+ const json = await fs.readJson(abs);
43
+ return normalizeManifest(json);
44
+ }
@@ -0,0 +1,72 @@
1
+ export type LinkKind = 'file' | 'dir';
2
+ export type Operation = 'install' | 'uninstall' | 'add' | 'remove';
3
+ export type StepKind = 'noop' | 'mkdirp' | 'touch' | 'symlink' | 'unlink' | 'rm' | 'move' | 'copy' | 'write_manifest' | 'audit';
4
+ export interface Step {
5
+ kind: StepKind;
6
+ message: string;
7
+ /**
8
+ * Optional paths involved in the step, for observability and auditing.
9
+ */
10
+ paths?: Record<string, string>;
11
+ /**
12
+ * Whether the step was executed or skipped.
13
+ */
14
+ status?: 'planned' | 'executed' | 'skipped' | 'failed';
15
+ /**
16
+ * Optional error message for failed steps.
17
+ */
18
+ error?: string;
19
+ /**
20
+ * Optional rollback hint for this step (protocol only; may be partial).
21
+ * If present and the step was executed, a future rollback can apply this.
22
+ */
23
+ undo?: Omit<Step, 'status' | 'error' | 'undo'>;
24
+ }
25
+ export interface Result {
26
+ ok: boolean;
27
+ operation: Operation;
28
+ manifestPath?: string;
29
+ startedAt: string;
30
+ finishedAt: string;
31
+ durationMs: number;
32
+ steps: Step[];
33
+ warnings: string[];
34
+ errors: string[];
35
+ /**
36
+ * Summary of changes that occurred.
37
+ */
38
+ changes: Array<{
39
+ target?: string;
40
+ source?: string;
41
+ action: string;
42
+ }>;
43
+ /**
44
+ * Optional rollback plan (best-effort) in reverse order of execution.
45
+ */
46
+ rollbackSteps?: Step[];
47
+ /**
48
+ * Optional human-readable plan / summary text (best-effort).
49
+ */
50
+ planText?: string;
51
+ }
52
+ export interface Logger {
53
+ info(msg: string): void;
54
+ warn(msg: string): void;
55
+ error(msg: string): void;
56
+ }
57
+ export interface CommonOptions {
58
+ /**
59
+ * If provided, we append one JSON line per operation (Result summary).
60
+ * Default strategy (V1): `${manifestPath}.log.jsonl` when manifestPath is known.
61
+ */
62
+ auditLogPath?: string;
63
+ logger?: Logger;
64
+ /**
65
+ * If true, do not perform filesystem writes; only return the planned steps/result.
66
+ */
67
+ dryRun?: boolean;
68
+ /**
69
+ * If true, return plan text in Result.planText (best-effort).
70
+ */
71
+ includePlanText?: boolean;
72
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "linkany",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ }
12
+ },
13
+ "bin": {
14
+ "linkany": "./dist/cli.js"
15
+ },
16
+ "scripts": {
17
+ "build": "tsc && node ./scripts/postbuild-shebang.mjs",
18
+ "test": "vitest"
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "license": "Unlicense",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/lbb00/linkany.git"
27
+ },
28
+ "dependencies": {
29
+ "fs-extra": "^11.3.3"
30
+ },
31
+ "devDependencies": {
32
+ "@types/fs-extra": "^11.0.4",
33
+ "@types/node": "^25.0.3",
34
+ "typescript": "^5.9.3",
35
+ "vitest": "^4.0.16"
36
+ },
37
+ "packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"
38
+ }