@narumitw/pi-sync 0.1.30 → 0.1.32

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.
Files changed (3) hide show
  1. package/README.md +6 -3
  2. package/package.json +1 -1
  3. package/src/sync.ts +91 -23
package/README.md CHANGED
@@ -64,7 +64,6 @@ Example:
64
64
  "region": "auto",
65
65
  "accessKeyId": "<access-key-id>",
66
66
  "secretAccessKey": "<secret-access-key>",
67
- "sessionToken": "<optional-session-token>",
68
67
  "profile": "default",
69
68
  "prefix": "pi-sync",
70
69
  "autoSync": true
@@ -79,13 +78,17 @@ export PI_SYNC_BUCKET="pi-sync"
79
78
  export PI_SYNC_REGION="auto"
80
79
  export PI_SYNC_ACCESS_KEY_ID="..."
81
80
  export PI_SYNC_SECRET_ACCESS_KEY="..."
82
- export PI_SYNC_SESSION_TOKEN="..." # optional, for temporary STS/SSO credentials
81
+ export PI_SYNC_SESSION_TOKEN="..." # optional, for temporary credentials that require a session token
83
82
  export PI_SYNC_PROFILE="default"
84
83
  export PI_SYNC_PREFIX="pi-sync"
85
84
  export PI_SYNC_AUTO_SYNC="true"
86
85
  ```
87
86
 
88
- `PI_SYNC_ACCESS_KEY_ID`, `PI_SYNC_SECRET_ACCESS_KEY`, and `PI_SYNC_SESSION_TOKEN` are local-only credentials. Do not put them in files that pi-sync syncs. `PI_SYNC_SESSION_TOKEN` is optional and only needed for temporary credentials such as AWS STS, AWS SSO, or assumed roles. pi-sync also reads `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `AWS_REGION`, `R2_ENDPOINT`, and `R2_BUCKET` as compatibility aliases when the matching `PI_SYNC_*` variable is not set.
87
+ `PI_SYNC_ACCESS_KEY_ID`, `PI_SYNC_SECRET_ACCESS_KEY`, and `PI_SYNC_SESSION_TOKEN` are local-only credentials. Do not put them in files that pi-sync syncs. `PI_SYNC_SESSION_TOKEN` is optional and only needed for temporary credentials such as AWS STS, AWS SSO, assumed roles, or S3-compatible providers that issue short-lived credentials.
88
+
89
+ Cloudflare R2 static access keys do not use a session token and usually reject requests signed with `X-Amz-Security-Token`. R2 temporary credentials that require a token are still supported. For R2 endpoints (`*.r2.cloudflarestorage.com`), pi-sync first sends the configured session token; if R2 rejects it with `InvalidArgument: X-Amz-Security-Token`, pi-sync retries that request once without the token and omits the token for the rest of the same command after a successful retry.
90
+
91
+ pi-sync also reads `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `AWS_REGION`, `R2_ENDPOINT`, and `R2_BUCKET` as compatibility aliases when the matching `PI_SYNC_*` variable is not set.
89
92
 
90
93
  ## 🚀 Usage
91
94
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-sync",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "description": "Pi extension that syncs Pi configuration through Cloudflare R2 or S3-compatible storage.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/sync.ts CHANGED
@@ -230,6 +230,7 @@ async function initConfig(ctx: ExtensionCommandContext) {
230
230
 
231
231
  async function showConfig(ctx: ExtensionCommandContext) {
232
232
  const partial = await loadPartialConfig();
233
+ const warnings = sessionTokenWarnings(partial);
233
234
  ctx.ui.notify(
234
235
  [
235
236
  "pi-sync config:",
@@ -243,8 +244,9 @@ async function showConfig(ctx: ExtensionCommandContext) {
243
244
  `prefix: ${partial.prefix ?? DEFAULT_PREFIX}`,
244
245
  `autoSync: ${isEnabled(partial.autoSync ?? process.env.PI_SYNC_AUTO_SYNC, true) ? "enabled" : "disabled"}`,
245
246
  `local config: ${localConfigPath()}`,
247
+ ...warnings,
246
248
  ].join("\n"),
247
- "info",
249
+ warnings.length > 0 ? "warning" : "info",
248
250
  );
249
251
  }
250
252
 
@@ -300,6 +302,11 @@ async function doctor(ctx: ExtensionCommandContext) {
300
302
  try {
301
303
  const config = await loadConfig();
302
304
  messages.push(`config: ok (${config.bucket}/${profilePrefix(config)})`);
305
+ const warnings = sessionTokenWarnings(config);
306
+ if (warnings.length > 0) {
307
+ level = "warning";
308
+ messages.push(...warnings);
309
+ }
303
310
  } catch (error) {
304
311
  level = "warning";
305
312
  messages.push(`config: ${errorMessage(error)}`);
@@ -565,18 +572,18 @@ async function loadConfig(): Promise<SyncConfig> {
565
572
  }
566
573
 
567
574
  async function loadPartialConfig(): Promise<PartialConfig> {
568
- const fileConfig = await readJsonIfExists<PartialConfig>(localConfigPath());
575
+ const fileConfig = (await readJsonIfExists<PartialConfig>(localConfigPath())) ?? {};
569
576
  return {
570
577
  ...fileConfig,
571
- endpoint: process.env.PI_SYNC_ENDPOINT ?? process.env.R2_ENDPOINT ?? fileConfig?.endpoint,
572
- bucket: process.env.PI_SYNC_BUCKET ?? process.env.R2_BUCKET ?? fileConfig?.bucket,
573
- region: process.env.PI_SYNC_REGION ?? process.env.AWS_REGION ?? fileConfig?.region,
574
- accessKeyId: process.env.PI_SYNC_ACCESS_KEY_ID ?? process.env.AWS_ACCESS_KEY_ID ?? fileConfig?.accessKeyId,
575
- secretAccessKey: process.env.PI_SYNC_SECRET_ACCESS_KEY ?? process.env.AWS_SECRET_ACCESS_KEY ?? fileConfig?.secretAccessKey,
576
- sessionToken: process.env.PI_SYNC_SESSION_TOKEN ?? process.env.AWS_SESSION_TOKEN ?? fileConfig?.sessionToken,
577
- profile: process.env.PI_SYNC_PROFILE ?? fileConfig?.profile,
578
- prefix: process.env.PI_SYNC_PREFIX ?? fileConfig?.prefix,
579
- autoSync: process.env.PI_SYNC_AUTO_SYNC ?? fileConfig?.autoSync,
578
+ endpoint: process.env.PI_SYNC_ENDPOINT ?? process.env.R2_ENDPOINT ?? fileConfig.endpoint,
579
+ bucket: process.env.PI_SYNC_BUCKET ?? process.env.R2_BUCKET ?? fileConfig.bucket,
580
+ region: process.env.PI_SYNC_REGION ?? process.env.AWS_REGION ?? fileConfig.region,
581
+ accessKeyId: process.env.PI_SYNC_ACCESS_KEY_ID ?? process.env.AWS_ACCESS_KEY_ID ?? fileConfig.accessKeyId,
582
+ secretAccessKey: process.env.PI_SYNC_SECRET_ACCESS_KEY ?? process.env.AWS_SECRET_ACCESS_KEY ?? fileConfig.secretAccessKey,
583
+ sessionToken: selectSessionToken(fileConfig.sessionToken),
584
+ profile: process.env.PI_SYNC_PROFILE ?? fileConfig.profile,
585
+ prefix: process.env.PI_SYNC_PREFIX ?? fileConfig.prefix,
586
+ autoSync: process.env.PI_SYNC_AUTO_SYNC ?? fileConfig.autoSync,
580
587
  };
581
588
  }
582
589
 
@@ -847,6 +854,7 @@ async function writeState(profile: string, state: SyncState) {
847
854
  class S3Client {
848
855
  private config: SyncConfig;
849
856
  private endpoint: URL;
857
+ private omitSessionTokenAfterRejection = false;
850
858
 
851
859
  constructor(config: SyncConfig) {
852
860
  this.config = config;
@@ -886,17 +894,38 @@ class S3Client {
886
894
  ) {
887
895
  const url = new URL(this.endpoint.toString());
888
896
  url.pathname = posixJoin(url.pathname, this.config.bucket, encodeKey(key));
889
- const headers = await signedHeaders({
890
- method,
891
- url,
892
- body,
893
- extraHeaders,
894
- accessKeyId: this.config.accessKeyId,
895
- secretAccessKey: this.config.secretAccessKey,
896
- sessionToken: this.config.sessionToken,
897
- region: this.config.region,
898
- });
899
- return fetch(url, { method, headers, body: body ? new Uint8Array(body) : undefined });
897
+ const send = async (sessionToken: string | undefined) => {
898
+ const headers = await signedHeaders({
899
+ method,
900
+ url,
901
+ body,
902
+ extraHeaders,
903
+ accessKeyId: this.config.accessKeyId,
904
+ secretAccessKey: this.config.secretAccessKey,
905
+ sessionToken,
906
+ region: this.config.region,
907
+ });
908
+ return fetch(url, { method, headers, body: body ? new Uint8Array(body) : undefined });
909
+ };
910
+ const sessionToken = this.omitSessionTokenAfterRejection ? undefined : this.config.sessionToken;
911
+ const response = await send(sessionToken);
912
+ if (!(await this.shouldRetryWithoutSessionToken(response, sessionToken))) return response;
913
+
914
+ const retry = await send(undefined);
915
+ if (retry.ok || retry.status === 404) this.omitSessionTokenAfterRejection = true;
916
+ return retry;
917
+ }
918
+
919
+ private async shouldRetryWithoutSessionToken(response: Response, sessionToken: string | undefined) {
920
+ if (
921
+ !sessionToken ||
922
+ !isCloudflareR2Endpoint(this.config.endpoint) ||
923
+ response.ok ||
924
+ response.status !== 400
925
+ ) {
926
+ return false;
927
+ }
928
+ return isSecurityTokenInvalidArgument(await response.clone().text());
900
929
  }
901
930
  }
902
931
 
@@ -1129,7 +1158,7 @@ function usage() {
1129
1158
  return [
1130
1159
  "Usage: /pisync <command>",
1131
1160
  "Commands: init, config, status, diff, doctor, push, pull, sync, history, rollback <snapshot>, unlock --stale",
1132
- "Config: set PI_SYNC_ENDPOINT, PI_SYNC_BUCKET, PI_SYNC_ACCESS_KEY_ID, PI_SYNC_SECRET_ACCESS_KEY, optional PI_SYNC_SESSION_TOKEN/region/profile/prefix, or edit ~/.pi/agent/pi-sync.local.json.",
1161
+ "Config: set PI_SYNC_ENDPOINT, PI_SYNC_BUCKET, PI_SYNC_ACCESS_KEY_ID, PI_SYNC_SECRET_ACCESS_KEY, optional PI_SYNC_SESSION_TOKEN (ignored for R2)/region/profile/prefix, or edit ~/.pi/agent/pi-sync.local.json.",
1133
1162
  ].join("\n");
1134
1163
  }
1135
1164
 
@@ -1173,6 +1202,45 @@ function redact(value: string) {
1173
1202
  return value.length <= 8 ? "configured" : `${value.slice(0, 4)}…${value.slice(-4)}`;
1174
1203
  }
1175
1204
 
1205
+ function selectSessionToken(fileSessionToken: string | undefined) {
1206
+ if (hasEnv("PI_SYNC_SESSION_TOKEN")) return normalizeOptionalString(process.env.PI_SYNC_SESSION_TOKEN);
1207
+ return normalizeOptionalString(process.env.AWS_SESSION_TOKEN) ?? normalizeOptionalString(fileSessionToken);
1208
+ }
1209
+
1210
+ function sessionTokenWarnings(config: { endpoint?: string; sessionToken?: string }) {
1211
+ if (!isCloudflareR2Endpoint(config.endpoint) || !config.sessionToken) return [];
1212
+ return [
1213
+ "session token: configured for Cloudflare R2; if R2 rejects X-Amz-Security-Token, pi-sync retries once without it. R2 static access keys usually do not need a session token.",
1214
+ ];
1215
+ }
1216
+
1217
+ function isSecurityTokenInvalidArgument(text: string) {
1218
+ return (
1219
+ text.includes("<Code>InvalidArgument</Code>") &&
1220
+ text.includes("<Message>X-Amz-Security-Token</Message>")
1221
+ );
1222
+ }
1223
+
1224
+ function isCloudflareR2Endpoint(endpoint: string | undefined) {
1225
+ const value = endpoint?.trim();
1226
+ if (!value) return false;
1227
+ try {
1228
+ const hostname = new URL(value).hostname.toLowerCase();
1229
+ return hostname === "r2.cloudflarestorage.com" || hostname.endsWith(".r2.cloudflarestorage.com");
1230
+ } catch {
1231
+ return false;
1232
+ }
1233
+ }
1234
+
1235
+ function normalizeOptionalString(value: string | undefined) {
1236
+ const normalized = value?.trim();
1237
+ return normalized ? normalized : undefined;
1238
+ }
1239
+
1240
+ function hasEnv(name: string) {
1241
+ return Object.prototype.hasOwnProperty.call(process.env, name);
1242
+ }
1243
+
1176
1244
  function isEnabled(value: boolean | string | undefined, defaultValue: boolean) {
1177
1245
  if (value === undefined) return defaultValue;
1178
1246
  if (typeof value === "boolean") return value;