@heliosgraphics/epoque 0.0.1-alpha.0 → 0.0.1-alpha.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heliosgraphics/epoque",
3
- "version": "0.0.1-alpha.0",
3
+ "version": "0.0.1-alpha.2",
4
4
  "author": "Chris Puska <chris@puska.org>",
5
5
  "description": "sync a local folder",
6
6
  "type": "module",
package/src/api.ts CHANGED
@@ -2,8 +2,8 @@ import { readFile } from "node:fs/promises"
2
2
  import type { RemoteArchive, RemoteArticle, RemoteArticleImage, RemoteCollection } from "./types"
3
3
 
4
4
  interface ApiErrorBody {
5
- error?: string
6
- message?: string
5
+ error?: unknown
6
+ message?: unknown
7
7
  }
8
8
 
9
9
  type JsonBody = Record<string, unknown> | Array<unknown>
@@ -77,9 +77,7 @@ export class EpoqueApi {
77
77
 
78
78
  const response = await fetch(`${this.#origin}${path}`, { body, headers, method: options.method ?? "GET" })
79
79
 
80
- if (!response.ok) {
81
- throw new Error(await getResponseError(response))
82
- }
80
+ if (!response.ok) throw new Error(await getResponseError(response))
83
81
 
84
82
  if (response.status === 204) return undefined as T
85
83
 
@@ -87,11 +85,28 @@ export class EpoqueApi {
87
85
  }
88
86
  }
89
87
 
88
+ const getErrorMessage = (value: unknown): string | null => {
89
+ if (typeof value === "string") return value.trim() || null
90
+ if (!value || typeof value !== "object") return null
91
+ if (Array.isArray(value)) return getErrorMessage(value[0])
92
+
93
+ const record = value as Record<string, unknown>
94
+ const details = getErrorMessage(record.details)
95
+ const message = getErrorMessage(record.message)
96
+
97
+ if (details && message) return `${message}: ${details}`
98
+
99
+ return details ?? message ?? getErrorMessage(record.error) ?? getErrorMessage(record.code)
100
+ }
101
+
90
102
  const getResponseError = async (response: Response): Promise<string> => {
91
103
  try {
92
104
  const body = (await response.json()) as ApiErrorBody
105
+ const message = getErrorMessage(body.error) ?? getErrorMessage(body.message)
106
+
107
+ if (response.status === 401 && message === "unauthorized") return "invalid API key"
93
108
 
94
- return body.error || body.message || `request failed with ${response.status}`
109
+ return message ?? `request failed with ${response.status}`
95
110
  } catch (_error) {
96
111
  return `request failed with ${response.status}`
97
112
  }
package/src/cli.ts CHANGED
@@ -3,12 +3,12 @@
3
3
  import { createInterface } from "node:readline/promises"
4
4
  import { stdin as input, stdout as output } from "node:process"
5
5
  import { EpoqueApi } from "./api"
6
- import { getApiKey, getStoredOrigin, normalizeOrigin, readGlobalConfig, readProjectConfig, writeGlobalConfig } from "./config"
6
+ import { getApiKey, normalizeOrigin, readGlobalConfig, writeGlobalConfig } from "./config"
7
7
  import { syncFolder } from "./sync"
8
8
 
9
9
  const HELP = `Usage:
10
- epoque login [--origin https://epoque.app]
11
- epoque sync [--dry-run] [--origin https://epoque.app]
10
+ epoque login
11
+ epoque sync [--dry-run]
12
12
  `
13
13
 
14
14
  const promptLine = async (question: string): Promise<string> => {
@@ -68,17 +68,10 @@ const promptSecret = async (question: string): Promise<string> => {
68
68
  })
69
69
  }
70
70
 
71
- const getFlagValue = (args: Array<string>, name: string): string | undefined => {
72
- const index = args.indexOf(name)
73
- if (index === -1) return undefined
74
-
75
- return args[index + 1]
76
- }
77
-
78
71
  const hasFlag = (args: Array<string>, name: string): boolean => args.includes(name)
79
72
 
80
- const login = async (args: Array<string>): Promise<void> => {
81
- const origin = normalizeOrigin(getFlagValue(args, "--origin") ?? (await getStoredOrigin()))
73
+ const login = async (): Promise<void> => {
74
+ const origin = normalizeOrigin(undefined)
82
75
  const apiKey = (await promptSecret("API key: ")).trim()
83
76
 
84
77
  if (!apiKey) throw new Error("api key is required")
@@ -93,8 +86,7 @@ const login = async (args: Array<string>): Promise<void> => {
93
86
 
94
87
  const sync = async (args: Array<string>): Promise<void> => {
95
88
  const cwd = process.cwd()
96
- const projectConfig = await readProjectConfig(cwd)
97
- const origin = normalizeOrigin(getFlagValue(args, "--origin") ?? projectConfig?.origin ?? (await getStoredOrigin()))
89
+ const origin = normalizeOrigin(undefined)
98
90
  const apiKey = await getApiKey()
99
91
 
100
92
  if (!apiKey) throw new Error("not logged in. Run `epoque login` first or set EPOQUE_API_KEY")
@@ -118,7 +110,7 @@ const main = async (): Promise<void> => {
118
110
 
119
111
  switch (command) {
120
112
  case "login":
121
- await login(args)
113
+ await login()
122
114
  return
123
115
  case "sync":
124
116
  await sync(args)
package/src/config.ts CHANGED
@@ -42,14 +42,6 @@ export const getApiKey = async (): Promise<string | null> => {
42
42
  return config.apiKey ?? null
43
43
  }
44
44
 
45
- export const getStoredOrigin = async (originOverride?: string): Promise<string> => {
46
- if (originOverride) return normalizeOrigin(originOverride)
47
-
48
- const config = await readGlobalConfig()
49
-
50
- return normalizeOrigin(config.origin)
51
- }
52
-
53
45
  export const readProjectConfig = async (cwd: string): Promise<ProjectConfig | null> => {
54
46
  try {
55
47
  const content = await readFile(getProjectConfigPath(cwd), "utf8")
package/src/sync.ts CHANGED
@@ -107,7 +107,7 @@ const getLocalById = (articles: Array<LocalArticle>): Map<string, LocalArticle>
107
107
  const getRemoteById = (archive: RemoteArchive): Map<string, RemoteArticle> =>
108
108
  new Map(archive.articles.map((article) => [article.id, article]))
109
109
 
110
- const assertNoUpstreamChanges = ({
110
+ const getUpstreamConflicts = ({
111
111
  archive,
112
112
  config,
113
113
  localArticles,
@@ -115,7 +115,7 @@ const assertNoUpstreamChanges = ({
115
115
  archive: RemoteArchive
116
116
  config: ProjectConfig
117
117
  localArticles: Array<LocalArticle>
118
- }): void => {
118
+ }): Array<string> => {
119
119
  const conflicts: Array<string> = []
120
120
  const localById = getLocalById(localArticles)
121
121
 
@@ -147,9 +147,24 @@ const assertNoUpstreamChanges = ({
147
147
  }
148
148
  }
149
149
 
150
- if (conflicts.length) {
151
- throw new Error(`sync aborted because upstream is newer:\n${conflicts.map((conflict) => `- ${conflict}`).join("\n")}`)
152
- }
150
+ return conflicts
151
+ }
152
+
153
+ const formatUpstreamConflicts = (conflicts: Array<string>): string =>
154
+ `remote has newer changes:\n${conflicts.map((conflict) => `- ${conflict}`).join("\n")}`
155
+
156
+ const isConfirmed = (value: string): boolean => ["y", "yes"].includes(value.trim().toLowerCase())
157
+
158
+ const confirmUpstreamOverride = async ({ dryRun, prompt }: SyncOptions, conflicts: Array<string>): Promise<boolean> => {
159
+ if (!conflicts.length) return false
160
+
161
+ const question = dryRun
162
+ ? `${formatUpstreamConflicts(conflicts)}\nContinue dry run using local folder as source of truth? [y/N] `
163
+ : `${formatUpstreamConflicts(conflicts)}\nOverride remote changes with local folder? [y/N] `
164
+
165
+ if (isConfirmed(await prompt(question))) return true
166
+
167
+ throw new Error(`sync aborted because upstream is newer`)
153
168
  }
154
169
 
155
170
  const getUpdateBody = (article: LocalArticle): Record<string, unknown> => ({
@@ -176,18 +191,32 @@ const syncAttachments = async ({
176
191
  api,
177
192
  article,
178
193
  dryRun,
194
+ overwriteUpstream,
195
+ remoteArticle,
179
196
  state,
180
197
  summary,
181
198
  }: {
182
199
  api: EpoqueApi
183
200
  article: LocalArticle
184
201
  dryRun: boolean
202
+ overwriteUpstream: boolean
203
+ remoteArticle: RemoteArticle | undefined
185
204
  state: ArticleSyncState
186
205
  summary: SyncSummary
187
206
  }): Promise<ArticleSyncState> => {
188
207
  const nextState: ArticleSyncState = { ...state, attachments: {} }
189
208
  const localPaths = new Set(article.attachments.map((attachment) => attachment.relativePath))
190
209
  const imageIds: Array<string> = []
210
+ const trackedImageIds = new Set(Object.values(state.attachments).map((attachment) => attachment.id))
211
+
212
+ if (overwriteUpstream && remoteArticle) {
213
+ for (const image of remoteArticle.images) {
214
+ if (trackedImageIds.has(image.id)) continue
215
+
216
+ summary.deletedAttachments += 1
217
+ if (!dryRun) await api.deleteAttachment(image.url)
218
+ }
219
+ }
191
220
 
192
221
  for (const attachment of article.attachments) {
193
222
  const existing = state.attachments[attachment.relativePath]
@@ -236,14 +265,20 @@ export const syncFolder = async (options: SyncOptions): Promise<SyncSummary> =>
236
265
  const remoteById = getRemoteById(archive)
237
266
  const localById = getLocalById(localArticles)
238
267
  const summary: SyncSummary = { ...EMPTY_SUMMARY }
268
+ const overwriteUpstream = await confirmUpstreamOverride(options, getUpstreamConflicts({ archive, config, localArticles }))
239
269
 
240
- assertNoUpstreamChanges({ archive, config, localArticles })
270
+ for (const remoteArticle of archive.articles) {
271
+ if (localById.has(remoteArticle.id)) continue
272
+ if (!overwriteUpstream && !config.articles[remoteArticle.id]) continue
273
+
274
+ summary.deletedArticles += 1
275
+ if (!options.dryRun) await options.api.deleteArticle(remoteArticle.id)
276
+ delete config.articles[remoteArticle.id]
277
+ }
241
278
 
242
279
  for (const articleId of Object.keys(config.articles)) {
243
- if (localById.has(articleId) || !remoteById.has(articleId)) continue
280
+ if (localById.has(articleId) || remoteById.has(articleId)) continue
244
281
 
245
- summary.deletedArticles += 1
246
- if (!options.dryRun) await options.api.deleteArticle(articleId)
247
282
  delete config.articles[articleId]
248
283
  }
249
284
 
@@ -281,6 +316,8 @@ export const syncFolder = async (options: SyncOptions): Promise<SyncSummary> =>
281
316
  api: options.api,
282
317
  article: localArticle,
283
318
  dryRun: options.dryRun,
319
+ overwriteUpstream,
320
+ remoteArticle,
284
321
  state: { ...state, file: localArticle.file, remoteUpdatedAt: remoteArticle?.updatedAt ?? state.remoteUpdatedAt },
285
322
  summary,
286
323
  })