telstore 0.1.6 → 0.1.7

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/src/sources.js ADDED
@@ -0,0 +1,163 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ // What a command line names and what telstore uploads are not the same list: a folder stands
5
+ // for the files inside it, and a pattern for the names that match. Both are resolved here,
6
+ // before anything connects, so the run is decided against the disk rather than discovered one
7
+ // file at a time.
8
+ //
9
+ // The shell expands `*` long before node sees it, and that is the expansion telstore prefers —
10
+ // this one only runs on a pattern that arrived intact: quoted, or left alone by a shell that
11
+ // found nothing to expand it to. Whichever did the work, the rule is the same one glob has
12
+ // always had, so a quoted pattern is not a different feature with different results.
13
+
14
+ // One level, because a folder is a place someone put files, not a tree telstore may walk on
15
+ // its own: a recursive sweep of a home directory is thousands of backups nobody asked for.
16
+ const HIDDEN = /^\./
17
+
18
+ function isPattern(text) {
19
+ return /[*?]/.test(text)
20
+ }
21
+
22
+ // `*` and `?` mean what they mean in a shell; everything else is a literal, including the
23
+ // regex metacharacters a file name is perfectly allowed to contain.
24
+ function patternToRegExp(pattern) {
25
+ const body = pattern
26
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
27
+ .replace(/[*?]/g, (char) => (char === '*' ? '[^/]*' : '[^/]'))
28
+
29
+ return new RegExp(`^${body}$`)
30
+ }
31
+
32
+ // A directory entry is only a source if it is a file — following symlinks, since a link to a
33
+ // file is a perfectly ordinary thing to keep in a folder of backups. Anything that cannot be
34
+ // read at all is named rather than dropped.
35
+ async function classify(full) {
36
+ try {
37
+ const stat = await fs.stat(full)
38
+
39
+ if (stat.isFile()) return { kind: 'file' }
40
+ if (stat.isDirectory()) return { kind: 'skip', reason: 'directory' }
41
+
42
+ return { kind: 'skip', reason: 'not a file' }
43
+ } catch (err) {
44
+ return { kind: 'skip', reason: `cannot be read (${err.code ?? err.message})` }
45
+ }
46
+ }
47
+
48
+ async function readEntries(dir) {
49
+ return (await fs.readdir(dir)).sort()
50
+ }
51
+
52
+ async function expandDirectory(dir, skipped) {
53
+ const paths = []
54
+ const folders = []
55
+
56
+ for (const name of await readEntries(dir)) {
57
+ if (HIDDEN.test(name)) continue
58
+
59
+ const full = path.join(dir, name)
60
+ const { kind, reason } = await classify(full)
61
+
62
+ if (kind === 'file') {
63
+ paths.push(full)
64
+ continue
65
+ }
66
+
67
+ if (reason === 'directory') folders.push(name)
68
+ skipped.push({ path: full, reason })
69
+ }
70
+
71
+ if (paths.length === 0) {
72
+ throw new Error(
73
+ `${dir} has no files to upload — telstore reads one level down and leaves hidden files ` +
74
+ 'alone.' +
75
+ (folders.length > 0
76
+ ? ` telstore does not walk into the folders inside it (${folders.join(', ')}) — ` +
77
+ 'name one of those instead.'
78
+ : ''),
79
+ )
80
+ }
81
+
82
+ return paths
83
+ }
84
+
85
+ async function expandPattern(pattern) {
86
+ const dir = path.dirname(pattern)
87
+ const base = path.basename(pattern)
88
+
89
+ if (isPattern(dir)) {
90
+ throw new Error(
91
+ `Wildcards only work in the last part of a path, and ${pattern} has one earlier: ` +
92
+ 'telstore does not walk folders looking for matches.',
93
+ )
94
+ }
95
+
96
+ let entries
97
+
98
+ try {
99
+ entries = await readEntries(dir)
100
+ } catch (err) {
101
+ if (err.code === 'ENOENT') {
102
+ throw new Error(`No file matches ${pattern}: ${dir} does not exist.`)
103
+ }
104
+ throw err
105
+ }
106
+
107
+ const matches = patternToRegExp(base)
108
+ const paths = []
109
+
110
+ for (const name of entries) {
111
+ // A pattern that does not start with a dot does not go looking for hidden files, the same
112
+ // rule every shell uses — otherwise `telstore '*'` in a home directory sends .ssh upwards.
113
+ if (HIDDEN.test(name) && !HIDDEN.test(base)) continue
114
+ if (!matches.test(name)) continue
115
+
116
+ const full = path.join(dir, name)
117
+ if ((await classify(full)).kind === 'file') paths.push(full)
118
+ }
119
+
120
+ if (paths.length === 0) {
121
+ throw new Error(
122
+ `No file matches ${pattern} in ${dir}. Note that the shell usually expands patterns ` +
123
+ 'itself, so a quoted one reaches telstore exactly as typed.',
124
+ )
125
+ }
126
+
127
+ return paths
128
+ }
129
+
130
+ /**
131
+ * Turns the positionals of an upload into the list of files it will actually send.
132
+ * Returns `{ paths, skipped }`; a name that is neither a folder nor a pattern is handed on
133
+ * untouched, missing ones included, so "File does not exist" keeps coming from one place.
134
+ */
135
+ export async function expandSources(args) {
136
+ const paths = []
137
+ const skipped = []
138
+
139
+ for (const arg of args) {
140
+ if (isPattern(arg)) {
141
+ paths.push(...(await expandPattern(arg)))
142
+ continue
143
+ }
144
+
145
+ let stat = null
146
+
147
+ try {
148
+ stat = await fs.stat(arg)
149
+ } catch {
150
+ paths.push(arg)
151
+ continue
152
+ }
153
+
154
+ if (stat.isDirectory()) {
155
+ paths.push(...(await expandDirectory(arg, skipped)))
156
+ continue
157
+ }
158
+
159
+ paths.push(arg)
160
+ }
161
+
162
+ return { paths, skipped }
163
+ }