@tinyrack/devsync 1.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,138 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, resolve } from "node:path";
3
+
4
+ const readTrimmedEnvironmentValue = (
5
+ environment: NodeJS.ProcessEnv,
6
+ key: string,
7
+ ) => {
8
+ const value = environment[key];
9
+
10
+ if (value === undefined) {
11
+ return undefined;
12
+ }
13
+
14
+ const trimmedValue = value.trim();
15
+
16
+ return trimmedValue === "" ? undefined : trimmedValue;
17
+ };
18
+
19
+ const bracedXdgConfigHomeToken = "$" + "{XDG_CONFIG_HOME}";
20
+ const bracedXdgConfigHomePrefix = `${bracedXdgConfigHomeToken}/`;
21
+
22
+ export const resolveHomeDirectory = (
23
+ environment: NodeJS.ProcessEnv = process.env,
24
+ ) => {
25
+ const configuredValue = readTrimmedEnvironmentValue(environment, "HOME");
26
+
27
+ if (configuredValue !== undefined) {
28
+ return resolve(configuredValue);
29
+ }
30
+
31
+ return resolve(homedir());
32
+ };
33
+
34
+ export const resolveXdgConfigHome = (
35
+ environment: NodeJS.ProcessEnv = process.env,
36
+ ) => {
37
+ const configuredValue = readTrimmedEnvironmentValue(
38
+ environment,
39
+ "XDG_CONFIG_HOME",
40
+ );
41
+
42
+ if (configuredValue !== undefined) {
43
+ return resolve(configuredValue);
44
+ }
45
+
46
+ return resolve(resolveHomeDirectory(environment), ".config");
47
+ };
48
+
49
+ export const resolveDevsyncConfigDirectory = (
50
+ environment: NodeJS.ProcessEnv = process.env,
51
+ ) => {
52
+ return resolve(resolveXdgConfigHome(environment), "devsync");
53
+ };
54
+
55
+ export const resolveDevsyncSyncDirectory = (
56
+ environment: NodeJS.ProcessEnv = process.env,
57
+ ) => {
58
+ return resolve(resolveDevsyncConfigDirectory(environment), "sync");
59
+ };
60
+
61
+ export const resolveDevsyncAgeDirectory = (
62
+ environment: NodeJS.ProcessEnv = process.env,
63
+ ) => {
64
+ return resolve(resolveDevsyncConfigDirectory(environment), "age");
65
+ };
66
+
67
+ export const expandHomePath = (
68
+ value: string,
69
+ environment: NodeJS.ProcessEnv = process.env,
70
+ ) => {
71
+ let expandedValue = value.trim();
72
+
73
+ if (expandedValue === "~") {
74
+ expandedValue = resolveHomeDirectory(environment);
75
+ } else if (expandedValue.startsWith("~/")) {
76
+ expandedValue = resolve(
77
+ resolveHomeDirectory(environment),
78
+ expandedValue.slice(2),
79
+ );
80
+ }
81
+
82
+ return expandedValue;
83
+ };
84
+
85
+ export const expandConfiguredPath = (
86
+ value: string,
87
+ environment: NodeJS.ProcessEnv = process.env,
88
+ ) => {
89
+ let expandedValue = expandHomePath(value, environment);
90
+
91
+ if (expandedValue === "$XDG_CONFIG_HOME") {
92
+ expandedValue = resolveXdgConfigHome(environment);
93
+ } else if (expandedValue.startsWith("$XDG_CONFIG_HOME/")) {
94
+ expandedValue = resolve(
95
+ resolveXdgConfigHome(environment),
96
+ expandedValue.slice("$XDG_CONFIG_HOME/".length),
97
+ );
98
+ } else if (expandedValue === bracedXdgConfigHomeToken) {
99
+ expandedValue = resolveXdgConfigHome(environment);
100
+ } else if (expandedValue.startsWith(bracedXdgConfigHomePrefix)) {
101
+ expandedValue = resolve(
102
+ resolveXdgConfigHome(environment),
103
+ expandedValue.slice(bracedXdgConfigHomePrefix.length),
104
+ );
105
+ }
106
+
107
+ return expandedValue;
108
+ };
109
+
110
+ export const resolveConfiguredAbsolutePath = (
111
+ value: string,
112
+ environment: NodeJS.ProcessEnv = process.env,
113
+ ) => {
114
+ const expandedValue = expandConfiguredPath(value, environment);
115
+
116
+ if (!isAbsolute(expandedValue)) {
117
+ throw new Error(
118
+ `Configured path must be absolute or start with ~ or $XDG_CONFIG_HOME: ${value}`,
119
+ );
120
+ }
121
+
122
+ return resolve(expandedValue);
123
+ };
124
+
125
+ export const resolveHomeConfiguredAbsolutePath = (
126
+ value: string,
127
+ environment: NodeJS.ProcessEnv = process.env,
128
+ ) => {
129
+ const expandedValue = expandHomePath(value, environment);
130
+
131
+ if (!isAbsolute(expandedValue)) {
132
+ throw new Error(
133
+ `Configured path must be absolute or start with ~: ${value}`,
134
+ );
135
+ }
136
+
137
+ return resolve(expandedValue);
138
+ };
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { execute } from "@oclif/core";
3
+
4
+ await execute({ dir: import.meta.url });
@@ -0,0 +1,3 @@
1
+ export const ensureTrailingNewline = (value: string) => {
2
+ return value.endsWith("\n") ? value : `${value}\n`;
3
+ };
@@ -0,0 +1,11 @@
1
+ import type { z } from "zod";
2
+
3
+ export const formatInputIssues = (issues: z.ZodIssue[]): string => {
4
+ return issues
5
+ .map((issue) => {
6
+ const path = issue.path.length === 0 ? "input" : issue.path.join(".");
7
+
8
+ return `- ${path}: ${issue.message}`;
9
+ })
10
+ .join("\n");
11
+ };
@@ -0,0 +1,178 @@
1
+ import {
2
+ type ResolvedSyncConfig,
3
+ type ResolvedSyncConfigEntry,
4
+ readSyncConfig,
5
+ type SyncConfigEntryKind,
6
+ type SyncMode,
7
+ } from "#app/config/sync.ts";
8
+
9
+ import {
10
+ createSyncConfigDocument,
11
+ createSyncConfigDocumentEntry,
12
+ sortSyncConfigEntries,
13
+ writeValidatedSyncConfig,
14
+ } from "./config-file.ts";
15
+ import { DevsyncError } from "./error.ts";
16
+ import { getPathStats } from "./filesystem.ts";
17
+ import {
18
+ buildConfiguredHomeLocalPath,
19
+ buildRepoPathWithinRoot,
20
+ doPathsOverlap,
21
+ resolveCommandTargetPath,
22
+ } from "./paths.ts";
23
+ import { ensureSyncRepository, type SyncContext } from "./runtime.ts";
24
+
25
+ export type SyncAddRequest = Readonly<{
26
+ secret: boolean;
27
+ target: string;
28
+ }>;
29
+
30
+ export type SyncAddResult = Readonly<{
31
+ alreadyTracked: boolean;
32
+ configPath: string;
33
+ kind: SyncConfigEntryKind;
34
+ localPath: string;
35
+ mode: SyncMode;
36
+ repoPath: string;
37
+ syncDirectory: string;
38
+ }>;
39
+
40
+ const buildAddEntryCandidate = async (
41
+ targetPath: string,
42
+ config: ResolvedSyncConfig,
43
+ context: Pick<SyncContext, "paths">,
44
+ ) => {
45
+ const targetStats = await getPathStats(targetPath);
46
+
47
+ if (targetStats === undefined) {
48
+ throw new DevsyncError(`Sync target does not exist: ${targetPath}`);
49
+ }
50
+
51
+ const kind = (() => {
52
+ if (targetStats.isDirectory()) {
53
+ return "directory" as const;
54
+ }
55
+
56
+ if (targetStats.isFile() || targetStats.isSymbolicLink()) {
57
+ return "file" as const;
58
+ }
59
+
60
+ throw new DevsyncError(`Unsupported sync target type: ${targetPath}`);
61
+ })();
62
+
63
+ if (doPathsOverlap(targetPath, context.paths.syncDirectory)) {
64
+ throw new DevsyncError(
65
+ `Sync target must not overlap the sync directory: ${targetPath}`,
66
+ );
67
+ }
68
+
69
+ if (doPathsOverlap(targetPath, config.age.identityFile)) {
70
+ throw new DevsyncError(
71
+ `Sync target must not contain the age identity file: ${targetPath}`,
72
+ );
73
+ }
74
+
75
+ const repoPath = buildRepoPathWithinRoot(
76
+ targetPath,
77
+ context.paths.homeDirectory,
78
+ "Sync target",
79
+ );
80
+
81
+ return {
82
+ configuredLocalPath: buildConfiguredHomeLocalPath(repoPath),
83
+ kind,
84
+ localPath: targetPath,
85
+ mode: "normal",
86
+ name: repoPath,
87
+ overrides: [],
88
+ repoPath,
89
+ } satisfies ResolvedSyncConfigEntry;
90
+ };
91
+
92
+ export const addSyncTarget = async (
93
+ request: SyncAddRequest,
94
+ context: SyncContext,
95
+ ): Promise<SyncAddResult> => {
96
+ const target = request.target.trim();
97
+
98
+ if (target.length === 0) {
99
+ throw new DevsyncError("Target path is required.");
100
+ }
101
+
102
+ await ensureSyncRepository(context);
103
+
104
+ const config = await readSyncConfig(
105
+ context.paths.syncDirectory,
106
+ context.environment,
107
+ );
108
+ const candidate = await buildAddEntryCandidate(
109
+ resolveCommandTargetPath(target, context.environment, context.cwd),
110
+ config,
111
+ context,
112
+ );
113
+ const existingEntry = config.entries.find((entry) => {
114
+ return (
115
+ entry.localPath === candidate.localPath ||
116
+ entry.repoPath === candidate.repoPath
117
+ );
118
+ });
119
+ let alreadyTracked = false;
120
+
121
+ if (existingEntry !== undefined) {
122
+ if (
123
+ existingEntry.localPath === candidate.localPath &&
124
+ existingEntry.repoPath === candidate.repoPath &&
125
+ existingEntry.kind === candidate.kind
126
+ ) {
127
+ alreadyTracked = true;
128
+ } else {
129
+ throw new DevsyncError(
130
+ `Sync target conflicts with an existing entry: ${existingEntry.repoPath}`,
131
+ );
132
+ }
133
+ }
134
+
135
+ const nextConfig = createSyncConfigDocument(config);
136
+ const desiredMode: SyncMode = request.secret ? "secret" : "normal";
137
+ let mode = existingEntry?.mode ?? (request.secret ? "secret" : "normal");
138
+
139
+ if (!alreadyTracked) {
140
+ nextConfig.entries = sortSyncConfigEntries([
141
+ ...nextConfig.entries,
142
+ createSyncConfigDocumentEntry({
143
+ ...candidate,
144
+ mode: desiredMode,
145
+ }),
146
+ ]);
147
+ mode = desiredMode;
148
+ } else if (request.secret && existingEntry?.mode !== "secret") {
149
+ nextConfig.entries = nextConfig.entries.map((entry) => {
150
+ if (entry.repoPath !== candidate.repoPath) {
151
+ return entry;
152
+ }
153
+
154
+ return {
155
+ ...entry,
156
+ mode: "secret",
157
+ };
158
+ });
159
+
160
+ mode = "secret";
161
+ }
162
+
163
+ if (!alreadyTracked || (request.secret && existingEntry?.mode !== "secret")) {
164
+ await writeValidatedSyncConfig(context.paths.syncDirectory, nextConfig, {
165
+ environment: context.environment,
166
+ });
167
+ }
168
+
169
+ return {
170
+ alreadyTracked,
171
+ configPath: context.paths.configPath,
172
+ kind: candidate.kind,
173
+ localPath: candidate.localPath,
174
+ mode,
175
+ repoPath: candidate.repoPath,
176
+ syncDirectory: context.paths.syncDirectory,
177
+ };
178
+ };
@@ -0,0 +1,101 @@
1
+ import {
2
+ formatSyncConfig,
3
+ formatSyncOverrideSelector,
4
+ parseSyncConfig,
5
+ type ResolvedSyncConfig,
6
+ type ResolvedSyncConfigEntry,
7
+ type ResolvedSyncOverride,
8
+ resolveSyncConfigFilePath,
9
+ type SyncConfig,
10
+ } from "#app/config/sync.ts";
11
+
12
+ import { writeTextFileAtomically } from "./filesystem.ts";
13
+
14
+ type SyncConfigDocumentEntry = SyncConfig["entries"][number];
15
+
16
+ export const sortSyncOverrides = (
17
+ overrides: readonly Pick<ResolvedSyncOverride, "match" | "mode" | "path">[],
18
+ ) => {
19
+ return [...overrides].sort((left, right) => {
20
+ return formatSyncOverrideSelector(left).localeCompare(
21
+ formatSyncOverrideSelector(right),
22
+ );
23
+ });
24
+ };
25
+
26
+ export const createSyncConfigDocumentEntry = (
27
+ entry: Pick<
28
+ ResolvedSyncConfigEntry,
29
+ "configuredLocalPath" | "kind" | "name" | "mode" | "overrides" | "repoPath"
30
+ >,
31
+ ): SyncConfigDocumentEntry => {
32
+ return {
33
+ kind: entry.kind,
34
+ localPath: entry.configuredLocalPath,
35
+ mode: entry.mode,
36
+ name: entry.name,
37
+ ...(entry.overrides.length === 0
38
+ ? {}
39
+ : {
40
+ overrides: Object.fromEntries(
41
+ sortSyncOverrides(entry.overrides).map((override) => {
42
+ return [formatSyncOverrideSelector(override), override.mode];
43
+ }),
44
+ ),
45
+ }),
46
+ repoPath: entry.repoPath,
47
+ };
48
+ };
49
+
50
+ export const createSyncConfigDocument = (
51
+ config: ResolvedSyncConfig,
52
+ ): SyncConfig => {
53
+ return {
54
+ version: 1,
55
+ age: {
56
+ identityFile: config.age.configuredIdentityFile,
57
+ recipients: [...config.age.recipients],
58
+ },
59
+ entries: config.entries.map((entry) => {
60
+ return createSyncConfigDocumentEntry(entry);
61
+ }),
62
+ };
63
+ };
64
+
65
+ export const sortSyncConfigEntries = (
66
+ entries: readonly SyncConfigDocumentEntry[],
67
+ ) => {
68
+ return [...entries].sort((left, right) => {
69
+ return left.repoPath.localeCompare(right.repoPath);
70
+ });
71
+ };
72
+
73
+ export const countConfiguredRules = (config: ResolvedSyncConfig) => {
74
+ return config.entries.reduce((total, entry) => {
75
+ return total + entry.overrides.length;
76
+ }, 0);
77
+ };
78
+
79
+ export const writeValidatedSyncConfig = async (
80
+ syncDirectory: string,
81
+ config: SyncConfig,
82
+ dependencies: Readonly<{
83
+ environment: NodeJS.ProcessEnv;
84
+ }>,
85
+ ) => {
86
+ const resolvedConfig = parseSyncConfig(
87
+ {
88
+ ...config,
89
+ entries: sortSyncConfigEntries(config.entries),
90
+ },
91
+ dependencies.environment,
92
+ );
93
+ const nextConfig = createSyncConfigDocument(resolvedConfig);
94
+
95
+ await writeTextFileAtomically(
96
+ resolveSyncConfigFilePath(syncDirectory),
97
+ formatSyncConfig(nextConfig),
98
+ );
99
+
100
+ return nextConfig;
101
+ };
@@ -0,0 +1,83 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+
4
+ import {
5
+ armor,
6
+ Decrypter,
7
+ Encrypter,
8
+ generateIdentity,
9
+ identityToRecipient,
10
+ } from "age-encryption";
11
+
12
+ import { ensureTrailingNewline } from "#app/lib/string.ts";
13
+
14
+ export const readAgeIdentityLines = async (identityFile: string) => {
15
+ const contents = await readFile(identityFile, "utf8");
16
+ const identities = contents
17
+ .split(/\r?\n/u)
18
+ .map((line) => line.trim())
19
+ .filter((line) => {
20
+ return line !== "" && !line.startsWith("#");
21
+ });
22
+
23
+ if (identities.length === 0) {
24
+ throw new Error(`No age identities found in ${identityFile}`);
25
+ }
26
+
27
+ return identities;
28
+ };
29
+
30
+ export const readAgeRecipientsFromIdentityFile = async (
31
+ identityFile: string,
32
+ ) => {
33
+ const identities = await readAgeIdentityLines(identityFile);
34
+ const recipients = await Promise.all(
35
+ identities.map(async (identity) => {
36
+ return await identityToRecipient(identity);
37
+ }),
38
+ );
39
+
40
+ return [...new Set(recipients)];
41
+ };
42
+
43
+ export const createAgeIdentityFile = async (identityFile: string) => {
44
+ const identity = await generateIdentity();
45
+ const recipient = await identityToRecipient(identity);
46
+
47
+ await mkdir(dirname(identityFile), { recursive: true });
48
+ await writeFile(identityFile, ensureTrailingNewline(identity), "utf8");
49
+
50
+ return {
51
+ identity,
52
+ recipient,
53
+ };
54
+ };
55
+
56
+ export const encryptSecretFile = async (
57
+ contents: Uint8Array,
58
+ recipients: readonly string[],
59
+ ) => {
60
+ const encrypter = new Encrypter();
61
+
62
+ for (const recipient of recipients) {
63
+ encrypter.addRecipient(recipient);
64
+ }
65
+
66
+ const ciphertext = await encrypter.encrypt(contents);
67
+
68
+ return armor.encode(ciphertext);
69
+ };
70
+
71
+ export const decryptSecretFile = async (
72
+ armoredCiphertext: string,
73
+ identityFile: string,
74
+ ) => {
75
+ const decrypter = new Decrypter();
76
+ const identities = await readAgeIdentityLines(identityFile);
77
+
78
+ for (const identity of identities) {
79
+ decrypter.addIdentity(identity);
80
+ }
81
+
82
+ return await decrypter.decrypt(armor.decode(armoredCiphertext));
83
+ };
@@ -0,0 +1,6 @@
1
+ export class DevsyncError extends Error {
2
+ public constructor(message: string) {
3
+ super(message);
4
+ this.name = "DevsyncError";
5
+ }
6
+ }
@@ -0,0 +1,183 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ access,
4
+ chmod,
5
+ lstat,
6
+ mkdir,
7
+ mkdtemp,
8
+ readdir,
9
+ readFile,
10
+ readlink,
11
+ rename,
12
+ rm,
13
+ symlink,
14
+ writeFile,
15
+ } from "node:fs/promises";
16
+ import { basename, dirname, join } from "node:path";
17
+
18
+ import { DevsyncError } from "./error.ts";
19
+
20
+ export const pathExists = async (path: string) => {
21
+ try {
22
+ await access(path);
23
+
24
+ return true;
25
+ } catch (error: unknown) {
26
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
27
+ return false;
28
+ }
29
+
30
+ throw error;
31
+ }
32
+ };
33
+
34
+ export const getPathStats = async (path: string) => {
35
+ try {
36
+ return await lstat(path);
37
+ } catch (error: unknown) {
38
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
39
+ return undefined;
40
+ }
41
+
42
+ throw error;
43
+ }
44
+ };
45
+
46
+ export const listDirectoryEntries = async (path: string) => {
47
+ const entries = await readdir(path, { withFileTypes: true });
48
+
49
+ return entries.sort((left, right) => {
50
+ return left.name.localeCompare(right.name);
51
+ });
52
+ };
53
+
54
+ export const buildExecutableMode = (executable: boolean) => {
55
+ return executable ? 0o755 : 0o644;
56
+ };
57
+
58
+ export const isExecutableMode = (mode: number | bigint) => {
59
+ return (Number(mode) & 0o111) !== 0;
60
+ };
61
+
62
+ export const writeFileNode = async (
63
+ path: string,
64
+ node: Readonly<{
65
+ contents: string | Uint8Array;
66
+ executable: boolean;
67
+ }>,
68
+ ) => {
69
+ await mkdir(dirname(path), { recursive: true });
70
+ await writeFile(path, node.contents);
71
+ await chmod(path, buildExecutableMode(node.executable));
72
+ };
73
+
74
+ export const writeSymlinkNode = async (path: string, linkTarget: string) => {
75
+ await mkdir(dirname(path), { recursive: true });
76
+ await rm(path, { force: true, recursive: true });
77
+ await symlink(linkTarget, path);
78
+ };
79
+
80
+ export const copyFilesystemNode = async (
81
+ sourcePath: string,
82
+ targetPath: string,
83
+ stats?: Awaited<ReturnType<typeof lstat>>,
84
+ ) => {
85
+ const sourceStats = stats ?? (await lstat(sourcePath));
86
+
87
+ if (sourceStats.isDirectory()) {
88
+ await mkdir(targetPath, { recursive: true });
89
+
90
+ const entries = await listDirectoryEntries(sourcePath);
91
+
92
+ for (const entry of entries) {
93
+ await copyFilesystemNode(
94
+ join(sourcePath, entry.name),
95
+ join(targetPath, entry.name),
96
+ );
97
+ }
98
+
99
+ return;
100
+ }
101
+
102
+ if (sourceStats.isSymbolicLink()) {
103
+ await writeSymlinkNode(targetPath, await readlink(sourcePath));
104
+
105
+ return;
106
+ }
107
+
108
+ if (!sourceStats.isFile()) {
109
+ throw new DevsyncError(`Unsupported filesystem entry: ${sourcePath}`);
110
+ }
111
+
112
+ await writeFileNode(targetPath, {
113
+ contents: await readFile(sourcePath),
114
+ executable: isExecutableMode(sourceStats.mode),
115
+ });
116
+ };
117
+
118
+ export const replacePathAtomically = async (
119
+ targetPath: string,
120
+ nextPath: string,
121
+ ) => {
122
+ const backupPath = join(
123
+ dirname(targetPath),
124
+ `.${basename(targetPath)}.devsync-sync-backup-${randomUUID()}`,
125
+ );
126
+ const existingStats = await getPathStats(targetPath);
127
+ let targetMoved = false;
128
+
129
+ try {
130
+ if (existingStats !== undefined) {
131
+ await rename(targetPath, backupPath);
132
+ targetMoved = true;
133
+ }
134
+
135
+ await rename(nextPath, targetPath);
136
+
137
+ if (targetMoved) {
138
+ await rm(backupPath, { force: true, recursive: true });
139
+ }
140
+ } catch (error: unknown) {
141
+ if (targetMoved && !(await pathExists(targetPath))) {
142
+ await rename(backupPath, targetPath).catch(() => {});
143
+ }
144
+
145
+ throw error;
146
+ } finally {
147
+ await rm(backupPath, { force: true, recursive: true }).catch(() => {});
148
+ }
149
+ };
150
+
151
+ export const removePathAtomically = async (targetPath: string) => {
152
+ const stats = await getPathStats(targetPath);
153
+
154
+ if (stats === undefined) {
155
+ return;
156
+ }
157
+
158
+ const backupPath = join(
159
+ dirname(targetPath),
160
+ `.${basename(targetPath)}.devsync-sync-remove-${randomUUID()}`,
161
+ );
162
+
163
+ await rename(targetPath, backupPath);
164
+ await rm(backupPath, { force: true, recursive: true });
165
+ };
166
+
167
+ export const writeTextFileAtomically = async (
168
+ targetPath: string,
169
+ contents: string,
170
+ ) => {
171
+ await mkdir(dirname(targetPath), { recursive: true });
172
+ const stagingDirectory = await mkdtemp(
173
+ join(dirname(targetPath), `.${basename(targetPath)}.devsync-sync-`),
174
+ );
175
+ const stagedPath = join(stagingDirectory, basename(targetPath));
176
+
177
+ try {
178
+ await writeFile(stagedPath, contents, "utf8");
179
+ await replacePathAtomically(targetPath, stagedPath);
180
+ } finally {
181
+ await rm(stagingDirectory, { force: true, recursive: true });
182
+ }
183
+ };