@narumitw/pi-sync 0.31.0 → 0.34.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.
package/src/s3-client.ts CHANGED
@@ -1,21 +1,17 @@
1
1
  import { createHash, createHmac } from "node:crypto";
2
2
  import { setTimeout as sleep } from "node:timers/promises";
3
3
  import { encodeKey, posixJoin } from "./paths.js";
4
- import type { LatestPointer, RemoteObject, Snapshot, SyncConfig } from "./types.js";
4
+ import type { RemoteObject, ResolvedS3Backend } from "./types.js";
5
5
 
6
- const VERSION = 1;
6
+ const MAX_JSON_RESPONSE_BYTES = 1024 * 1024;
7
+ const MAX_SNAPSHOT_RESPONSE_BYTES = 256 * 1024 * 1024;
8
+ const MAX_ERROR_RESPONSE_BYTES = 64 * 1024;
9
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
7
10
 
8
11
  function iso8601Basic(date: Date) {
9
12
  return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
10
13
  }
11
14
 
12
- function snapshotIncludesSessions(snapshot: Snapshot) {
13
- return (
14
- snapshot.syncSessions === true ||
15
- snapshot.files.some((file) => file.path.startsWith("sessions/"))
16
- );
17
- }
18
-
19
15
  function isCloudflareR2Endpoint(endpoint: string | undefined) {
20
16
  const value = endpoint?.trim();
21
17
  if (!value) return false;
@@ -29,16 +25,29 @@ function isCloudflareR2Endpoint(endpoint: string | undefined) {
29
25
  }
30
26
  }
31
27
 
28
+ export class S3ObjectAlreadyExistsError extends Error {
29
+ constructor(readonly key: string) {
30
+ super(`S3 object already exists: ${key}`);
31
+ this.name = "S3ObjectAlreadyExistsError";
32
+ }
33
+ }
34
+
32
35
  export class S3Client {
33
- private config: SyncConfig;
36
+ private config: ResolvedS3Backend;
34
37
  private endpoint: URL;
35
38
  private signal?: AbortSignal;
39
+ private requestTimeoutMs: number;
36
40
  private omitSessionTokenAfterRejection = false;
37
41
 
38
- constructor(config: SyncConfig, signal?: AbortSignal) {
42
+ constructor(
43
+ config: ResolvedS3Backend,
44
+ signal?: AbortSignal,
45
+ requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
46
+ ) {
39
47
  this.config = config;
40
- this.endpoint = new URL(config.endpoint);
48
+ this.endpoint = new URL(config.profile.endpoint);
41
49
  this.signal = signal;
50
+ this.requestTimeoutMs = requestTimeoutMs;
42
51
  }
43
52
 
44
53
  async getJson<T>(key: string): Promise<RemoteObject<T>> {
@@ -47,8 +56,10 @@ export class S3Client {
47
56
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
48
57
  const object = await this.request("GET", key);
49
58
  if (object.status === 404) return { missing: true };
50
- if (!object.ok) throw new Error(`S3 GET failed (${object.status}): ${await object.text()}`);
51
- const body = await object.text();
59
+ if (!object.ok) {
60
+ throw new Error(`S3 GET failed (${object.status}): ${await this.readErrorText(object)}`);
61
+ }
62
+ const body = await readBoundedText(object, MAX_JSON_RESPONSE_BYTES, "S3 JSON response");
52
63
  // R2 can intermittently return an empty 200 body (read-after-write
53
64
  // inconsistency on a long-lived keep-alive connection), which makes
54
65
  // response.json() throw "JSON Parse error: Unexpected EOF" under Bun.
@@ -75,8 +86,14 @@ export class S3Client {
75
86
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
76
87
  const object = await this.request("GET", key);
77
88
  if (object.status === 404) return { missing: true };
78
- if (!object.ok) throw new Error(`S3 GET failed (${object.status}): ${await object.text()}`);
79
- const buffer = Buffer.from(await object.arrayBuffer());
89
+ if (!object.ok) {
90
+ throw new Error(`S3 GET failed (${object.status}): ${await this.readErrorText(object)}`);
91
+ }
92
+ const buffer = await readBoundedBuffer(
93
+ object,
94
+ MAX_SNAPSHOT_RESPONSE_BYTES,
95
+ "S3 snapshot response",
96
+ );
80
97
  // R2 can intermittently return an empty 200 body on a long-lived
81
98
  // keep-alive connection (same root cause as the getJson retry
82
99
  // above). getBuffer is only used for snapshot .json.gz payloads,
@@ -100,11 +117,21 @@ export class S3Client {
100
117
  await this.putBuffer(key, body, "application/json");
101
118
  }
102
119
 
103
- async putBuffer(key: string, body: Buffer, contentType: string) {
120
+ async putBuffer(
121
+ key: string,
122
+ body: Buffer,
123
+ contentType: string,
124
+ options: { ifAbsent?: boolean } = {},
125
+ ) {
104
126
  const headers: Record<string, string> = { "content-type": contentType };
127
+ if (options.ifAbsent) headers["if-none-match"] = "*";
105
128
  const response = await this.request("PUT", key, body, headers);
106
- if (!response.ok)
107
- throw new Error(`S3 PUT failed (${response.status}): ${await response.text()}`);
129
+ if (options.ifAbsent && response.status === 412) {
130
+ throw new S3ObjectAlreadyExistsError(key);
131
+ }
132
+ if (!response.ok) {
133
+ throw new Error(`S3 PUT failed (${response.status}): ${await this.readErrorText(response)}`);
134
+ }
108
135
  }
109
136
 
110
137
  private async request(
@@ -114,26 +141,37 @@ export class S3Client {
114
141
  extraHeaders: Record<string, string> = {},
115
142
  ) {
116
143
  const url = new URL(this.endpoint.toString());
117
- url.pathname = posixJoin(url.pathname, this.config.bucket, encodeKey(key));
144
+ url.pathname = posixJoin(url.pathname, this.config.destination.bucket, encodeKey(key));
118
145
  const send = async (sessionToken: string | undefined) => {
146
+ const transportSignal = this.transportSignal();
119
147
  const headers = await signedHeaders({
120
148
  method,
121
149
  url,
122
150
  body,
123
151
  extraHeaders,
124
- accessKeyId: this.config.accessKeyId,
125
- secretAccessKey: this.config.secretAccessKey,
152
+ accessKeyId: this.config.profile.accessKeyId,
153
+ secretAccessKey: this.config.profile.secretAccessKey,
126
154
  sessionToken,
127
- region: this.config.region,
128
- });
129
- return fetch(url, {
130
- method,
131
- headers,
132
- body: body ? new Uint8Array(body) : undefined,
133
- signal: this.signal,
155
+ region: this.config.profile.region,
134
156
  });
157
+ try {
158
+ return await fetch(url, {
159
+ method,
160
+ headers,
161
+ body: body ? new Uint8Array(body) : undefined,
162
+ signal: transportSignal,
163
+ });
164
+ } catch (error) {
165
+ if (error instanceof Error && error.name === "AbortError") throw error;
166
+ if (error instanceof Error && error.name === "TimeoutError") {
167
+ throw new Error("S3 request timed out.", { cause: error });
168
+ }
169
+ throw new Error(`S3 request failed: ${this.redact(errorMessage(error))}`, { cause: error });
170
+ }
135
171
  };
136
- const sessionToken = this.omitSessionTokenAfterRejection ? undefined : this.config.sessionToken;
172
+ const sessionToken = this.omitSessionTokenAfterRejection
173
+ ? undefined
174
+ : this.config.profile.sessionToken;
137
175
  const response = await send(sessionToken);
138
176
  if (!(await this.shouldRetryWithoutSessionToken(response, sessionToken))) return response;
139
177
 
@@ -142,20 +180,97 @@ export class S3Client {
142
180
  return retry;
143
181
  }
144
182
 
183
+ private transportSignal() {
184
+ const timeout = AbortSignal.timeout(this.requestTimeoutMs);
185
+ return this.signal ? AbortSignal.any([this.signal, timeout]) : timeout;
186
+ }
187
+
145
188
  private async shouldRetryWithoutSessionToken(
146
189
  response: Response,
147
190
  sessionToken: string | undefined,
148
191
  ) {
149
192
  if (
150
193
  !sessionToken ||
151
- !isCloudflareR2Endpoint(this.config.endpoint) ||
194
+ !isCloudflareR2Endpoint(this.config.profile.endpoint) ||
152
195
  response.ok ||
153
196
  response.status !== 400
154
197
  ) {
155
198
  return false;
156
199
  }
157
- return isSecurityTokenInvalidArgument(await response.clone().text());
200
+ return isSecurityTokenInvalidArgument(
201
+ await readBoundedText(response.clone(), MAX_ERROR_RESPONSE_BYTES, "S3 error response"),
202
+ );
203
+ }
204
+
205
+ private async readErrorText(response: Response) {
206
+ try {
207
+ return this.redact(
208
+ await readBoundedText(response, MAX_ERROR_RESPONSE_BYTES, "S3 error response"),
209
+ );
210
+ } catch (error) {
211
+ return this.redact(errorMessage(error));
212
+ }
213
+ }
214
+
215
+ private redact(value: string) {
216
+ let redacted = value;
217
+ let endpointUsername: string | undefined;
218
+ let endpointPassword: string | undefined;
219
+ let endpointQueryValues: string[] = [];
220
+ try {
221
+ const endpoint = new URL(this.config.profile.endpoint);
222
+ endpointUsername = endpoint.username;
223
+ endpointPassword = endpoint.password;
224
+ endpointQueryValues = [...endpoint.searchParams.values()];
225
+ } catch {
226
+ // The constructor already validates normal runtime endpoints.
227
+ }
228
+ for (const secret of [
229
+ this.config.profile.accessKeyId,
230
+ this.config.profile.secretAccessKey,
231
+ this.config.profile.sessionToken,
232
+ endpointUsername,
233
+ endpointPassword,
234
+ ...endpointQueryValues,
235
+ ]) {
236
+ if (secret) redacted = redacted.replaceAll(secret, "[REDACTED]");
237
+ }
238
+ return redacted;
239
+ }
240
+ }
241
+
242
+ async function readBoundedText(response: Response, limit: number, label: string) {
243
+ return (await readBoundedBuffer(response, limit, label)).toString("utf8");
244
+ }
245
+
246
+ async function readBoundedBuffer(response: Response, limit: number, label: string) {
247
+ const contentLength = Number(response.headers.get("content-length"));
248
+ if (Number.isFinite(contentLength) && contentLength > limit) {
249
+ throw new Error(`${label} exceeds the ${limit}-byte limit.`);
158
250
  }
251
+ if (!response.body) return Buffer.alloc(0);
252
+ const reader = response.body.getReader();
253
+ const chunks: Buffer[] = [];
254
+ let total = 0;
255
+ try {
256
+ while (true) {
257
+ const { done, value } = await reader.read();
258
+ if (done) break;
259
+ total += value.byteLength;
260
+ if (total > limit) {
261
+ await reader.cancel().catch(() => undefined);
262
+ throw new Error(`${label} exceeds the ${limit}-byte limit.`);
263
+ }
264
+ chunks.push(Buffer.from(value));
265
+ }
266
+ } finally {
267
+ reader.releaseLock();
268
+ }
269
+ return Buffer.concat(chunks, total);
270
+ }
271
+
272
+ function errorMessage(error: unknown) {
273
+ return error instanceof Error ? error.message : String(error);
159
274
  }
160
275
 
161
276
  async function signedHeaders(input: {
@@ -217,38 +332,6 @@ function sha256(value: Buffer) {
217
332
  return createHash("sha256").update(value).digest("hex");
218
333
  }
219
334
 
220
- export function latestKey(config: SyncConfig) {
221
- return posixJoin(profilePrefix(config), "latest.json");
222
- }
223
-
224
- export function historyKey(config: SyncConfig) {
225
- return posixJoin(profilePrefix(config), "history.json");
226
- }
227
-
228
- export function snapshotKey(config: SyncConfig, id: string) {
229
- return posixJoin(profilePrefix(config), "snapshots", `${id}.json.gz`);
230
- }
231
-
232
- export function profilePrefix(config: SyncConfig) {
233
- return posixJoin(config.prefix, "profiles", config.profile);
234
- }
235
-
236
- export function pointerFor(
237
- config: SyncConfig,
238
- snapshot: Snapshot,
239
- checksum: string,
240
- ): LatestPointer {
241
- return {
242
- version: VERSION,
243
- profile: config.profile,
244
- snapshot: snapshot.id,
245
- sha256: checksum,
246
- createdAt: snapshot.createdAt,
247
- machine: snapshot.machine,
248
- syncSessions: snapshotIncludesSessions(snapshot),
249
- };
250
- }
251
-
252
335
  function lowercaseKeys(value: Record<string, string>) {
253
336
  return Object.fromEntries(Object.entries(value).map(([key, item]) => [key.toLowerCase(), item]));
254
337
  }
@@ -0,0 +1,92 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { promptSecret } from "./secret-input.js";
3
+
4
+ export interface ChosenS3Credentials {
5
+ profileFields: { accessKeyId?: string; secretAccessKey?: string };
6
+ summary: string;
7
+ ready: boolean;
8
+ replace?: boolean;
9
+ }
10
+
11
+ export async function chooseS3CredentialUpdate(
12
+ ctx: ExtensionCommandContext,
13
+ profile: Record<string, unknown>,
14
+ signal?: AbortSignal,
15
+ ) {
16
+ const hasStored =
17
+ typeof profile.accessKeyId === "string" && typeof profile.secretAccessKey === "string";
18
+ if (hasStored) {
19
+ const action = await ctx.ui.select(
20
+ "Credentials",
21
+ ["Keep current credentials", "Change credential source", "Cancel"],
22
+ { signal },
23
+ );
24
+ throwIfAborted(signal);
25
+ if (!action || action === "Cancel") return undefined;
26
+ if (action === "Keep current credentials") {
27
+ return { profileFields: {}, summary: "Unchanged (values hidden)", ready: true };
28
+ }
29
+ }
30
+ const selected = await chooseS3Credentials(ctx, signal);
31
+ return selected ? { ...selected, replace: true } : undefined;
32
+ }
33
+
34
+ export function applyS3CredentialUpdate(
35
+ profile: Record<string, unknown>,
36
+ credentials: ChosenS3Credentials,
37
+ ) {
38
+ const next = { ...profile };
39
+ if (credentials.replace) {
40
+ delete next.accessKeyId;
41
+ delete next.secretAccessKey;
42
+ delete next.sessionToken;
43
+ }
44
+ return { ...next, ...credentials.profileFields };
45
+ }
46
+
47
+ export async function chooseS3Credentials(
48
+ ctx: ExtensionCommandContext,
49
+ signal?: AbortSignal,
50
+ ): Promise<ChosenS3Credentials | undefined> {
51
+ const choice = await ctx.ui.select(
52
+ "Credentials\n\nCredentials are stored in the private pi-sync settings file. Secret values are masked during input and never shown afterward.",
53
+ ["Store credentials privately", "Cancel"],
54
+ { signal },
55
+ );
56
+ throwIfAborted(signal);
57
+ if (choice !== "Store credentials privately") return undefined;
58
+ const accessKeyId = await requiredCredentialInput(ctx, "Access key ID", "access-key-id", signal);
59
+ if (!accessKeyId) return undefined;
60
+ const secretAccessKey = await promptSecret(ctx, "Secret access key", { signal });
61
+ throwIfAborted(signal);
62
+ if (secretAccessKey === undefined) return undefined;
63
+ return {
64
+ profileFields: { accessKeyId, secretAccessKey },
65
+ summary: "Stored privately (values hidden)",
66
+ ready: true,
67
+ };
68
+ }
69
+
70
+ async function requiredCredentialInput(
71
+ ctx: ExtensionCommandContext,
72
+ title: string,
73
+ placeholder: string,
74
+ signal?: AbortSignal,
75
+ ) {
76
+ const value = await ctx.ui.input(title, placeholder, { signal });
77
+ throwIfAborted(signal);
78
+ if (value === undefined) return undefined;
79
+ const normalized = value.trim();
80
+ if (!normalized) {
81
+ ctx.ui.notify(`${title} is required.`, "warning");
82
+ return undefined;
83
+ }
84
+ return normalized.includes("<") || normalized.includes(">") ? undefined : normalized;
85
+ }
86
+
87
+ function throwIfAborted(signal?: AbortSignal) {
88
+ if (!signal?.aborted) return;
89
+ throw signal.reason instanceof Error
90
+ ? signal.reason
91
+ : new DOMException("The operation was aborted", "AbortError");
92
+ }
@@ -0,0 +1,199 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ CURSOR_MARKER,
4
+ decodeKittyPrintable,
5
+ type Focusable,
6
+ type KeybindingsManager,
7
+ Text,
8
+ truncateToWidth,
9
+ } from "@earendil-works/pi-tui";
10
+
11
+ const MASK = "•";
12
+
13
+ export async function promptSecret(
14
+ ctx: ExtensionCommandContext,
15
+ title: string,
16
+ options: { required?: boolean; signal?: AbortSignal } = { required: true },
17
+ ) {
18
+ if (ctx.mode !== "tui" || options.signal?.aborted) return undefined;
19
+ const value = await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
20
+ const heading = new Text("", 0, 0);
21
+ const hint = new Text("", 0, 0);
22
+ const applyTheme = () => {
23
+ heading.setText(theme.fg("accent", theme.bold(title)));
24
+ hint.setText(theme.fg("dim", "Enter save • Escape cancel • Input is hidden"));
25
+ };
26
+ applyTheme();
27
+ const input = new MaskedInput(keybindings);
28
+ let settled = false;
29
+ const finish = (result: string | undefined) => {
30
+ if (settled) return;
31
+ settled = true;
32
+ done(result);
33
+ };
34
+ const onAbort = () => finish(undefined);
35
+ options.signal?.addEventListener("abort", onAbort, { once: true });
36
+ const component: Focusable & {
37
+ render(width: number): string[];
38
+ invalidate(): void;
39
+ handleInput(data: string): void;
40
+ dispose(): void;
41
+ } = {
42
+ get focused() {
43
+ return input.focused;
44
+ },
45
+ set focused(focused: boolean) {
46
+ input.focused = focused;
47
+ },
48
+ render(width: number) {
49
+ const safeWidth = Math.max(1, width);
50
+ return [
51
+ ...heading.render(safeWidth),
52
+ ...input.render(safeWidth),
53
+ ...hint.render(safeWidth),
54
+ ].map((line) => truncateToWidth(line, safeWidth));
55
+ },
56
+ invalidate() {
57
+ applyTheme();
58
+ heading.invalidate();
59
+ input.invalidate();
60
+ hint.invalidate();
61
+ },
62
+ handleInput(data: string) {
63
+ if (keybindings.matches(data, "tui.select.cancel")) finish(undefined);
64
+ else if (keybindings.matches(data, "tui.input.submit")) finish(input.getValue());
65
+ else input.handleInput(data);
66
+ tui.requestRender();
67
+ },
68
+ dispose() {
69
+ options.signal?.removeEventListener("abort", onAbort);
70
+ input.clear();
71
+ },
72
+ };
73
+ return component;
74
+ });
75
+ if (value === undefined) return undefined;
76
+ if (options.required !== false && value.length === 0) {
77
+ ctx.ui.notify(`${title} is required.`, "warning");
78
+ return undefined;
79
+ }
80
+ return value;
81
+ }
82
+
83
+ class MaskedInput implements Focusable {
84
+ focused = false;
85
+ private value: string[] = [];
86
+ private cursor = 0;
87
+ private paste = "";
88
+ private pasting = false;
89
+
90
+ constructor(private readonly keybindings: KeybindingsManager) {}
91
+
92
+ getValue() {
93
+ return this.value.join("");
94
+ }
95
+
96
+ handleInput(data: string) {
97
+ if (data.includes("\u001b[200~")) {
98
+ this.pasting = true;
99
+ this.paste = "";
100
+ data = data.replace("\u001b[200~", "");
101
+ }
102
+ if (this.pasting) {
103
+ this.paste += data;
104
+ const end = this.paste.indexOf("\u001b[201~");
105
+ if (end < 0) return;
106
+ const pasted = this.paste
107
+ .slice(0, end)
108
+ .replace(/[\r\n]/gu, "")
109
+ .replace(/\t/gu, " ");
110
+ this.insert(pasted);
111
+ const remaining = this.paste.slice(end + 6);
112
+ this.paste = "";
113
+ this.pasting = false;
114
+ if (remaining) this.handleInput(remaining);
115
+ return;
116
+ }
117
+ if (this.keybindings.matches(data, "tui.editor.deleteCharBackward")) {
118
+ if (this.cursor > 0) this.value.splice(--this.cursor, 1);
119
+ return;
120
+ }
121
+ if (this.keybindings.matches(data, "tui.editor.deleteCharForward")) {
122
+ if (this.cursor < this.value.length) this.value.splice(this.cursor, 1);
123
+ return;
124
+ }
125
+ if (this.keybindings.matches(data, "tui.editor.cursorLeft")) {
126
+ this.cursor = Math.max(0, this.cursor - 1);
127
+ return;
128
+ }
129
+ if (this.keybindings.matches(data, "tui.editor.cursorRight")) {
130
+ this.cursor = Math.min(this.value.length, this.cursor + 1);
131
+ return;
132
+ }
133
+ if (this.keybindings.matches(data, "tui.editor.cursorLineStart")) {
134
+ this.cursor = 0;
135
+ return;
136
+ }
137
+ if (this.keybindings.matches(data, "tui.editor.cursorLineEnd")) {
138
+ this.cursor = this.value.length;
139
+ return;
140
+ }
141
+ if (this.keybindings.matches(data, "tui.editor.deleteToLineStart")) {
142
+ this.value.splice(0, this.cursor);
143
+ this.cursor = 0;
144
+ return;
145
+ }
146
+ if (this.keybindings.matches(data, "tui.editor.deleteToLineEnd")) {
147
+ this.value.splice(this.cursor);
148
+ return;
149
+ }
150
+ const printable = decodeKittyPrintable(data) ?? data;
151
+ if (!hasControlCharacter(printable)) this.insert(printable);
152
+ }
153
+
154
+ render(width: number) {
155
+ const prompt = "> ";
156
+ const available = width - prompt.length;
157
+ if (available <= 0) return [truncateToWidth(prompt, Math.max(1, width))];
158
+ const contentWidth = Math.max(0, available - 1);
159
+ let start = 0;
160
+ if (this.value.length > contentWidth) {
161
+ start = Math.max(
162
+ 0,
163
+ Math.min(this.cursor - Math.floor(contentWidth / 2), this.value.length - contentWidth),
164
+ );
165
+ }
166
+ const end = Math.min(this.value.length, start + contentWidth);
167
+ const visibleCursor = Math.max(0, Math.min(this.cursor - start, end - start));
168
+ const masks = Array.from({ length: end - start }, () => MASK);
169
+ const before = masks.slice(0, visibleCursor).join("");
170
+ const atCursor = visibleCursor < masks.length ? MASK : " ";
171
+ const after = masks.slice(visibleCursor + (visibleCursor < masks.length ? 1 : 0)).join("");
172
+ const marker = this.focused ? CURSOR_MARKER : "";
173
+ const line = `${prompt}${before}${marker}\u001b[7m${atCursor}\u001b[27m${after}`;
174
+ return [truncateToWidth(line, width, "")];
175
+ }
176
+
177
+ invalidate() {}
178
+
179
+ clear() {
180
+ this.value.fill("");
181
+ this.value = [];
182
+ this.paste = "";
183
+ this.cursor = 0;
184
+ this.pasting = false;
185
+ }
186
+
187
+ private insert(value: string) {
188
+ const characters = Array.from(value);
189
+ this.value.splice(this.cursor, 0, ...characters);
190
+ this.cursor += characters.length;
191
+ }
192
+ }
193
+
194
+ function hasControlCharacter(value: string) {
195
+ return [...value].some((character) => {
196
+ const code = character.codePointAt(0) ?? 0;
197
+ return code < 0x20 || (code >= 0x7f && code <= 0x9f);
198
+ });
199
+ }