@narumitw/pi-sync 0.49.14 → 0.50.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/README.md +134 -241
- package/dist/chunks/chunk-2B2ELUF5.ts +29 -0
- package/dist/chunks/chunk-2B2ELUF5.ts.map +7 -0
- package/dist/chunks/chunk-6LP3MCR2.ts +115 -0
- package/dist/chunks/chunk-6LP3MCR2.ts.map +7 -0
- package/dist/chunks/chunk-6UHKGALE.ts +134 -0
- package/dist/chunks/chunk-6UHKGALE.ts.map +7 -0
- package/dist/chunks/{chunk-RGJ6SOYR.ts → chunk-AUSDLD6F.ts} +4 -4
- package/dist/chunks/chunk-AUSDLD6F.ts.map +7 -0
- package/dist/chunks/{chunk-3NDD5QPM.ts → chunk-AXXS2BPA.ts} +9 -9
- package/dist/chunks/chunk-AXXS2BPA.ts.map +7 -0
- package/dist/chunks/{chunk-APZVBSM6.ts → chunk-C7XY5NF5.ts} +214 -365
- package/dist/chunks/chunk-C7XY5NF5.ts.map +7 -0
- package/dist/chunks/{chunk-4U6AFLJB.ts → chunk-CHH26D4F.ts} +3 -2
- package/dist/chunks/chunk-CHH26D4F.ts.map +7 -0
- package/dist/chunks/chunk-CPEHGXJV.ts +50 -0
- package/dist/chunks/chunk-CPEHGXJV.ts.map +7 -0
- package/dist/chunks/{chunk-5MQ76BK5.ts → chunk-D5X27LCA.ts} +2 -2
- package/dist/chunks/chunk-D5X27LCA.ts.map +7 -0
- package/dist/chunks/{chunk-LJDSHNS3.ts → chunk-GWMHAQG4.ts} +4 -4
- package/dist/chunks/chunk-GWMHAQG4.ts.map +7 -0
- package/dist/chunks/{chunk-FR6CVXAL.ts → chunk-HUDQATCL.ts} +25 -25
- package/dist/chunks/chunk-HUDQATCL.ts.map +7 -0
- package/dist/chunks/{chunk-YQ6UW7IF.ts → chunk-JAAKLL6M.ts} +2 -2
- package/dist/chunks/chunk-JAAKLL6M.ts.map +7 -0
- package/dist/chunks/chunk-JSYMGJQQ.ts +51 -0
- package/dist/chunks/chunk-JSYMGJQQ.ts.map +7 -0
- package/dist/chunks/chunk-L7TBO32M.ts +25 -0
- package/dist/chunks/chunk-L7TBO32M.ts.map +7 -0
- package/dist/chunks/{chunk-4F3W4CSC.ts → chunk-LCRI3ABW.ts} +22 -8
- package/dist/chunks/chunk-LCRI3ABW.ts.map +7 -0
- package/dist/chunks/chunk-LTBWGSBV.ts +12 -0
- package/dist/chunks/chunk-LTBWGSBV.ts.map +7 -0
- package/dist/chunks/chunk-NJFUIVYL.ts +17 -0
- package/dist/chunks/chunk-NJFUIVYL.ts.map +7 -0
- package/dist/chunks/{chunk-EWX3TPLQ.ts → chunk-OEX4ABY6.ts} +39 -37
- package/dist/chunks/chunk-OEX4ABY6.ts.map +7 -0
- package/dist/chunks/chunk-OUHGBFYF.ts +1255 -0
- package/dist/chunks/chunk-OUHGBFYF.ts.map +7 -0
- package/dist/chunks/{chunk-HGV24P4T.ts → chunk-Q2TUM5X6.ts} +3 -3
- package/dist/chunks/chunk-Q2TUM5X6.ts.map +7 -0
- package/dist/chunks/{chunk-BHJUISB4.ts → chunk-SL64RLXR.ts} +8 -6
- package/dist/chunks/chunk-SL64RLXR.ts.map +7 -0
- package/dist/chunks/chunk-T3HH3UEJ.ts +96 -0
- package/dist/chunks/chunk-T3HH3UEJ.ts.map +7 -0
- package/dist/chunks/chunk-TEN2ZBJV.ts +101 -0
- package/dist/chunks/chunk-TEN2ZBJV.ts.map +7 -0
- package/dist/chunks/chunk-THMF4BVL.ts +18 -0
- package/dist/chunks/chunk-THMF4BVL.ts.map +7 -0
- package/dist/chunks/{chunk-LONPFFNP.ts → chunk-X5OM6GXT.ts} +9 -7
- package/dist/chunks/chunk-X5OM6GXT.ts.map +7 -0
- package/dist/chunks/chunk-XBX4HBLG.ts +57 -0
- package/dist/chunks/chunk-XBX4HBLG.ts.map +7 -0
- package/dist/chunks/{chunk-5WJNWFTG.ts → chunk-YKJHPTGO.ts} +23 -55
- package/dist/chunks/chunk-YKJHPTGO.ts.map +7 -0
- package/dist/chunks/chunk-Z4DQF6L3.ts +12 -0
- package/dist/chunks/chunk-Z4DQF6L3.ts.map +7 -0
- package/dist/chunks/{chunk-F5QL2GPY.ts → chunk-ZI5MXMEX.ts} +5 -99
- package/dist/chunks/chunk-ZI5MXMEX.ts.map +7 -0
- package/dist/chunks/{file-selection-4UABOP44.ts → file-selection-FTTKAOYX.ts} +10 -7
- package/dist/chunks/file-selection-FTTKAOYX.ts.map +7 -0
- package/dist/chunks/{git-backend-E3UOUUOM.ts → git-backend-OQW46G2X.ts} +42 -17
- package/dist/chunks/git-backend-OQW46G2X.ts.map +7 -0
- package/dist/chunks/manager-result-dispatcher-U23ODD32.ts +22 -0
- package/dist/chunks/manager-ui-ABATHC36.ts +1680 -0
- package/dist/chunks/manager-ui-ABATHC36.ts.map +7 -0
- package/dist/chunks/remote-selection-ui-ZE3TY67S.ts +21 -0
- package/dist/chunks/{s3-backend-3QZGO2EF.ts → s3-backend-3P33LQHU.ts} +96 -18
- package/dist/chunks/s3-backend-3P33LQHU.ts.map +7 -0
- package/dist/chunks/{setup-switch-X3W3FALZ.ts → setup-switch-IS2PEZ3J.ts} +9 -9
- package/dist/chunks/setup-wizard-VB3VDOV5.ts +89 -0
- package/dist/chunks/setup-wizard-VB3VDOV5.ts.map +7 -0
- package/dist/chunks/{snapshot-WBSV4MQG.ts → snapshot-HKEWCVK6.ts} +7 -6
- package/dist/chunks/sync-inspection-FEP4L5TR.ts +20 -0
- package/dist/chunks/{sync-operations-MPAFVVHA.ts → sync-operations-ONJ27QRL.ts} +381 -299
- package/dist/chunks/sync-operations-ONJ27QRL.ts.map +7 -0
- package/dist/chunks/{sync-state-36H7ULG2.ts → sync-state-Q2FTXTW6.ts} +6 -6
- package/dist/chunks/sync-state-Q2FTXTW6.ts.map +7 -0
- package/dist/chunks/{webdav-backend-ZD5RHRIU.ts → webdav-backend-ECIKD7SO.ts} +26 -13
- package/dist/chunks/webdav-backend-ECIKD7SO.ts.map +7 -0
- package/dist/index.ts +477 -342
- package/dist/index.ts.map +4 -4
- package/docs/settings.md +211 -0
- package/package.json +6 -8
- package/src/{backend-factory.ts → backends/backend-factory.ts} +4 -4
- package/src/backends/backend-identity.ts +40 -0
- package/src/backends/backend-types.ts +81 -0
- package/src/{git-backend.ts → backends/git/git-backend.ts} +43 -19
- package/src/{git-config.ts → backends/git/git-config.ts} +1 -0
- package/src/{git-storage.ts → backends/git/git-storage.ts} +2 -2
- package/src/{s3-backend.ts → backends/s3/s3-backend.ts} +45 -9
- package/src/{s3-client.ts → backends/s3/s3-client.ts} +56 -8
- package/src/{sync-backend.ts → backends/sync-backend.ts} +1 -1
- package/src/{webdav-backend.ts → backends/webdav/webdav-backend.ts} +22 -10
- package/src/{webdav-client.ts → backends/webdav/webdav-client.ts} +1 -1
- package/src/{webdav-config.ts → backends/webdav/webdav-config.ts} +1 -0
- package/src/commands/command-execution.ts +330 -0
- package/src/commands/command-handler.ts +201 -0
- package/src/commands/command-types.ts +18 -0
- package/src/{command.ts → commands/command.ts} +2 -1
- package/src/commands/setup-completions.ts +6 -0
- package/src/settings/config.ts +238 -0
- package/src/{settings-management.ts → settings/settings-management.ts} +40 -14
- package/src/settings/settings-store.ts +109 -0
- package/src/settings/settings-types.ts +128 -0
- package/src/settings/settings-validation.ts +443 -0
- package/src/snapshot/session-paths.ts +84 -0
- package/src/{snapshot-apply.ts → snapshot/snapshot-apply.ts} +3 -3
- package/src/{snapshot-codec.ts → snapshot/snapshot-codec.ts} +2 -2
- package/src/{snapshot-transaction.ts → snapshot/snapshot-transaction.ts} +5 -4
- package/src/snapshot/snapshot-types.ts +38 -0
- package/src/{snapshot.ts → snapshot/snapshot.ts} +39 -20
- package/src/state/json-file.ts +20 -0
- package/src/{lock.ts → state/lock.ts} +3 -2
- package/src/{operation-availability.ts → state/operation-availability.ts} +2 -2
- package/src/state/state-types.ts +20 -0
- package/src/state/sync-state-store.ts +62 -0
- package/src/sync/automatic-sync.ts +103 -0
- package/src/{remote-snapshot.ts → sync/remote-snapshot.ts} +27 -4
- package/src/{setup-switch.ts → sync/setup-switch.ts} +6 -7
- package/src/sync/signals.ts +10 -0
- package/src/sync/startup-check.ts +128 -0
- package/src/{sync-decision.ts → sync/sync-decision.ts} +6 -3
- package/src/sync/sync-error-guidance.ts +53 -0
- package/src/sync/sync-inspection.ts +51 -0
- package/src/sync/sync-loaders.ts +54 -0
- package/src/{sync-operations.ts → sync/sync-mutations.ts} +41 -330
- package/src/sync/sync-operations.ts +12 -0
- package/src/{sync-policy.ts → sync/sync-policy.ts} +2 -2
- package/src/sync/sync-queries.ts +235 -0
- package/src/{sync-state.ts → sync/sync-state.ts} +5 -4
- package/src/sync-extension.ts +64 -708
- package/src/sync.ts +8 -8
- package/src/types.ts +47 -284
- package/src/ui/automatic-sync-summary.ts +6 -0
- package/src/{cancellable-operation.ts → ui/cancellable-operation.ts} +4 -4
- package/src/{file-selection.ts → ui/file-selection.ts} +5 -3
- package/src/{manager-attention.ts → ui/manager-attention.ts} +5 -1
- package/src/{manager-recovery.ts → ui/manager-recovery.ts} +6 -2
- package/src/{manager-state.ts → ui/manager-state.ts} +35 -14
- package/src/ui/manager-ui.ts +386 -0
- package/src/{remote-selection-ui.ts → ui/remote-selection-ui.ts} +21 -13
- package/src/{secret-input.ts → ui/secret-input.ts} +22 -4
- package/src/{settings-ui.ts → ui/settings-ui.ts} +42 -11
- package/src/ui/setup/git-ui.ts +293 -0
- package/src/{s3-credentials-ui.ts → ui/setup/s3-credentials-ui.ts} +13 -21
- package/src/ui/setup/s3-ui.ts +217 -0
- package/src/ui/setup/setup-actions.ts +127 -0
- package/src/ui/setup/setup-location-ui.ts +167 -0
- package/src/ui/setup/setup-name-ui.ts +34 -0
- package/src/ui/setup/setup-prompts.ts +92 -0
- package/src/ui/setup/setup-review.ts +70 -0
- package/src/ui/setup/setup-switcher.ts +181 -0
- package/src/ui/setup/setup-wizard.ts +39 -0
- package/src/ui/setup/text-input.ts +78 -0
- package/src/ui/setup/webdav-ui.ts +330 -0
- package/src/{storage-connections-ui.ts → ui/storage-connections-ui.ts} +99 -69
- package/src/{sync-attention.ts → ui/sync-attention.ts} +75 -6
- package/src/{sync-format.ts → ui/sync-format.ts} +7 -13
- package/src/{sync-resolution-ui.ts → ui/sync-resolution-ui.ts} +4 -3
- package/src/{sync-setups-ui.ts → ui/sync-setups-ui.ts} +23 -10
- package/src/ui/terminal-text.ts +4 -0
- package/dist/chunks/chunk-3NDD5QPM.ts.map +0 -7
- package/dist/chunks/chunk-4F3W4CSC.ts.map +0 -7
- package/dist/chunks/chunk-4U6AFLJB.ts.map +0 -7
- package/dist/chunks/chunk-5MQ76BK5.ts.map +0 -7
- package/dist/chunks/chunk-5WJNWFTG.ts.map +0 -7
- package/dist/chunks/chunk-APZVBSM6.ts.map +0 -7
- package/dist/chunks/chunk-BHJUISB4.ts.map +0 -7
- package/dist/chunks/chunk-EWX3TPLQ.ts.map +0 -7
- package/dist/chunks/chunk-F5QL2GPY.ts.map +0 -7
- package/dist/chunks/chunk-FR6CVXAL.ts.map +0 -7
- package/dist/chunks/chunk-HGV24P4T.ts.map +0 -7
- package/dist/chunks/chunk-JNOOSPEW.ts +0 -82
- package/dist/chunks/chunk-JNOOSPEW.ts.map +0 -7
- package/dist/chunks/chunk-LJDSHNS3.ts.map +0 -7
- package/dist/chunks/chunk-LONPFFNP.ts.map +0 -7
- package/dist/chunks/chunk-RGJ6SOYR.ts.map +0 -7
- package/dist/chunks/chunk-W64NEHXT.ts +0 -167
- package/dist/chunks/chunk-W64NEHXT.ts.map +0 -7
- package/dist/chunks/chunk-YQ6UW7IF.ts.map +0 -7
- package/dist/chunks/file-selection-4UABOP44.ts.map +0 -7
- package/dist/chunks/git-backend-E3UOUUOM.ts.map +0 -7
- package/dist/chunks/manager-result-dispatcher-RNRQA3PW.ts +0 -18
- package/dist/chunks/manager-ui-QO2P4V3Z.ts +0 -2757
- package/dist/chunks/manager-ui-QO2P4V3Z.ts.map +0 -7
- package/dist/chunks/remote-selection-ui-LHSAGKGA.ts +0 -17
- package/dist/chunks/s3-backend-3QZGO2EF.ts.map +0 -7
- package/dist/chunks/sync-operations-MPAFVVHA.ts.map +0 -7
- package/dist/chunks/webdav-backend-ZD5RHRIU.ts.map +0 -7
- package/src/config.ts +0 -910
- package/src/git-ui.ts +0 -307
- package/src/manager-helpers.ts +0 -64
- package/src/manager-ui.ts +0 -997
- package/src/webdav-ui.ts +0 -421
- /package/dist/chunks/{manager-result-dispatcher-RNRQA3PW.ts.map → manager-result-dispatcher-U23ODD32.ts.map} +0 -0
- /package/dist/chunks/{remote-selection-ui-LHSAGKGA.ts.map → remote-selection-ui-ZE3TY67S.ts.map} +0 -0
- /package/dist/chunks/{setup-switch-X3W3FALZ.ts.map → setup-switch-IS2PEZ3J.ts.map} +0 -0
- /package/dist/chunks/{snapshot-WBSV4MQG.ts.map → snapshot-HKEWCVK6.ts.map} +0 -0
- /package/dist/chunks/{sync-state-36H7ULG2.ts.map → sync-inspection-FEP4L5TR.ts.map} +0 -0
- /package/src/{git-runner.ts → backends/git/git-runner.ts} +0 -0
- /package/src/{config-errors.ts → settings/config-errors.ts} +0 -0
- /package/src/{config-file.ts → settings/config-file.ts} +0 -0
- /package/src/{snapshot-paths.ts → snapshot/snapshot-paths.ts} +0 -0
- /package/src/{lock-policy.ts → state/lock-policy.ts} +0 -0
- /package/src/{lockfile-fs.ts → state/lockfile-fs.ts} +0 -0
- /package/src/{state-directory.ts → state/state-directory.ts} +0 -0
- /package/src/{sync-errors.ts → sync/sync-errors.ts} +0 -0
- /package/src/{manager-result-dispatcher.ts → ui/manager-result-dispatcher.ts} +0 -0
|
@@ -3,11 +3,11 @@ import type { Dirent } from "node:fs";
|
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { isDeniedPath, posixJoin, safeJoin, toPosix } from "../paths.js";
|
|
7
|
+
import { agentDir, configuredSessionDir } from "./session-paths.js";
|
|
8
8
|
import { sessionStorageRoot } from "./snapshot-paths.js";
|
|
9
9
|
|
|
10
|
-
export { isDeniedPath } from "
|
|
10
|
+
export { isDeniedPath } from "../paths.js";
|
|
11
11
|
export { sessionStorageRoot } from "./snapshot-paths.js";
|
|
12
12
|
|
|
13
13
|
import {
|
|
@@ -23,10 +23,10 @@ import {
|
|
|
23
23
|
selectionForSnapshot,
|
|
24
24
|
snapshotSelectionInclude,
|
|
25
25
|
syncIncludeSelection,
|
|
26
|
-
} from "
|
|
27
|
-
import type { Snapshot, SnapshotFile, SnapshotOptions } from "./types.js";
|
|
26
|
+
} from "../sync/sync-policy.js";
|
|
27
|
+
import type { Snapshot, SnapshotFile, SnapshotOptions } from "./snapshot-types.js";
|
|
28
28
|
|
|
29
|
-
export { canonicalSnapshotPathForConfig, isConfiguredSnapshotPath } from "
|
|
29
|
+
export { canonicalSnapshotPathForConfig, isConfiguredSnapshotPath } from "../sync/sync-policy.js";
|
|
30
30
|
|
|
31
31
|
const VERSION = 1;
|
|
32
32
|
const TOP_LEVEL_FILES: readonly string[] = DEFAULT_SYNC_INCLUDE.filter((name) =>
|
|
@@ -96,12 +96,13 @@ export async function createSnapshot(
|
|
|
96
96
|
profile: string,
|
|
97
97
|
options: SnapshotOptions = {},
|
|
98
98
|
): Promise<Snapshot> {
|
|
99
|
+
options.signal?.throwIfAborted();
|
|
99
100
|
const include = effectiveInclude(options);
|
|
100
101
|
const syncSessions = include.includes("sessions");
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
const sessionDir = options.sessionDir ?? (await configuredSessionDir());
|
|
103
|
+
options.signal?.throwIfAborted();
|
|
104
|
+
const files = await collectFiles(agentDir(), { include, sessionDir, signal: options.signal });
|
|
105
|
+
options.signal?.throwIfAborted();
|
|
105
106
|
return {
|
|
106
107
|
version: VERSION,
|
|
107
108
|
id: snapshotId(),
|
|
@@ -127,48 +128,61 @@ export async function collectFiles(
|
|
|
127
128
|
root: string,
|
|
128
129
|
options: SnapshotOptions = {},
|
|
129
130
|
): Promise<SnapshotFile[]> {
|
|
131
|
+
const signal = options.signal;
|
|
132
|
+
signal?.throwIfAborted();
|
|
130
133
|
const results: SnapshotFile[] = [];
|
|
131
134
|
const entries = await fs.readdir(root, { withFileTypes: true });
|
|
135
|
+
signal?.throwIfAborted();
|
|
132
136
|
const selection = syncIncludeSelection(effectiveInclude(options));
|
|
133
137
|
const selectedFiles = new Set<string>(selection.builtIns);
|
|
134
138
|
for (const entry of entries) {
|
|
135
139
|
if (entry.isDirectory() && TOP_LEVEL_DIRS.has(entry.name) && selectedFiles.has(entry.name)) {
|
|
136
|
-
await collectDirectory(results, root, entry.name);
|
|
140
|
+
await collectDirectory(results, root, entry.name, { signal });
|
|
137
141
|
}
|
|
138
142
|
}
|
|
139
143
|
for (const fileName of TOP_LEVEL_FILES) {
|
|
140
144
|
if (!selectedFiles.has(fileName)) continue;
|
|
141
145
|
const entry = selectTopLevelFileEntry(entries, fileName);
|
|
142
|
-
if (entry) await addFile(results, root, entry.name, fileName);
|
|
146
|
+
if (entry) await addFile(results, root, entry.name, fileName, signal);
|
|
143
147
|
}
|
|
144
148
|
for (const relativePath of selection.custom) {
|
|
145
|
-
await collectIncludedPath(results, root, relativePath);
|
|
149
|
+
await collectIncludedPath(results, root, relativePath, signal);
|
|
146
150
|
}
|
|
147
151
|
if (selection.sessions) {
|
|
148
152
|
try {
|
|
149
153
|
await collectDirectory(results, sessionStorageRoot(root, options.sessionDir), "", {
|
|
150
154
|
sessionsOnly: true,
|
|
151
155
|
virtualPrefix: "sessions",
|
|
156
|
+
signal,
|
|
152
157
|
});
|
|
153
158
|
} catch (error) {
|
|
154
159
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
155
160
|
}
|
|
156
161
|
}
|
|
162
|
+
signal?.throwIfAborted();
|
|
157
163
|
return results.sort((left, right) => left.path.localeCompare(right.path));
|
|
158
164
|
}
|
|
159
165
|
|
|
160
|
-
async function collectIncludedPath(
|
|
166
|
+
async function collectIncludedPath(
|
|
167
|
+
results: SnapshotFile[],
|
|
168
|
+
root: string,
|
|
169
|
+
relativePath: string,
|
|
170
|
+
signal?: AbortSignal,
|
|
171
|
+
) {
|
|
172
|
+
signal?.throwIfAborted();
|
|
161
173
|
const absolutePath = safeJoin(root, relativePath);
|
|
162
174
|
try {
|
|
163
175
|
const stat = await fs.lstat(absolutePath);
|
|
164
|
-
|
|
165
|
-
|
|
176
|
+
signal?.throwIfAborted();
|
|
177
|
+
if (stat.isFile()) await addFile(results, root, relativePath, relativePath, signal);
|
|
178
|
+
else if (stat.isDirectory()) await collectDirectory(results, root, relativePath, { signal });
|
|
166
179
|
} catch (error) {
|
|
167
180
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
168
181
|
if (!relativePath.includes("/")) {
|
|
169
182
|
const entries = await fs.readdir(root, { withFileTypes: true });
|
|
183
|
+
signal?.throwIfAborted();
|
|
170
184
|
const entry = selectTopLevelFileEntry(entries, relativePath);
|
|
171
|
-
if (entry) await addFile(results, root, entry.name, relativePath);
|
|
185
|
+
if (entry) await addFile(results, root, entry.name, relativePath, signal);
|
|
172
186
|
}
|
|
173
187
|
}
|
|
174
188
|
}
|
|
@@ -177,10 +191,12 @@ async function collectDirectory(
|
|
|
177
191
|
results: SnapshotFile[],
|
|
178
192
|
root: string,
|
|
179
193
|
relativeDirectory: string,
|
|
180
|
-
options: { sessionsOnly?: boolean; virtualPrefix?: string } = {},
|
|
194
|
+
options: { sessionsOnly?: boolean; virtualPrefix?: string; signal?: AbortSignal } = {},
|
|
181
195
|
) {
|
|
196
|
+
options.signal?.throwIfAborted();
|
|
182
197
|
const absoluteDirectory = path.join(root, relativeDirectory);
|
|
183
198
|
for (const entry of await fs.readdir(absoluteDirectory, { withFileTypes: true })) {
|
|
199
|
+
options.signal?.throwIfAborted();
|
|
184
200
|
const relativePath = relativeDirectory ? posixJoin(relativeDirectory, entry.name) : entry.name;
|
|
185
201
|
const snapshotPath = options.virtualPrefix
|
|
186
202
|
? posixJoin(options.virtualPrefix, relativePath)
|
|
@@ -189,7 +205,7 @@ async function collectDirectory(
|
|
|
189
205
|
if (entry.isDirectory()) {
|
|
190
206
|
await collectDirectory(results, root, relativePath, options);
|
|
191
207
|
} else if (entry.isFile() && (!options.sessionsOnly || isSessionFilePath(snapshotPath))) {
|
|
192
|
-
await addFile(results, root, relativePath, snapshotPath);
|
|
208
|
+
await addFile(results, root, relativePath, snapshotPath, options.signal);
|
|
193
209
|
}
|
|
194
210
|
}
|
|
195
211
|
}
|
|
@@ -199,10 +215,13 @@ async function addFile(
|
|
|
199
215
|
root: string,
|
|
200
216
|
relativePath: string,
|
|
201
217
|
snapshotPath = relativePath,
|
|
218
|
+
signal?: AbortSignal,
|
|
202
219
|
) {
|
|
220
|
+
signal?.throwIfAborted();
|
|
203
221
|
if (!isSafeSnapshotPath(snapshotPath)) return;
|
|
204
222
|
const absolutePath = safeJoin(root, relativePath);
|
|
205
|
-
const content = await fs.readFile(absolutePath);
|
|
223
|
+
const content = await fs.readFile(absolutePath, { signal });
|
|
224
|
+
signal?.throwIfAborted();
|
|
206
225
|
results.push({
|
|
207
226
|
path: snapshotPath,
|
|
208
227
|
contentBase64: content.toString("base64"),
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export async function readJsonIfExists<T>(filePath: string): Promise<T | undefined> {
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
|
|
8
|
+
} catch (error) {
|
|
9
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
10
|
+
throw error;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function writeJson(filePath: string, value: unknown) {
|
|
15
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
16
|
+
const temp = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
17
|
+
await fs.writeFile(temp, `${JSON.stringify(value, null, "\t")}\n`, { mode: 0o600 });
|
|
18
|
+
if (process.platform !== "win32") await fs.chmod(temp, 0o600);
|
|
19
|
+
await fs.rename(temp, filePath);
|
|
20
|
+
}
|
|
@@ -2,10 +2,11 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import lockfile from "proper-lockfile";
|
|
5
|
-
import {
|
|
5
|
+
import type { CommandOptions } from "../commands/command-types.js";
|
|
6
6
|
import { LOCK_GUARD_STALE_MS, LOCK_GUARD_UPDATE_MS } from "./lock-policy.js";
|
|
7
7
|
import { LOCKFILE_FS_ADAPTER } from "./lockfile-fs.js";
|
|
8
|
-
import type {
|
|
8
|
+
import type { LockFile } from "./state-types.js";
|
|
9
|
+
import { ensureStateDir, lockPath } from "./sync-state-store.js";
|
|
9
10
|
|
|
10
11
|
const LOCK_STALE_MS = 30 * 60 * 1000;
|
|
11
12
|
const MAX_PROCESS_ID = 2_147_483_647;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { errorMessage } from "../sync/sync-errors.js";
|
|
1
2
|
import { inspectLock, isLockGuardHeld, isStaleLock, type LockInspection } from "./lock.js";
|
|
2
|
-
import {
|
|
3
|
-
import type { LockFile } from "./types.js";
|
|
3
|
+
import type { LockFile } from "./state-types.js";
|
|
4
4
|
|
|
5
5
|
export type OperationAvailability =
|
|
6
6
|
| { kind: "free" }
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface SyncState {
|
|
2
|
+
version: number;
|
|
3
|
+
profile: string;
|
|
4
|
+
lastAppliedSnapshot?: string;
|
|
5
|
+
lastRemoteRevision?: string;
|
|
6
|
+
lastRemoteEtag?: string;
|
|
7
|
+
lastFileHashes: Record<string, string>;
|
|
8
|
+
include?: string[];
|
|
9
|
+
/** Legacy state fields are read only so v3 can detect and replace stale policy state. */
|
|
10
|
+
syncFiles?: string[];
|
|
11
|
+
syncSessions?: boolean;
|
|
12
|
+
extraFiles?: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface LockFile {
|
|
16
|
+
id: string;
|
|
17
|
+
pid: number;
|
|
18
|
+
command: string;
|
|
19
|
+
startedAt: string;
|
|
20
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { backendIdentityCoordinates } from "../backends/backend-identity.js";
|
|
5
|
+
import { safeName } from "../paths.js";
|
|
6
|
+
import type { AnySyncConfig } from "../settings/settings-types.js";
|
|
7
|
+
import { readJsonIfExists, writeJson } from "./json-file.js";
|
|
8
|
+
import { stateDir } from "./state-directory.js";
|
|
9
|
+
import type { SyncState } from "./state-types.js";
|
|
10
|
+
|
|
11
|
+
const STATE_VERSION = 2;
|
|
12
|
+
|
|
13
|
+
/** Bounded observation token; do not retain the full baseline in passive UI state. */
|
|
14
|
+
export function syncStateFingerprint(state: SyncState) {
|
|
15
|
+
return createHash("sha256").update(JSON.stringify(state)).digest("hex");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readState(profile: string): Promise<SyncState> {
|
|
19
|
+
return (
|
|
20
|
+
(await readJsonIfExists<SyncState>(statePath(profile))) ?? {
|
|
21
|
+
version: STATE_VERSION,
|
|
22
|
+
profile,
|
|
23
|
+
lastFileHashes: {},
|
|
24
|
+
}
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function writeState(profile: string, state: SyncState) {
|
|
29
|
+
await writeJson(statePath(profile), state);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function readStateForConfig(config: AnySyncConfig): Promise<SyncState> {
|
|
33
|
+
return (
|
|
34
|
+
(await readJsonIfExists<SyncState>(statePathForConfig(config))) ?? {
|
|
35
|
+
version: STATE_VERSION,
|
|
36
|
+
profile: config.snapshotIdentity,
|
|
37
|
+
lastFileHashes: {},
|
|
38
|
+
}
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function writeStateForConfig(config: AnySyncConfig, state: SyncState) {
|
|
43
|
+
await writeJson(statePathForConfig(config), state);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function statePathForConfig(config: AnySyncConfig) {
|
|
47
|
+
const identity = backendIdentityCoordinates(config);
|
|
48
|
+
const hash = createHash("sha256").update(identity).digest("hex").slice(0, 16);
|
|
49
|
+
return path.join(stateDir(), "setups", `${config.backend.type}-${hash}.state.json`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function statePath(profile: string) {
|
|
53
|
+
return path.join(stateDir(), `${safeName(profile)}.state.json`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function lockPath() {
|
|
57
|
+
return path.join(stateDir(), "lock");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function ensureStateDir() {
|
|
61
|
+
await fs.mkdir(stateDir(), { recursive: true });
|
|
62
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { setSyncSetupCompletions } from "../commands/command.js";
|
|
3
|
+
import type { CommandOptions } from "../commands/command-types.js";
|
|
4
|
+
import { loadConfig, loadPartialConfig } from "../settings/config.js";
|
|
5
|
+
import { isMissingConfigError } from "../settings/config-errors.js";
|
|
6
|
+
import { consumeLocalConfigMigrationNotice } from "../settings/config-file.js";
|
|
7
|
+
import { configuredSyncSetupNames } from "../settings/settings-store.js";
|
|
8
|
+
import { snapshotOptionsForContext } from "../snapshot/session-paths.js";
|
|
9
|
+
import { recoverSnapshotTransactionsOnStartup } from "../snapshot/snapshot-transaction.js";
|
|
10
|
+
import { withLock } from "../state/lock.js";
|
|
11
|
+
import { stateDirectoryMigrationNotice } from "../state/state-directory.js";
|
|
12
|
+
import { ensureStateDir, readStateForConfig } from "../state/sync-state-store.js";
|
|
13
|
+
import { safeTerminalText } from "../ui/terminal-text.js";
|
|
14
|
+
import { throwIfAborted } from "./signals.js";
|
|
15
|
+
import { errorMessage } from "./sync-errors.js";
|
|
16
|
+
import type { SyncLoaders } from "./sync-loaders.js";
|
|
17
|
+
|
|
18
|
+
const AUTO_SYNC_OPTIONS: CommandOptions = {
|
|
19
|
+
yes: true,
|
|
20
|
+
force: false,
|
|
21
|
+
stale: false,
|
|
22
|
+
silent: true,
|
|
23
|
+
reload: false,
|
|
24
|
+
auto: true,
|
|
25
|
+
args: [],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const STATUS_KEY = "sync";
|
|
29
|
+
|
|
30
|
+
export async function startSession(ctx: ExtensionContext, signal: AbortSignal) {
|
|
31
|
+
throwIfAborted(signal);
|
|
32
|
+
const stateNotice = stateDirectoryMigrationNotice();
|
|
33
|
+
if (stateNotice && ctx.hasUI) ctx.ui.notify(stateNotice, "warning");
|
|
34
|
+
// Recovery can restore managed files: finish it before Pi accepts user edits.
|
|
35
|
+
// Unlike remote inspection, it must never be detached behind startup.
|
|
36
|
+
await recoverSnapshotTransactionsOnStartup();
|
|
37
|
+
throwIfAborted(signal);
|
|
38
|
+
try {
|
|
39
|
+
const names = await configuredSyncSetupNames();
|
|
40
|
+
throwIfAborted(signal);
|
|
41
|
+
setSyncSetupCompletions(names);
|
|
42
|
+
} catch {
|
|
43
|
+
if (signal.aborted) return;
|
|
44
|
+
setSyncSetupCompletions([]);
|
|
45
|
+
}
|
|
46
|
+
const migrationNotice = consumeLocalConfigMigrationNotice();
|
|
47
|
+
if (migrationNotice && ctx.hasUI) ctx.ui.notify(migrationNotice, "warning");
|
|
48
|
+
throwIfAborted(signal);
|
|
49
|
+
if (!ctx.hasUI) return;
|
|
50
|
+
try {
|
|
51
|
+
const config = await loadConfig();
|
|
52
|
+
throwIfAborted(signal);
|
|
53
|
+
return config.automatic ? config : undefined;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
throwIfAborted(signal);
|
|
56
|
+
if (!isMissingConfigError(error)) {
|
|
57
|
+
ctx.ui.notify(
|
|
58
|
+
`pi-sync startup check skipped: ${safeTerminalText(errorMessage(error))}`,
|
|
59
|
+
"warning",
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function autoPushSessions(
|
|
66
|
+
ctx: ExtensionContext,
|
|
67
|
+
signal: AbortSignal,
|
|
68
|
+
loaders: SyncLoaders,
|
|
69
|
+
) {
|
|
70
|
+
try {
|
|
71
|
+
const partial = await loadPartialConfig();
|
|
72
|
+
throwIfAborted(signal);
|
|
73
|
+
if (!partial.automatic) return;
|
|
74
|
+
if (!partial.include.includes("sessions")) return;
|
|
75
|
+
await ensureStateDir();
|
|
76
|
+
throwIfAborted(signal);
|
|
77
|
+
const config = await loadConfig();
|
|
78
|
+
throwIfAborted(signal);
|
|
79
|
+
if (!config.include.includes("sessions")) return;
|
|
80
|
+
const [operations, snapshotModule, syncStateModule] = await Promise.all([
|
|
81
|
+
loaders.operations(),
|
|
82
|
+
loaders.snapshot(),
|
|
83
|
+
loaders.syncState(),
|
|
84
|
+
]);
|
|
85
|
+
throwIfAborted(signal);
|
|
86
|
+
await withLock("auto-session-push", async () => {
|
|
87
|
+
throwIfAborted(signal);
|
|
88
|
+
const state = await readStateForConfig(config);
|
|
89
|
+
throwIfAborted(signal);
|
|
90
|
+
const local = await snapshotModule.createSnapshot(config.snapshotIdentity, {
|
|
91
|
+
...snapshotOptionsForContext(ctx, config),
|
|
92
|
+
signal,
|
|
93
|
+
});
|
|
94
|
+
throwIfAborted(signal);
|
|
95
|
+
if (!syncStateModule.hasLocalChanges(local, state, config)) return;
|
|
96
|
+
await operations.push(ctx, { ...AUTO_SYNC_OPTIONS, signal }, { config, state, local });
|
|
97
|
+
});
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (signal.aborted || isMissingConfigError(error)) return;
|
|
100
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
101
|
+
ctx.ui.notify(`pi-sync session push skipped: ${errorMessage(error)}`, "warning");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
1
|
+
import type { RemoteHead, SyncBackend } from "../backends/sync-backend.js";
|
|
2
|
+
import { syncConfigReviewFingerprint } from "../settings/config.js";
|
|
3
|
+
import type { AnySyncConfig } from "../settings/settings-types.js";
|
|
4
|
+
import { filterSnapshotForConfigPolicy } from "../snapshot/snapshot.js";
|
|
5
|
+
import type { Snapshot } from "../snapshot/snapshot-types.js";
|
|
6
|
+
import { safeTerminalText } from "../ui/terminal-text.js";
|
|
4
7
|
import {
|
|
5
8
|
inspectRemoteSelection,
|
|
6
9
|
type RemoteSelectionState,
|
|
@@ -8,7 +11,6 @@ import {
|
|
|
8
11
|
sameSyncInclude,
|
|
9
12
|
snapshotSelectionInclude,
|
|
10
13
|
} from "./sync-policy.js";
|
|
11
|
-
import type { AnySyncConfig, Snapshot } from "./types.js";
|
|
12
14
|
|
|
13
15
|
export async function readSnapshotForHead(
|
|
14
16
|
backend: SyncBackend,
|
|
@@ -58,3 +60,24 @@ export function formatRemoteSelectionStatus(state: RemoteSelectionState | undefi
|
|
|
58
60
|
function safeList(values: readonly string[]) {
|
|
59
61
|
return values.length > 0 ? values.map(safeTerminalText).join(", ") : "none";
|
|
60
62
|
}
|
|
63
|
+
|
|
64
|
+
export async function readRemoteSnapshot(
|
|
65
|
+
backend: SyncBackend,
|
|
66
|
+
config: AnySyncConfig,
|
|
67
|
+
signal?: AbortSignal,
|
|
68
|
+
options: { allowSelectionDifference?: boolean } = {},
|
|
69
|
+
) {
|
|
70
|
+
const head = await backend.readHead(signal);
|
|
71
|
+
if (!head) return { head: undefined, snapshot: undefined, selectionState: undefined };
|
|
72
|
+
const snapshot = await readSnapshotForHead(backend, head, signal);
|
|
73
|
+
const selectionState = inspectRemoteSelection(config.include, snapshot);
|
|
74
|
+
if (!options.allowSelectionDifference && selectionState.kind === "different") {
|
|
75
|
+
const configIdentity = syncConfigReviewFingerprint(config);
|
|
76
|
+
throw remoteSelectionMismatch(config, selectionState.include, configIdentity);
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
head,
|
|
80
|
+
snapshot: filterSnapshotForConfigPolicy(snapshot, config),
|
|
81
|
+
selectionState,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { setSyncSetupCompletions } from "
|
|
2
|
+
import { setSyncSetupCompletions } from "../commands/command.js";
|
|
3
3
|
import {
|
|
4
|
-
configuredSyncSetupNames,
|
|
5
4
|
loadConfig,
|
|
6
|
-
normalizeOnSwitch,
|
|
7
5
|
syncConfigReviewIdentity,
|
|
8
6
|
syncSetupReviewIdentity,
|
|
9
|
-
|
|
10
|
-
} from "
|
|
7
|
+
} from "../settings/config.js";
|
|
8
|
+
import { configuredSyncSetupNames, updateLocalConfig } from "../settings/settings-store.js";
|
|
9
|
+
import type { OnSwitchAction } from "../settings/settings-types.js";
|
|
10
|
+
import { normalizeOnSwitch } from "../settings/settings-validation.js";
|
|
11
|
+
import { safeTerminalText } from "../ui/terminal-text.js";
|
|
11
12
|
import { SetupPullRequiresUiError } from "./sync-errors.js";
|
|
12
|
-
import { safeTerminalText } from "./sync-format.js";
|
|
13
|
-
import type { OnSwitchAction } from "./types.js";
|
|
14
13
|
|
|
15
14
|
export { SetupPullRequiresUiError } from "./sync-errors.js";
|
|
16
15
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function throwIfAborted(signal?: AbortSignal) {
|
|
2
|
+
if (!signal?.aborted) return;
|
|
3
|
+
throw signal.reason instanceof Error
|
|
4
|
+
? signal.reason
|
|
5
|
+
: new DOMException("The operation was aborted", "AbortError");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function combineSignals(primary: AbortSignal, secondary?: AbortSignal) {
|
|
9
|
+
return secondary ? AbortSignal.any([primary, secondary]) : primary;
|
|
10
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { loadConfigForCheck, syncCheckConfigFingerprint } from "../settings/config.js";
|
|
3
|
+
import { isMissingConfigError } from "../settings/config-errors.js";
|
|
4
|
+
import type { AnySyncConfig } from "../settings/settings-types.js";
|
|
5
|
+
import { snapshotOptionsForContext } from "../snapshot/session-paths.js";
|
|
6
|
+
import { withLock } from "../state/lock.js";
|
|
7
|
+
import { withStateDirectoryAccess } from "../state/state-directory.js";
|
|
8
|
+
import { readStateForConfig, syncStateFingerprint } from "../state/sync-state-store.js";
|
|
9
|
+
import type { SyncAttentionController } from "../ui/sync-attention.js";
|
|
10
|
+
import { safeTerminalText } from "../ui/terminal-text.js";
|
|
11
|
+
import { combineSignals, throwIfAborted } from "./signals.js";
|
|
12
|
+
import { errorMessage } from "./sync-errors.js";
|
|
13
|
+
import type { SyncLoaders } from "./sync-loaders.js";
|
|
14
|
+
|
|
15
|
+
const STATUS_KEY = "sync";
|
|
16
|
+
|
|
17
|
+
/** One owned task per extension session, never keyed by the shared headless UI. */
|
|
18
|
+
export function createStartupCheck(
|
|
19
|
+
loaders: SyncLoaders,
|
|
20
|
+
attention: SyncAttentionController,
|
|
21
|
+
timeoutMs = 30_000,
|
|
22
|
+
) {
|
|
23
|
+
let active:
|
|
24
|
+
| {
|
|
25
|
+
controller: AbortController;
|
|
26
|
+
promise: Promise<void>;
|
|
27
|
+
owner: ExtensionContext["sessionManager"];
|
|
28
|
+
}
|
|
29
|
+
| undefined;
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
start(ctx: ExtensionContext, sessionSignal: AbortSignal, initialConfig?: AnySyncConfig) {
|
|
33
|
+
if (!ctx.hasUI || sessionSignal.aborted) return;
|
|
34
|
+
if (active) throw new Error("Previous startup check has not been drained.");
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
const signal = combineSignals(sessionSignal, controller.signal);
|
|
37
|
+
const task = { controller, owner: ctx.sessionManager, promise: Promise.resolve() };
|
|
38
|
+
active = task;
|
|
39
|
+
const isCurrent = () =>
|
|
40
|
+
!sessionSignal.aborted && active === task && task.owner === ctx.sessionManager;
|
|
41
|
+
const timer = setTimeout(
|
|
42
|
+
() => controller.abort(new DOMException("Startup check timed out", "TimeoutError")),
|
|
43
|
+
timeoutMs,
|
|
44
|
+
);
|
|
45
|
+
let config: AnySyncConfig | undefined;
|
|
46
|
+
let checking = false;
|
|
47
|
+
// Handle every detached rejection here; Pi's awaited event runner cannot catch it.
|
|
48
|
+
task.promise = Promise.resolve()
|
|
49
|
+
.then(async () => {
|
|
50
|
+
try {
|
|
51
|
+
throwIfAborted(signal);
|
|
52
|
+
config = initialConfig ?? (await loadConfigForCheck());
|
|
53
|
+
throwIfAborted(signal);
|
|
54
|
+
if (!config.automatic) return;
|
|
55
|
+
const captured = config;
|
|
56
|
+
const identity = syncCheckConfigFingerprint(captured);
|
|
57
|
+
checking = true;
|
|
58
|
+
ctx.ui.setStatus(STATUS_KEY, "checking in background");
|
|
59
|
+
const { inspectSync } = await loaders.inspection();
|
|
60
|
+
throwIfAborted(signal);
|
|
61
|
+
const currentConfig = await loadConfigForCheck();
|
|
62
|
+
throwIfAborted(signal);
|
|
63
|
+
if (syncCheckConfigFingerprint(currentConfig) !== identity) return;
|
|
64
|
+
// Lock order: state migration guard → sync operation → backend cache.
|
|
65
|
+
// Read heads may write only private cache data. Never call doctor/syncBoth.
|
|
66
|
+
const inspection = await withStateDirectoryAccess(() => {
|
|
67
|
+
throwIfAborted(signal);
|
|
68
|
+
return withLock("startup-check", () => {
|
|
69
|
+
throwIfAborted(signal);
|
|
70
|
+
return inspectSync(captured, snapshotOptionsForContext(ctx, captured), signal);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
throwIfAborted(signal);
|
|
74
|
+
const latest = await loadConfigForCheck();
|
|
75
|
+
throwIfAborted(signal);
|
|
76
|
+
if (syncCheckConfigFingerprint(latest) !== identity) return;
|
|
77
|
+
const state = await withStateDirectoryAccess(() => readStateForConfig(latest));
|
|
78
|
+
throwIfAborted(signal);
|
|
79
|
+
if (syncStateFingerprint(state) !== inspection.stateIdentity || !isCurrent()) return;
|
|
80
|
+
attention.observe({
|
|
81
|
+
setupName: captured.setupName,
|
|
82
|
+
configIdentity: identity,
|
|
83
|
+
checkedAt: new Date().toISOString(),
|
|
84
|
+
inspection,
|
|
85
|
+
});
|
|
86
|
+
if (ctx.mode === "rpc") attention.notifyObservation(ctx);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (!isCurrent() || sessionSignal.aborted || isMissingConfigError(error)) return;
|
|
89
|
+
if (signal.aborted && controller.signal.reason?.name !== "TimeoutError") return;
|
|
90
|
+
// A settings edit invalidates failures as well as successful observations.
|
|
91
|
+
if (config) {
|
|
92
|
+
const latest = await loadConfigForCheck().catch(() => undefined);
|
|
93
|
+
if (
|
|
94
|
+
!isCurrent() ||
|
|
95
|
+
(signal.aborted && controller.signal.reason?.name !== "TimeoutError")
|
|
96
|
+
)
|
|
97
|
+
return;
|
|
98
|
+
if (
|
|
99
|
+
!latest ||
|
|
100
|
+
syncCheckConfigFingerprint(latest) !== syncCheckConfigFingerprint(config)
|
|
101
|
+
)
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
ctx.ui.notify(
|
|
105
|
+
`pi-sync startup check skipped: ${safeTerminalText(errorMessage(error))}. Run /sync status to retry. No startup transfer was performed.`,
|
|
106
|
+
"warning",
|
|
107
|
+
);
|
|
108
|
+
} finally {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
if (isCurrent() && checking) attention.publish(ctx);
|
|
111
|
+
if (active === task) active = undefined;
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
.catch(() => {
|
|
115
|
+
// UI teardown can itself throw. No unhandled rejection or retained timer.
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
if (active === task) active = undefined;
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
async stop() {
|
|
121
|
+
const task = active;
|
|
122
|
+
if (!task) return;
|
|
123
|
+
task.controller.abort(new DOMException("Startup check cancelled", "AbortError"));
|
|
124
|
+
// Drain the actual work, including child/ref cleanup, before foreground guards.
|
|
125
|
+
await task.promise;
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { syncConfigReviewIdentity } from "
|
|
1
|
+
import { syncConfigReviewIdentity } from "../settings/config.js";
|
|
2
|
+
import type { AnySyncConfig } from "../settings/settings-types.js";
|
|
3
|
+
import type { Snapshot } from "../snapshot/snapshot-types.js";
|
|
4
|
+
import type { SyncState } from "../state/state-types.js";
|
|
5
|
+
import { formatDiff, formatSnapshotOnlyDiff } from "../ui/sync-format.js";
|
|
6
|
+
import { safeTerminalText } from "../ui/terminal-text.js";
|
|
2
7
|
import { SyncDecisionRequiredError } from "./sync-errors.js";
|
|
3
|
-
import { formatDiff, formatSnapshotOnlyDiff, safeTerminalText } from "./sync-format.js";
|
|
4
8
|
import { includeFromSelectionConfig } from "./sync-policy.js";
|
|
5
9
|
import { syncPolicyChanged } from "./sync-state.js";
|
|
6
|
-
import type { AnySyncConfig, Snapshot, SyncState } from "./types.js";
|
|
7
10
|
|
|
8
11
|
export type SyncDecisionKind =
|
|
9
12
|
| "remote-or-policy-changed"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { errorMessage } from "./sync-errors.js";
|
|
2
|
+
|
|
3
|
+
/** Add recovery advice without changing backend outcome semantics or printing error causes. */
|
|
4
|
+
export function syncErrorGuidance(error: unknown): string {
|
|
5
|
+
const detail = errorMessage(error);
|
|
6
|
+
const message = detail.length > 1200 ? `${detail.slice(0, 1200)}… (details truncated)` : detail;
|
|
7
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
8
|
+
const status = (error as { status?: number } | undefined)?.status;
|
|
9
|
+
let next: string;
|
|
10
|
+
if (code === "ENOSPC") {
|
|
11
|
+
next = "Free disk space for the private pi-sync settings/state directory, then retry.";
|
|
12
|
+
} else if (code && ["EACCES", "EPERM", "EROFS"].includes(code)) {
|
|
13
|
+
next = "Check write access to the private pi-sync settings/state directory, then retry.";
|
|
14
|
+
} else if (code && ["EBUSY", "EIO", "EMFILE", "ENFILE"].includes(code)) {
|
|
15
|
+
next =
|
|
16
|
+
"Check local disk availability and other processes using the private settings file, then retry.";
|
|
17
|
+
} else if (/changed while|type changed|reopen/iu.test(message)) {
|
|
18
|
+
next = "Reopen the item in /sync and review its current settings before saving again.";
|
|
19
|
+
} else if (
|
|
20
|
+
status === 401 ||
|
|
21
|
+
status === 403 ||
|
|
22
|
+
/Permission denied|Authentication failed|HTTP (401|403)|failed \((401|403)\)/iu.test(message)
|
|
23
|
+
) {
|
|
24
|
+
next =
|
|
25
|
+
"Check the storage connection's credentials and remote permissions in /sync → More → Storage connections.";
|
|
26
|
+
} else if (status === 404 || /HTTP 404|failed \(404\)/iu.test(message)) {
|
|
27
|
+
next =
|
|
28
|
+
"Verify the connection address and setup's bucket, branch, or path with your provider; this response alone does not prove which resource is missing.";
|
|
29
|
+
} else if (status !== undefined && status >= 500) {
|
|
30
|
+
next = "Check your storage provider's service status, then retry Check setup before syncing.";
|
|
31
|
+
} else if (
|
|
32
|
+
/timed out|timeout|request failed|resolve host|Could not resolve|ENOTFOUND|ECONNREFUSED/iu.test(
|
|
33
|
+
message,
|
|
34
|
+
)
|
|
35
|
+
) {
|
|
36
|
+
next =
|
|
37
|
+
"Check the server address and network connection, then run /sync doctor for the affected setup.";
|
|
38
|
+
} else if (/strong ETag|If-Match|If-None-Match|precondition|conditional header/iu.test(message)) {
|
|
39
|
+
next =
|
|
40
|
+
"Ask your provider to check strong ETags and conditional writes, including proxy behavior, or choose compatible storage. Do not bypass these safety checks.";
|
|
41
|
+
} else if (/spawn git ENOENT/iu.test(message)) {
|
|
42
|
+
next = "Install Git 2.30 or newer and make sure Pi can find git on PATH, then retry.";
|
|
43
|
+
} else if (/response exceeds.*byte limit/iu.test(message)) {
|
|
44
|
+
next =
|
|
45
|
+
"Verify the storage endpoint; the server returned an unexpectedly large response. Check your provider's logs before retrying.";
|
|
46
|
+
} else if (/settings|config|already exists|duplicates|same normalized/iu.test(message)) {
|
|
47
|
+
next =
|
|
48
|
+
"Correct the named field or item in /sync. If the settings file is invalid, repair it before retrying; do not replace it with defaults.";
|
|
49
|
+
} else {
|
|
50
|
+
next = "Open /sync → More → Check setup and review the reported checks before retrying.";
|
|
51
|
+
}
|
|
52
|
+
return `${message}\n${next}`;
|
|
53
|
+
}
|