@ralphkrauss/codex-account-switcher 0.1.8 → 0.2.1
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/CHANGELOG.md +28 -0
- package/README.md +14 -5
- package/dist/accounts.d.ts +1 -0
- package/dist/accounts.js +12 -2
- package/dist/accounts.js.map +1 -1
- package/dist/cli.js +59 -21
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/remote.d.ts +35 -0
- package/dist/remote.js +303 -12
- package/dist/remote.js.map +1 -1
- package/docs/AGENT_SETUP.md +13 -9
- package/package.json +1 -1
- package/src/accounts.ts +13 -2
- package/src/cli.ts +68 -19
- package/src/index.ts +5 -0
- package/src/remote.ts +392 -12
package/src/remote.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
3
3
|
import { constants as fsConstants } from 'node:fs';
|
|
4
4
|
import {
|
|
5
5
|
access,
|
|
@@ -28,6 +28,8 @@ export const REMOTE_CONFIG_VERSION = 1;
|
|
|
28
28
|
export const DEFAULT_ONEPASSWORD_ITEM_PREFIX = 'cx-';
|
|
29
29
|
export const ONEPASSWORD_BACKEND = '1password';
|
|
30
30
|
export const ONEPASSWORD_AUTH_FIELD = 'auth_json';
|
|
31
|
+
export const REMOTE_METADATA_FIELD = 'cx_metadata';
|
|
32
|
+
export const LOCAL_SYNC_METADATA_VERSION = 1;
|
|
31
33
|
|
|
32
34
|
export type RemoteBackend = typeof ONEPASSWORD_BACKEND;
|
|
33
35
|
export type RemotePresence = 'present' | 'missing' | 'unknown';
|
|
@@ -91,6 +93,8 @@ export interface SyncPullResult {
|
|
|
91
93
|
readonly overwritten: boolean;
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
export type SyncState = 'in-sync' | 'local-newer' | 'remote-newer' | 'diverged' | 'unknown';
|
|
97
|
+
|
|
94
98
|
export interface SyncStatusAccount {
|
|
95
99
|
readonly account: string;
|
|
96
100
|
readonly item: string | null;
|
|
@@ -102,6 +106,38 @@ export interface SyncStatusAccount {
|
|
|
102
106
|
readonly presence: RemotePresence;
|
|
103
107
|
readonly error: string | null;
|
|
104
108
|
};
|
|
109
|
+
readonly sync: {
|
|
110
|
+
readonly state: SyncState;
|
|
111
|
+
readonly error: string | null;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface RemoteAuthMetadata {
|
|
116
|
+
readonly version: 1;
|
|
117
|
+
readonly account: string;
|
|
118
|
+
readonly authJsonSha256: string;
|
|
119
|
+
readonly updatedAt: string;
|
|
120
|
+
readonly deviceId?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface LocalSyncMetadata {
|
|
124
|
+
readonly version: typeof LOCAL_SYNC_METADATA_VERSION;
|
|
125
|
+
readonly backend: RemoteBackend;
|
|
126
|
+
readonly account: string;
|
|
127
|
+
readonly vault?: string;
|
|
128
|
+
readonly item?: string;
|
|
129
|
+
readonly remoteAuthJsonSha256: string;
|
|
130
|
+
readonly lastSyncedAuthJsonSha256: string;
|
|
131
|
+
readonly lastSyncedAt: string;
|
|
132
|
+
readonly deviceId: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface AutoSyncResult {
|
|
136
|
+
readonly action: 'pulled' | 'pushed' | 'skipped';
|
|
137
|
+
readonly account: string;
|
|
138
|
+
readonly reason?: string;
|
|
139
|
+
readonly item?: string;
|
|
140
|
+
readonly backend?: RemoteBackend;
|
|
105
141
|
}
|
|
106
142
|
|
|
107
143
|
export interface SyncStatus {
|
|
@@ -211,6 +247,98 @@ async function writeFilePrivate(destination: string, contents: string): Promise<
|
|
|
211
247
|
}
|
|
212
248
|
}
|
|
213
249
|
|
|
250
|
+
function sha256Hex(value: string): string {
|
|
251
|
+
return createHash('sha256').update(value).digest('hex');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function metadataAssignment(metadata: RemoteAuthMetadata): string {
|
|
255
|
+
return `${REMOTE_METADATA_FIELD}=${JSON.stringify(metadata)}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function buildRemoteAuthMetadata(account: string, authJson: string): RemoteAuthMetadata {
|
|
259
|
+
return {
|
|
260
|
+
version: 1,
|
|
261
|
+
account,
|
|
262
|
+
authJsonSha256: sha256Hex(authJson),
|
|
263
|
+
updatedAt: new Date().toISOString(),
|
|
264
|
+
deviceId: getLocalDeviceId(),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function getLocalDeviceId(): string {
|
|
269
|
+
const key = 'CX_DEVICE_ID';
|
|
270
|
+
const existing = process.env[key];
|
|
271
|
+
if (existing && existing.trim().length > 0) {
|
|
272
|
+
return existing.trim();
|
|
273
|
+
}
|
|
274
|
+
return randomUUID();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function getLocalSyncMetadataPath(paths: CodexPaths, account: string): string {
|
|
278
|
+
return join(paths.accountsDir, '.sync', `${validateAccountName(account)}.json`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function readLocalSyncMetadata(paths: CodexPaths, account: string): Promise<LocalSyncMetadata | null> {
|
|
282
|
+
let raw: string;
|
|
283
|
+
try {
|
|
284
|
+
raw = await readFile(getLocalSyncMetadataPath(paths, account), 'utf8');
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (isNotFoundError(error)) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
294
|
+
if (!isRecord(parsed)
|
|
295
|
+
|| parsed.version !== LOCAL_SYNC_METADATA_VERSION
|
|
296
|
+
|| parsed.backend !== ONEPASSWORD_BACKEND
|
|
297
|
+
|| parsed.account !== account
|
|
298
|
+
|| typeof parsed.remoteAuthJsonSha256 !== 'string'
|
|
299
|
+
|| typeof parsed.lastSyncedAuthJsonSha256 !== 'string'
|
|
300
|
+
|| typeof parsed.lastSyncedAt !== 'string'
|
|
301
|
+
|| typeof parsed.deviceId !== 'string') {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
version: LOCAL_SYNC_METADATA_VERSION,
|
|
306
|
+
backend: ONEPASSWORD_BACKEND,
|
|
307
|
+
account,
|
|
308
|
+
...(typeof parsed.vault === 'string' ? { vault: parsed.vault } : {}),
|
|
309
|
+
...(typeof parsed.item === 'string' ? { item: parsed.item } : {}),
|
|
310
|
+
remoteAuthJsonSha256: parsed.remoteAuthJsonSha256,
|
|
311
|
+
lastSyncedAuthJsonSha256: parsed.lastSyncedAuthJsonSha256,
|
|
312
|
+
lastSyncedAt: parsed.lastSyncedAt,
|
|
313
|
+
deviceId: parsed.deviceId,
|
|
314
|
+
};
|
|
315
|
+
} catch {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function writeLocalSyncMetadata(
|
|
321
|
+
paths: CodexPaths,
|
|
322
|
+
config: RemoteConfig,
|
|
323
|
+
account: string,
|
|
324
|
+
authJson: string,
|
|
325
|
+
remoteMetadata?: RemoteAuthMetadata | null,
|
|
326
|
+
): Promise<void> {
|
|
327
|
+
const hash = sha256Hex(authJson);
|
|
328
|
+
const metadata: LocalSyncMetadata = {
|
|
329
|
+
version: LOCAL_SYNC_METADATA_VERSION,
|
|
330
|
+
backend: config.backend,
|
|
331
|
+
account,
|
|
332
|
+
vault: config.vault,
|
|
333
|
+
item: itemTitle(config, account),
|
|
334
|
+
remoteAuthJsonSha256: remoteMetadata?.authJsonSha256 ?? hash,
|
|
335
|
+
lastSyncedAuthJsonSha256: hash,
|
|
336
|
+
lastSyncedAt: new Date().toISOString(),
|
|
337
|
+
deviceId: getLocalDeviceId(),
|
|
338
|
+
};
|
|
339
|
+
await writeFilePrivate(getLocalSyncMetadataPath(paths, account), `${JSON.stringify(metadata, null, 2)}\n`);
|
|
340
|
+
}
|
|
341
|
+
|
|
214
342
|
function remotePaths(options: RemotePathOptions & { readonly env?: NodeJS.ProcessEnv } = {}): CodexPaths {
|
|
215
343
|
return options.paths ?? getCodexPaths(options.env ?? process.env);
|
|
216
344
|
}
|
|
@@ -377,10 +505,11 @@ async function runOpRaw(
|
|
|
377
505
|
env: NodeJS.ProcessEnv,
|
|
378
506
|
): Promise<OpResult> {
|
|
379
507
|
const opPath = await resolveOp(env);
|
|
508
|
+
const shell = process.platform === 'win32' && /\.(?:cmd|bat)$/iu.test(opPath);
|
|
380
509
|
return await new Promise((resolvePromise, reject) => {
|
|
381
510
|
const child = spawn(opPath, [...args], {
|
|
382
511
|
env,
|
|
383
|
-
shell
|
|
512
|
+
shell,
|
|
384
513
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
385
514
|
windowsHide: true,
|
|
386
515
|
});
|
|
@@ -542,9 +671,11 @@ function authFieldAssignment(authJson: string): string {
|
|
|
542
671
|
async function upsertOnePasswordAuthJson(
|
|
543
672
|
config: RemoteConfig,
|
|
544
673
|
item: string,
|
|
674
|
+
account: string,
|
|
545
675
|
authJson: string,
|
|
546
676
|
env: NodeJS.ProcessEnv,
|
|
547
|
-
): Promise<'created' | 'updated'> {
|
|
677
|
+
): Promise<{ operation: 'created' | 'updated'; metadata: RemoteAuthMetadata }> {
|
|
678
|
+
const metadata = buildRemoteAuthMetadata(account, authJson);
|
|
548
679
|
if (await onePasswordItemExists(config, item, env)) {
|
|
549
680
|
await runOp([
|
|
550
681
|
'item',
|
|
@@ -553,8 +684,9 @@ async function upsertOnePasswordAuthJson(
|
|
|
553
684
|
'--vault',
|
|
554
685
|
config.vault,
|
|
555
686
|
authFieldAssignment(authJson),
|
|
687
|
+
metadataAssignment(metadata),
|
|
556
688
|
], env, `updating 1Password item '${item}'`, { sensitive: true });
|
|
557
|
-
return 'updated';
|
|
689
|
+
return { operation: 'updated', metadata };
|
|
558
690
|
}
|
|
559
691
|
|
|
560
692
|
await runOp([
|
|
@@ -567,8 +699,9 @@ async function upsertOnePasswordAuthJson(
|
|
|
567
699
|
'--title',
|
|
568
700
|
item,
|
|
569
701
|
authFieldAssignment(authJson),
|
|
702
|
+
metadataAssignment(metadata),
|
|
570
703
|
], env, `creating 1Password item '${item}'`, { sensitive: true });
|
|
571
|
-
return 'created';
|
|
704
|
+
return { operation: 'created', metadata };
|
|
572
705
|
}
|
|
573
706
|
|
|
574
707
|
function stripOneTrailingLineEnding(value: string): string {
|
|
@@ -600,7 +733,36 @@ function decodeAuthJsonField(stdout: string, item: string): string {
|
|
|
600
733
|
}
|
|
601
734
|
}
|
|
602
735
|
|
|
736
|
+
function parseRemoteAuthMetadata(value: unknown): RemoteAuthMetadata | null {
|
|
737
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
try {
|
|
741
|
+
const parsed = JSON.parse(value) as unknown;
|
|
742
|
+
if (!isRecord(parsed)
|
|
743
|
+
|| parsed.version !== 1
|
|
744
|
+
|| typeof parsed.account !== 'string'
|
|
745
|
+
|| typeof parsed.authJsonSha256 !== 'string'
|
|
746
|
+
|| typeof parsed.updatedAt !== 'string') {
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
return {
|
|
750
|
+
version: 1,
|
|
751
|
+
account: parsed.account,
|
|
752
|
+
authJsonSha256: parsed.authJsonSha256,
|
|
753
|
+
updatedAt: parsed.updatedAt,
|
|
754
|
+
...(typeof parsed.deviceId === 'string' ? { deviceId: parsed.deviceId } : {}),
|
|
755
|
+
};
|
|
756
|
+
} catch {
|
|
757
|
+
return null;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
603
761
|
function decodeAuthJsonFieldFromItemJson(stdout: string, item: string): string {
|
|
762
|
+
return decodeOnePasswordAccountFromItemJson(stdout, item).authJson;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function decodeOnePasswordAccountFromItemJson(stdout: string, item: string): { authJson: string; metadata: RemoteAuthMetadata | null } {
|
|
604
766
|
let parsed: unknown;
|
|
605
767
|
try {
|
|
606
768
|
parsed = JSON.parse(stdout) as unknown;
|
|
@@ -623,7 +785,14 @@ function decodeAuthJsonFieldFromItemJson(stdout: string, item: string): string {
|
|
|
623
785
|
}
|
|
624
786
|
|
|
625
787
|
parseAuthJsonString(field.value, `auth_json field in 1Password item '${item}'`);
|
|
626
|
-
|
|
788
|
+
|
|
789
|
+
const metadataField = parsed.fields.find((candidate: unknown): candidate is Record<string, unknown> => (
|
|
790
|
+
isRecord(candidate) && candidate.label === REMOTE_METADATA_FIELD
|
|
791
|
+
));
|
|
792
|
+
return {
|
|
793
|
+
authJson: field.value,
|
|
794
|
+
metadata: parseRemoteAuthMetadata(metadataField?.value),
|
|
795
|
+
};
|
|
627
796
|
}
|
|
628
797
|
|
|
629
798
|
async function readOnePasswordAuthJson(
|
|
@@ -631,6 +800,14 @@ async function readOnePasswordAuthJson(
|
|
|
631
800
|
item: string,
|
|
632
801
|
env: NodeJS.ProcessEnv,
|
|
633
802
|
): Promise<string> {
|
|
803
|
+
return (await readOnePasswordAccount(config, item, env)).authJson;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
async function readOnePasswordAccount(
|
|
807
|
+
config: RemoteConfig,
|
|
808
|
+
item: string,
|
|
809
|
+
env: NodeJS.ProcessEnv,
|
|
810
|
+
): Promise<{ authJson: string; metadata: RemoteAuthMetadata | null }> {
|
|
634
811
|
const jsonResult = await runOpRaw([
|
|
635
812
|
'item',
|
|
636
813
|
'get',
|
|
@@ -643,7 +820,7 @@ async function readOnePasswordAuthJson(
|
|
|
643
820
|
|
|
644
821
|
if (jsonResult.exitCode === 0) {
|
|
645
822
|
try {
|
|
646
|
-
return
|
|
823
|
+
return decodeOnePasswordAccountFromItemJson(jsonResult.stdout, item);
|
|
647
824
|
} catch (error) {
|
|
648
825
|
if (!errorMessage(error).includes(`'${ONEPASSWORD_AUTH_FIELD}'`)) {
|
|
649
826
|
throw error;
|
|
@@ -669,7 +846,7 @@ async function readOnePasswordAuthJson(
|
|
|
669
846
|
], env);
|
|
670
847
|
|
|
671
848
|
if (fieldResult.exitCode === 0) {
|
|
672
|
-
return decodeAuthJsonField(fieldResult.stdout, item);
|
|
849
|
+
return { authJson: decodeAuthJsonField(fieldResult.stdout, item), metadata: null };
|
|
673
850
|
}
|
|
674
851
|
if (looksLikeMissingField(fieldResult)) {
|
|
675
852
|
throw new CxError(`1Password item '${item}' does not contain a revealable '${ONEPASSWORD_AUTH_FIELD}' field`, 1);
|
|
@@ -740,7 +917,8 @@ export async function syncPushAccount(
|
|
|
740
917
|
await writebackCurrentAccountIfSyncTarget(safeAccount, paths);
|
|
741
918
|
const local = await readLocalAccountAuthJson(safeAccount, paths);
|
|
742
919
|
const item = itemTitle(config, local.account);
|
|
743
|
-
const
|
|
920
|
+
const upload = await upsertOnePasswordAuthJson(config, item, local.account, local.authJson, env);
|
|
921
|
+
await writeLocalSyncMetadata(paths, config, local.account, local.authJson, upload.metadata);
|
|
744
922
|
|
|
745
923
|
return {
|
|
746
924
|
account: local.account,
|
|
@@ -748,7 +926,7 @@ export async function syncPushAccount(
|
|
|
748
926
|
backend: config.backend,
|
|
749
927
|
vault: config.vault,
|
|
750
928
|
item,
|
|
751
|
-
operation,
|
|
929
|
+
operation: upload.operation,
|
|
752
930
|
};
|
|
753
931
|
}
|
|
754
932
|
|
|
@@ -761,8 +939,14 @@ export async function syncPullAccount(
|
|
|
761
939
|
const config = await requireRemoteConfig({ paths });
|
|
762
940
|
const safeAccount = validateRemoteSyncAccountName(account);
|
|
763
941
|
const item = itemTitle(config, safeAccount);
|
|
764
|
-
const
|
|
765
|
-
const
|
|
942
|
+
const remote = await readOnePasswordAccount(config, item, env);
|
|
943
|
+
const verifiedMetadata = remote.metadata
|
|
944
|
+
&& remote.metadata.account === safeAccount
|
|
945
|
+
&& remote.metadata.authJsonSha256 === sha256Hex(remote.authJson)
|
|
946
|
+
? remote.metadata
|
|
947
|
+
: null;
|
|
948
|
+
const local = await writeLocalAccountAuthJson(safeAccount, remote.authJson, paths, options.force === true);
|
|
949
|
+
await writeLocalSyncMetadata(paths, config, safeAccount, remote.authJson, verifiedMetadata);
|
|
766
950
|
|
|
767
951
|
return {
|
|
768
952
|
account: local.account,
|
|
@@ -805,6 +989,199 @@ export async function syncPushAllAccounts(options: RemoteCliOptions = {}): Promi
|
|
|
805
989
|
return results;
|
|
806
990
|
}
|
|
807
991
|
|
|
992
|
+
function autoSyncDisabled(env: NodeJS.ProcessEnv): boolean {
|
|
993
|
+
return env.CX_AUTO_SYNC === '0' || env.CX_MAGIC_SYNC === '0' || env.CX_NO_MAGIC_SYNC === '1';
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
async function readLocalAuthHash(account: string, paths: CodexPaths): Promise<string | null> {
|
|
997
|
+
try {
|
|
998
|
+
const raw = await readFile(accountPathForName(paths, account), 'utf8');
|
|
999
|
+
parseAuthJsonString(raw, `Codex account '${account}'`);
|
|
1000
|
+
return sha256Hex(raw);
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
if (isNotFoundError(error)) {
|
|
1003
|
+
return null;
|
|
1004
|
+
}
|
|
1005
|
+
throw error;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
async function readRemoteAccountMetadata(
|
|
1010
|
+
account: string,
|
|
1011
|
+
config: RemoteConfig,
|
|
1012
|
+
env: NodeJS.ProcessEnv,
|
|
1013
|
+
): Promise<{ metadata: RemoteAuthMetadata | null; authJson?: string }> {
|
|
1014
|
+
const item = itemTitle(config, account);
|
|
1015
|
+
const remote = await readOnePasswordAccount(config, item, env);
|
|
1016
|
+
const actualHash = sha256Hex(remote.authJson);
|
|
1017
|
+
const metadata = remote.metadata
|
|
1018
|
+
&& remote.metadata.account === account
|
|
1019
|
+
&& remote.metadata.authJsonSha256 === actualHash
|
|
1020
|
+
? remote.metadata
|
|
1021
|
+
: null;
|
|
1022
|
+
return { metadata, authJson: remote.authJson };
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function classifyHashes(
|
|
1026
|
+
localHash: string | null,
|
|
1027
|
+
localMetadata: LocalSyncMetadata | null,
|
|
1028
|
+
remoteMetadata: RemoteAuthMetadata | null,
|
|
1029
|
+
): SyncState {
|
|
1030
|
+
if (!remoteMetadata || !localHash || !localMetadata) {
|
|
1031
|
+
return 'unknown';
|
|
1032
|
+
}
|
|
1033
|
+
if (localHash === remoteMetadata.authJsonSha256) {
|
|
1034
|
+
return 'in-sync';
|
|
1035
|
+
}
|
|
1036
|
+
const localChangedSinceSync = localHash !== localMetadata.lastSyncedAuthJsonSha256;
|
|
1037
|
+
const remoteChangedSinceSync = remoteMetadata.authJsonSha256 !== localMetadata.remoteAuthJsonSha256
|
|
1038
|
+
|| remoteMetadata.authJsonSha256 !== localMetadata.lastSyncedAuthJsonSha256;
|
|
1039
|
+
if (!localChangedSinceSync && remoteChangedSinceSync) {
|
|
1040
|
+
return 'remote-newer';
|
|
1041
|
+
}
|
|
1042
|
+
if (localChangedSinceSync && !remoteChangedSinceSync) {
|
|
1043
|
+
return 'local-newer';
|
|
1044
|
+
}
|
|
1045
|
+
if (localChangedSinceSync && remoteChangedSinceSync) {
|
|
1046
|
+
return 'diverged';
|
|
1047
|
+
}
|
|
1048
|
+
return 'unknown';
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
async function inspectAccountSyncState(
|
|
1052
|
+
account: string,
|
|
1053
|
+
config: RemoteConfig | null,
|
|
1054
|
+
env: NodeJS.ProcessEnv,
|
|
1055
|
+
paths: CodexPaths,
|
|
1056
|
+
): Promise<{ state: SyncState; error: string | null }> {
|
|
1057
|
+
if (!config) {
|
|
1058
|
+
return { state: 'unknown', error: 'remote backend is not configured' };
|
|
1059
|
+
}
|
|
1060
|
+
try {
|
|
1061
|
+
const [localHash, localMetadata, remote] = await Promise.all([
|
|
1062
|
+
readLocalAuthHash(account, paths),
|
|
1063
|
+
readLocalSyncMetadata(paths, account),
|
|
1064
|
+
readRemoteAccountMetadata(account, config, env),
|
|
1065
|
+
]);
|
|
1066
|
+
return { state: classifyHashes(localHash, localMetadata, remote.metadata), error: null };
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
return { state: 'unknown', error: errorMessage(error) };
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
export async function autoPullAccountForUse(
|
|
1073
|
+
account: string,
|
|
1074
|
+
options: RemoteCliOptions = {},
|
|
1075
|
+
): Promise<AutoSyncResult> {
|
|
1076
|
+
const env = options.env ?? process.env;
|
|
1077
|
+
const paths = remotePaths(options);
|
|
1078
|
+
const safeAccount = validateAccountName(account);
|
|
1079
|
+
if (safeAccount === 'default') {
|
|
1080
|
+
return { action: 'skipped', account: safeAccount, reason: 'reserved default account' };
|
|
1081
|
+
}
|
|
1082
|
+
if (autoSyncDisabled(env)) {
|
|
1083
|
+
return { action: 'skipped', account: safeAccount, reason: 'auto sync disabled' };
|
|
1084
|
+
}
|
|
1085
|
+
const config = await readRemoteConfig({ paths });
|
|
1086
|
+
if (!config) {
|
|
1087
|
+
return { action: 'skipped', account: safeAccount, reason: 'remote backend is not configured' };
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const item = itemTitle(config, safeAccount);
|
|
1091
|
+
const accountFile = accountPathForName(paths, safeAccount);
|
|
1092
|
+
const localExists = await pathExists(accountFile);
|
|
1093
|
+
if (!localExists) {
|
|
1094
|
+
const pulled = await syncPullAccount(safeAccount, { env, paths });
|
|
1095
|
+
return { action: 'pulled', account: pulled.account, item: pulled.item, backend: pulled.backend, reason: 'local account missing' };
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const localHash = await readLocalAuthHash(safeAccount, paths);
|
|
1099
|
+
const localMetadata = await readLocalSyncMetadata(paths, safeAccount);
|
|
1100
|
+
const remote = await readRemoteAccountMetadata(safeAccount, config, env).catch((error: unknown) => ({
|
|
1101
|
+
metadata: null,
|
|
1102
|
+
error: errorMessage(error),
|
|
1103
|
+
}));
|
|
1104
|
+
if ('error' in remote) {
|
|
1105
|
+
return { action: 'skipped', account: safeAccount, item, backend: config.backend, reason: `remote unavailable: ${remote.error}` };
|
|
1106
|
+
}
|
|
1107
|
+
const state = classifyHashes(localHash, localMetadata, remote.metadata);
|
|
1108
|
+
if (state === 'remote-newer') {
|
|
1109
|
+
const pulled = await syncPullAccount(safeAccount, { env, paths, force: true });
|
|
1110
|
+
return { action: 'pulled', account: pulled.account, item: pulled.item, backend: pulled.backend, reason: 'remote newer' };
|
|
1111
|
+
}
|
|
1112
|
+
if (state === 'diverged') {
|
|
1113
|
+
throw new CxError(`sync conflict for '${safeAccount}': local and remote credentials diverged; use 'cx sync status ${safeAccount}', then resolve with explicit 'cx sync pull ${safeAccount} --force' or 'cx sync push ${safeAccount}'`, 1);
|
|
1114
|
+
}
|
|
1115
|
+
return { action: 'skipped', account: safeAccount, item, backend: config.backend, reason: state };
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
export async function autoPushAccountIfChanged(
|
|
1119
|
+
account: string,
|
|
1120
|
+
options: RemoteCliOptions = {},
|
|
1121
|
+
): Promise<AutoSyncResult> {
|
|
1122
|
+
const env = options.env ?? process.env;
|
|
1123
|
+
const paths = remotePaths(options);
|
|
1124
|
+
const safeAccount = validateAccountName(account);
|
|
1125
|
+
if (safeAccount === 'default') {
|
|
1126
|
+
return { action: 'skipped', account: safeAccount, reason: 'reserved default account' };
|
|
1127
|
+
}
|
|
1128
|
+
if (autoSyncDisabled(env)) {
|
|
1129
|
+
return { action: 'skipped', account: safeAccount, reason: 'auto sync disabled' };
|
|
1130
|
+
}
|
|
1131
|
+
const config = await readRemoteConfig({ paths });
|
|
1132
|
+
if (!config) {
|
|
1133
|
+
return { action: 'skipped', account: safeAccount, reason: 'remote backend is not configured' };
|
|
1134
|
+
}
|
|
1135
|
+
const localHash = await readLocalAuthHash(safeAccount, paths);
|
|
1136
|
+
if (!localHash) {
|
|
1137
|
+
return { action: 'skipped', account: safeAccount, reason: 'local account missing' };
|
|
1138
|
+
}
|
|
1139
|
+
const localMetadata = await readLocalSyncMetadata(paths, safeAccount);
|
|
1140
|
+
const remote = await readRemoteAccountMetadata(safeAccount, config, env).then(
|
|
1141
|
+
(value) => ({ ...value, missing: false, unavailable: null as string | null }),
|
|
1142
|
+
(error: unknown) => {
|
|
1143
|
+
const message = errorMessage(error);
|
|
1144
|
+
if (message.includes('was not found')) {
|
|
1145
|
+
return { metadata: null, missing: true, unavailable: null as string | null };
|
|
1146
|
+
}
|
|
1147
|
+
return { metadata: null, missing: false, unavailable: message };
|
|
1148
|
+
},
|
|
1149
|
+
);
|
|
1150
|
+
if (remote.unavailable) {
|
|
1151
|
+
return { action: 'skipped', account: safeAccount, item: itemTitle(config, safeAccount), backend: config.backend, reason: `remote unavailable: ${remote.unavailable}` };
|
|
1152
|
+
}
|
|
1153
|
+
if (remote.missing) {
|
|
1154
|
+
if (localMetadata) {
|
|
1155
|
+
return { action: 'skipped', account: safeAccount, item: itemTitle(config, safeAccount), backend: config.backend, reason: 'remote missing after previous sync' };
|
|
1156
|
+
}
|
|
1157
|
+
const pushed = await syncPushAccount(safeAccount, { env, paths });
|
|
1158
|
+
return { action: 'pushed', account: pushed.account, item: pushed.item, backend: pushed.backend, reason: 'remote missing' };
|
|
1159
|
+
}
|
|
1160
|
+
const state = classifyHashes(localHash, localMetadata, remote.metadata);
|
|
1161
|
+
if (state === 'in-sync') {
|
|
1162
|
+
return { action: 'skipped', account: safeAccount, item: itemTitle(config, safeAccount), backend: config.backend, reason: 'in-sync' };
|
|
1163
|
+
}
|
|
1164
|
+
if (state === 'diverged') {
|
|
1165
|
+
throw new CxError(`sync conflict for '${safeAccount}': local and remote credentials diverged; use 'cx sync status ${safeAccount}', then resolve with explicit 'cx sync pull ${safeAccount} --force' or 'cx sync push ${safeAccount}'`, 1);
|
|
1166
|
+
}
|
|
1167
|
+
if (state !== 'local-newer') {
|
|
1168
|
+
return { action: 'skipped', account: safeAccount, item: itemTitle(config, safeAccount), backend: config.backend, reason: state };
|
|
1169
|
+
}
|
|
1170
|
+
const pushed = await syncPushAccount(safeAccount, { env, paths });
|
|
1171
|
+
return { action: 'pushed', account: pushed.account, item: pushed.item, backend: pushed.backend, reason: state };
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
export async function writebackAndAutoPushCurrentAccount(
|
|
1175
|
+
options: RemoteCliOptions = {},
|
|
1176
|
+
): Promise<AutoSyncResult | null> {
|
|
1177
|
+
const paths = remotePaths(options);
|
|
1178
|
+
const writeback = await writebackCurrentAccount({ paths });
|
|
1179
|
+
if (!writeback.performed || !writeback.account) {
|
|
1180
|
+
return null;
|
|
1181
|
+
}
|
|
1182
|
+
return await autoPushAccountIfChanged(writeback.account, options);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
808
1185
|
export async function setupOnePasswordProfiles(
|
|
809
1186
|
input: SetupOnePasswordProfilesInput,
|
|
810
1187
|
options: RemoteCliOptions = {},
|
|
@@ -878,6 +1255,8 @@ export async function inspectSyncStatus(
|
|
|
878
1255
|
}
|
|
879
1256
|
}
|
|
880
1257
|
|
|
1258
|
+
const sync = await inspectAccountSyncState(accountName, config, env, paths);
|
|
1259
|
+
|
|
881
1260
|
statuses.push({
|
|
882
1261
|
account: accountName,
|
|
883
1262
|
item: remoteItem,
|
|
@@ -889,6 +1268,7 @@ export async function inspectSyncStatus(
|
|
|
889
1268
|
presence,
|
|
890
1269
|
error: remoteError,
|
|
891
1270
|
},
|
|
1271
|
+
sync,
|
|
892
1272
|
});
|
|
893
1273
|
}
|
|
894
1274
|
|