@narumitw/pi-sync 0.1.29 → 0.1.31
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/README.md +6 -3
- package/package.json +1 -1
- 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
|
|
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,
|
|
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
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
|
|
572
|
-
bucket: process.env.PI_SYNC_BUCKET ?? process.env.R2_BUCKET ?? fileConfig
|
|
573
|
-
region: process.env.PI_SYNC_REGION ?? process.env.AWS_REGION ?? fileConfig
|
|
574
|
-
accessKeyId: process.env.PI_SYNC_ACCESS_KEY_ID ?? process.env.AWS_ACCESS_KEY_ID ?? fileConfig
|
|
575
|
-
secretAccessKey: process.env.PI_SYNC_SECRET_ACCESS_KEY ?? process.env.AWS_SECRET_ACCESS_KEY ?? fileConfig
|
|
576
|
-
sessionToken:
|
|
577
|
-
profile: process.env.PI_SYNC_PROFILE ?? fileConfig
|
|
578
|
-
prefix: process.env.PI_SYNC_PREFIX ?? fileConfig
|
|
579
|
-
autoSync: process.env.PI_SYNC_AUTO_SYNC ?? fileConfig
|
|
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
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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;
|