@ralphkrauss/codex-account-switcher 0.1.7 → 0.2.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/CHANGELOG.md +29 -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 +310 -16
- package/dist/remote.js.map +1 -1
- package/docs/AGENT_SETUP.md +34 -7
- 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 +403 -15
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,
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
accountPathForName,
|
|
17
17
|
getCodexPaths,
|
|
18
18
|
listAccountNames,
|
|
19
|
+
readCurrentMarker,
|
|
19
20
|
resolveExecutable,
|
|
20
21
|
useAccount,
|
|
21
22
|
validateAccountName,
|
|
@@ -27,6 +28,8 @@ export const REMOTE_CONFIG_VERSION = 1;
|
|
|
27
28
|
export const DEFAULT_ONEPASSWORD_ITEM_PREFIX = 'cx-';
|
|
28
29
|
export const ONEPASSWORD_BACKEND = '1password';
|
|
29
30
|
export const ONEPASSWORD_AUTH_FIELD = 'auth_json';
|
|
31
|
+
export const REMOTE_METADATA_FIELD = 'cx_metadata';
|
|
32
|
+
export const LOCAL_SYNC_METADATA_VERSION = 1;
|
|
30
33
|
|
|
31
34
|
export type RemoteBackend = typeof ONEPASSWORD_BACKEND;
|
|
32
35
|
export type RemotePresence = 'present' | 'missing' | 'unknown';
|
|
@@ -90,6 +93,8 @@ export interface SyncPullResult {
|
|
|
90
93
|
readonly overwritten: boolean;
|
|
91
94
|
}
|
|
92
95
|
|
|
96
|
+
export type SyncState = 'in-sync' | 'local-newer' | 'remote-newer' | 'diverged' | 'unknown';
|
|
97
|
+
|
|
93
98
|
export interface SyncStatusAccount {
|
|
94
99
|
readonly account: string;
|
|
95
100
|
readonly item: string | null;
|
|
@@ -101,6 +106,38 @@ export interface SyncStatusAccount {
|
|
|
101
106
|
readonly presence: RemotePresence;
|
|
102
107
|
readonly error: string | null;
|
|
103
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;
|
|
104
141
|
}
|
|
105
142
|
|
|
106
143
|
export interface SyncStatus {
|
|
@@ -210,6 +247,98 @@ async function writeFilePrivate(destination: string, contents: string): Promise<
|
|
|
210
247
|
}
|
|
211
248
|
}
|
|
212
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
|
+
|
|
213
342
|
function remotePaths(options: RemotePathOptions & { readonly env?: NodeJS.ProcessEnv } = {}): CodexPaths {
|
|
214
343
|
return options.paths ?? getCodexPaths(options.env ?? process.env);
|
|
215
344
|
}
|
|
@@ -541,9 +670,11 @@ function authFieldAssignment(authJson: string): string {
|
|
|
541
670
|
async function upsertOnePasswordAuthJson(
|
|
542
671
|
config: RemoteConfig,
|
|
543
672
|
item: string,
|
|
673
|
+
account: string,
|
|
544
674
|
authJson: string,
|
|
545
675
|
env: NodeJS.ProcessEnv,
|
|
546
|
-
): Promise<'created' | 'updated'> {
|
|
676
|
+
): Promise<{ operation: 'created' | 'updated'; metadata: RemoteAuthMetadata }> {
|
|
677
|
+
const metadata = buildRemoteAuthMetadata(account, authJson);
|
|
547
678
|
if (await onePasswordItemExists(config, item, env)) {
|
|
548
679
|
await runOp([
|
|
549
680
|
'item',
|
|
@@ -552,8 +683,9 @@ async function upsertOnePasswordAuthJson(
|
|
|
552
683
|
'--vault',
|
|
553
684
|
config.vault,
|
|
554
685
|
authFieldAssignment(authJson),
|
|
686
|
+
metadataAssignment(metadata),
|
|
555
687
|
], env, `updating 1Password item '${item}'`, { sensitive: true });
|
|
556
|
-
return 'updated';
|
|
688
|
+
return { operation: 'updated', metadata };
|
|
557
689
|
}
|
|
558
690
|
|
|
559
691
|
await runOp([
|
|
@@ -566,8 +698,9 @@ async function upsertOnePasswordAuthJson(
|
|
|
566
698
|
'--title',
|
|
567
699
|
item,
|
|
568
700
|
authFieldAssignment(authJson),
|
|
701
|
+
metadataAssignment(metadata),
|
|
569
702
|
], env, `creating 1Password item '${item}'`, { sensitive: true });
|
|
570
|
-
return 'created';
|
|
703
|
+
return { operation: 'created', metadata };
|
|
571
704
|
}
|
|
572
705
|
|
|
573
706
|
function stripOneTrailingLineEnding(value: string): string {
|
|
@@ -599,7 +732,36 @@ function decodeAuthJsonField(stdout: string, item: string): string {
|
|
|
599
732
|
}
|
|
600
733
|
}
|
|
601
734
|
|
|
735
|
+
function parseRemoteAuthMetadata(value: unknown): RemoteAuthMetadata | null {
|
|
736
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
try {
|
|
740
|
+
const parsed = JSON.parse(value) as unknown;
|
|
741
|
+
if (!isRecord(parsed)
|
|
742
|
+
|| parsed.version !== 1
|
|
743
|
+
|| typeof parsed.account !== 'string'
|
|
744
|
+
|| typeof parsed.authJsonSha256 !== 'string'
|
|
745
|
+
|| typeof parsed.updatedAt !== 'string') {
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
return {
|
|
749
|
+
version: 1,
|
|
750
|
+
account: parsed.account,
|
|
751
|
+
authJsonSha256: parsed.authJsonSha256,
|
|
752
|
+
updatedAt: parsed.updatedAt,
|
|
753
|
+
...(typeof parsed.deviceId === 'string' ? { deviceId: parsed.deviceId } : {}),
|
|
754
|
+
};
|
|
755
|
+
} catch {
|
|
756
|
+
return null;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
602
760
|
function decodeAuthJsonFieldFromItemJson(stdout: string, item: string): string {
|
|
761
|
+
return decodeOnePasswordAccountFromItemJson(stdout, item).authJson;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function decodeOnePasswordAccountFromItemJson(stdout: string, item: string): { authJson: string; metadata: RemoteAuthMetadata | null } {
|
|
603
765
|
let parsed: unknown;
|
|
604
766
|
try {
|
|
605
767
|
parsed = JSON.parse(stdout) as unknown;
|
|
@@ -622,7 +784,14 @@ function decodeAuthJsonFieldFromItemJson(stdout: string, item: string): string {
|
|
|
622
784
|
}
|
|
623
785
|
|
|
624
786
|
parseAuthJsonString(field.value, `auth_json field in 1Password item '${item}'`);
|
|
625
|
-
|
|
787
|
+
|
|
788
|
+
const metadataField = parsed.fields.find((candidate: unknown): candidate is Record<string, unknown> => (
|
|
789
|
+
isRecord(candidate) && candidate.label === REMOTE_METADATA_FIELD
|
|
790
|
+
));
|
|
791
|
+
return {
|
|
792
|
+
authJson: field.value,
|
|
793
|
+
metadata: parseRemoteAuthMetadata(metadataField?.value),
|
|
794
|
+
};
|
|
626
795
|
}
|
|
627
796
|
|
|
628
797
|
async function readOnePasswordAuthJson(
|
|
@@ -630,6 +799,14 @@ async function readOnePasswordAuthJson(
|
|
|
630
799
|
item: string,
|
|
631
800
|
env: NodeJS.ProcessEnv,
|
|
632
801
|
): Promise<string> {
|
|
802
|
+
return (await readOnePasswordAccount(config, item, env)).authJson;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
async function readOnePasswordAccount(
|
|
806
|
+
config: RemoteConfig,
|
|
807
|
+
item: string,
|
|
808
|
+
env: NodeJS.ProcessEnv,
|
|
809
|
+
): Promise<{ authJson: string; metadata: RemoteAuthMetadata | null }> {
|
|
633
810
|
const jsonResult = await runOpRaw([
|
|
634
811
|
'item',
|
|
635
812
|
'get',
|
|
@@ -642,7 +819,7 @@ async function readOnePasswordAuthJson(
|
|
|
642
819
|
|
|
643
820
|
if (jsonResult.exitCode === 0) {
|
|
644
821
|
try {
|
|
645
|
-
return
|
|
822
|
+
return decodeOnePasswordAccountFromItemJson(jsonResult.stdout, item);
|
|
646
823
|
} catch (error) {
|
|
647
824
|
if (!errorMessage(error).includes(`'${ONEPASSWORD_AUTH_FIELD}'`)) {
|
|
648
825
|
throw error;
|
|
@@ -668,7 +845,7 @@ async function readOnePasswordAuthJson(
|
|
|
668
845
|
], env);
|
|
669
846
|
|
|
670
847
|
if (fieldResult.exitCode === 0) {
|
|
671
|
-
return decodeAuthJsonField(fieldResult.stdout, item);
|
|
848
|
+
return { authJson: decodeAuthJsonField(fieldResult.stdout, item), metadata: null };
|
|
672
849
|
}
|
|
673
850
|
if (looksLikeMissingField(fieldResult)) {
|
|
674
851
|
throw new CxError(`1Password item '${item}' does not contain a revealable '${ONEPASSWORD_AUTH_FIELD}' field`, 1);
|
|
@@ -699,6 +876,17 @@ async function readLocalAccountAuthJson(
|
|
|
699
876
|
return { account: safeAccount, accountFile, authJson };
|
|
700
877
|
}
|
|
701
878
|
|
|
879
|
+
async function writebackCurrentAccountIfSyncTarget(
|
|
880
|
+
targetAccount: string,
|
|
881
|
+
paths: CodexPaths,
|
|
882
|
+
): Promise<void> {
|
|
883
|
+
const current = await readCurrentMarker(paths);
|
|
884
|
+
if (current.state === 'valid' && current.name !== targetAccount) {
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
await writebackCurrentAccount({ paths });
|
|
888
|
+
}
|
|
889
|
+
|
|
702
890
|
async function writeLocalAccountAuthJson(
|
|
703
891
|
account: string,
|
|
704
892
|
authJson: string,
|
|
@@ -725,13 +913,11 @@ export async function syncPushAccount(
|
|
|
725
913
|
const paths = remotePaths(options);
|
|
726
914
|
const config = await requireRemoteConfig({ paths });
|
|
727
915
|
const safeAccount = validateRemoteSyncAccountName(account);
|
|
728
|
-
|
|
729
|
-
if (writeback.performed === true && writeback.account !== safeAccount) {
|
|
730
|
-
throw new CxError(`unexpected writeback account '${writeback.account}' while syncing '${safeAccount}'`, 1);
|
|
731
|
-
}
|
|
916
|
+
await writebackCurrentAccountIfSyncTarget(safeAccount, paths);
|
|
732
917
|
const local = await readLocalAccountAuthJson(safeAccount, paths);
|
|
733
918
|
const item = itemTitle(config, local.account);
|
|
734
|
-
const
|
|
919
|
+
const upload = await upsertOnePasswordAuthJson(config, item, local.account, local.authJson, env);
|
|
920
|
+
await writeLocalSyncMetadata(paths, config, local.account, local.authJson, upload.metadata);
|
|
735
921
|
|
|
736
922
|
return {
|
|
737
923
|
account: local.account,
|
|
@@ -739,7 +925,7 @@ export async function syncPushAccount(
|
|
|
739
925
|
backend: config.backend,
|
|
740
926
|
vault: config.vault,
|
|
741
927
|
item,
|
|
742
|
-
operation,
|
|
928
|
+
operation: upload.operation,
|
|
743
929
|
};
|
|
744
930
|
}
|
|
745
931
|
|
|
@@ -752,8 +938,14 @@ export async function syncPullAccount(
|
|
|
752
938
|
const config = await requireRemoteConfig({ paths });
|
|
753
939
|
const safeAccount = validateRemoteSyncAccountName(account);
|
|
754
940
|
const item = itemTitle(config, safeAccount);
|
|
755
|
-
const
|
|
756
|
-
const
|
|
941
|
+
const remote = await readOnePasswordAccount(config, item, env);
|
|
942
|
+
const verifiedMetadata = remote.metadata
|
|
943
|
+
&& remote.metadata.account === safeAccount
|
|
944
|
+
&& remote.metadata.authJsonSha256 === sha256Hex(remote.authJson)
|
|
945
|
+
? remote.metadata
|
|
946
|
+
: null;
|
|
947
|
+
const local = await writeLocalAccountAuthJson(safeAccount, remote.authJson, paths, options.force === true);
|
|
948
|
+
await writeLocalSyncMetadata(paths, config, safeAccount, remote.authJson, verifiedMetadata);
|
|
757
949
|
|
|
758
950
|
return {
|
|
759
951
|
account: local.account,
|
|
@@ -796,6 +988,199 @@ export async function syncPushAllAccounts(options: RemoteCliOptions = {}): Promi
|
|
|
796
988
|
return results;
|
|
797
989
|
}
|
|
798
990
|
|
|
991
|
+
function autoSyncDisabled(env: NodeJS.ProcessEnv): boolean {
|
|
992
|
+
return env.CX_AUTO_SYNC === '0' || env.CX_MAGIC_SYNC === '0' || env.CX_NO_MAGIC_SYNC === '1';
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
async function readLocalAuthHash(account: string, paths: CodexPaths): Promise<string | null> {
|
|
996
|
+
try {
|
|
997
|
+
const raw = await readFile(accountPathForName(paths, account), 'utf8');
|
|
998
|
+
parseAuthJsonString(raw, `Codex account '${account}'`);
|
|
999
|
+
return sha256Hex(raw);
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
if (isNotFoundError(error)) {
|
|
1002
|
+
return null;
|
|
1003
|
+
}
|
|
1004
|
+
throw error;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
async function readRemoteAccountMetadata(
|
|
1009
|
+
account: string,
|
|
1010
|
+
config: RemoteConfig,
|
|
1011
|
+
env: NodeJS.ProcessEnv,
|
|
1012
|
+
): Promise<{ metadata: RemoteAuthMetadata | null; authJson?: string }> {
|
|
1013
|
+
const item = itemTitle(config, account);
|
|
1014
|
+
const remote = await readOnePasswordAccount(config, item, env);
|
|
1015
|
+
const actualHash = sha256Hex(remote.authJson);
|
|
1016
|
+
const metadata = remote.metadata
|
|
1017
|
+
&& remote.metadata.account === account
|
|
1018
|
+
&& remote.metadata.authJsonSha256 === actualHash
|
|
1019
|
+
? remote.metadata
|
|
1020
|
+
: null;
|
|
1021
|
+
return { metadata, authJson: remote.authJson };
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function classifyHashes(
|
|
1025
|
+
localHash: string | null,
|
|
1026
|
+
localMetadata: LocalSyncMetadata | null,
|
|
1027
|
+
remoteMetadata: RemoteAuthMetadata | null,
|
|
1028
|
+
): SyncState {
|
|
1029
|
+
if (!remoteMetadata || !localHash || !localMetadata) {
|
|
1030
|
+
return 'unknown';
|
|
1031
|
+
}
|
|
1032
|
+
if (localHash === remoteMetadata.authJsonSha256) {
|
|
1033
|
+
return 'in-sync';
|
|
1034
|
+
}
|
|
1035
|
+
const localChangedSinceSync = localHash !== localMetadata.lastSyncedAuthJsonSha256;
|
|
1036
|
+
const remoteChangedSinceSync = remoteMetadata.authJsonSha256 !== localMetadata.remoteAuthJsonSha256
|
|
1037
|
+
|| remoteMetadata.authJsonSha256 !== localMetadata.lastSyncedAuthJsonSha256;
|
|
1038
|
+
if (!localChangedSinceSync && remoteChangedSinceSync) {
|
|
1039
|
+
return 'remote-newer';
|
|
1040
|
+
}
|
|
1041
|
+
if (localChangedSinceSync && !remoteChangedSinceSync) {
|
|
1042
|
+
return 'local-newer';
|
|
1043
|
+
}
|
|
1044
|
+
if (localChangedSinceSync && remoteChangedSinceSync) {
|
|
1045
|
+
return 'diverged';
|
|
1046
|
+
}
|
|
1047
|
+
return 'unknown';
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
async function inspectAccountSyncState(
|
|
1051
|
+
account: string,
|
|
1052
|
+
config: RemoteConfig | null,
|
|
1053
|
+
env: NodeJS.ProcessEnv,
|
|
1054
|
+
paths: CodexPaths,
|
|
1055
|
+
): Promise<{ state: SyncState; error: string | null }> {
|
|
1056
|
+
if (!config) {
|
|
1057
|
+
return { state: 'unknown', error: 'remote backend is not configured' };
|
|
1058
|
+
}
|
|
1059
|
+
try {
|
|
1060
|
+
const [localHash, localMetadata, remote] = await Promise.all([
|
|
1061
|
+
readLocalAuthHash(account, paths),
|
|
1062
|
+
readLocalSyncMetadata(paths, account),
|
|
1063
|
+
readRemoteAccountMetadata(account, config, env),
|
|
1064
|
+
]);
|
|
1065
|
+
return { state: classifyHashes(localHash, localMetadata, remote.metadata), error: null };
|
|
1066
|
+
} catch (error) {
|
|
1067
|
+
return { state: 'unknown', error: errorMessage(error) };
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
export async function autoPullAccountForUse(
|
|
1072
|
+
account: string,
|
|
1073
|
+
options: RemoteCliOptions = {},
|
|
1074
|
+
): Promise<AutoSyncResult> {
|
|
1075
|
+
const env = options.env ?? process.env;
|
|
1076
|
+
const paths = remotePaths(options);
|
|
1077
|
+
const safeAccount = validateAccountName(account);
|
|
1078
|
+
if (safeAccount === 'default') {
|
|
1079
|
+
return { action: 'skipped', account: safeAccount, reason: 'reserved default account' };
|
|
1080
|
+
}
|
|
1081
|
+
if (autoSyncDisabled(env)) {
|
|
1082
|
+
return { action: 'skipped', account: safeAccount, reason: 'auto sync disabled' };
|
|
1083
|
+
}
|
|
1084
|
+
const config = await readRemoteConfig({ paths });
|
|
1085
|
+
if (!config) {
|
|
1086
|
+
return { action: 'skipped', account: safeAccount, reason: 'remote backend is not configured' };
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
const item = itemTitle(config, safeAccount);
|
|
1090
|
+
const accountFile = accountPathForName(paths, safeAccount);
|
|
1091
|
+
const localExists = await pathExists(accountFile);
|
|
1092
|
+
if (!localExists) {
|
|
1093
|
+
const pulled = await syncPullAccount(safeAccount, { env, paths });
|
|
1094
|
+
return { action: 'pulled', account: pulled.account, item: pulled.item, backend: pulled.backend, reason: 'local account missing' };
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
const localHash = await readLocalAuthHash(safeAccount, paths);
|
|
1098
|
+
const localMetadata = await readLocalSyncMetadata(paths, safeAccount);
|
|
1099
|
+
const remote = await readRemoteAccountMetadata(safeAccount, config, env).catch((error: unknown) => ({
|
|
1100
|
+
metadata: null,
|
|
1101
|
+
error: errorMessage(error),
|
|
1102
|
+
}));
|
|
1103
|
+
if ('error' in remote) {
|
|
1104
|
+
return { action: 'skipped', account: safeAccount, item, backend: config.backend, reason: `remote unavailable: ${remote.error}` };
|
|
1105
|
+
}
|
|
1106
|
+
const state = classifyHashes(localHash, localMetadata, remote.metadata);
|
|
1107
|
+
if (state === 'remote-newer') {
|
|
1108
|
+
const pulled = await syncPullAccount(safeAccount, { env, paths, force: true });
|
|
1109
|
+
return { action: 'pulled', account: pulled.account, item: pulled.item, backend: pulled.backend, reason: 'remote newer' };
|
|
1110
|
+
}
|
|
1111
|
+
if (state === 'diverged') {
|
|
1112
|
+
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);
|
|
1113
|
+
}
|
|
1114
|
+
return { action: 'skipped', account: safeAccount, item, backend: config.backend, reason: state };
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
export async function autoPushAccountIfChanged(
|
|
1118
|
+
account: string,
|
|
1119
|
+
options: RemoteCliOptions = {},
|
|
1120
|
+
): Promise<AutoSyncResult> {
|
|
1121
|
+
const env = options.env ?? process.env;
|
|
1122
|
+
const paths = remotePaths(options);
|
|
1123
|
+
const safeAccount = validateAccountName(account);
|
|
1124
|
+
if (safeAccount === 'default') {
|
|
1125
|
+
return { action: 'skipped', account: safeAccount, reason: 'reserved default account' };
|
|
1126
|
+
}
|
|
1127
|
+
if (autoSyncDisabled(env)) {
|
|
1128
|
+
return { action: 'skipped', account: safeAccount, reason: 'auto sync disabled' };
|
|
1129
|
+
}
|
|
1130
|
+
const config = await readRemoteConfig({ paths });
|
|
1131
|
+
if (!config) {
|
|
1132
|
+
return { action: 'skipped', account: safeAccount, reason: 'remote backend is not configured' };
|
|
1133
|
+
}
|
|
1134
|
+
const localHash = await readLocalAuthHash(safeAccount, paths);
|
|
1135
|
+
if (!localHash) {
|
|
1136
|
+
return { action: 'skipped', account: safeAccount, reason: 'local account missing' };
|
|
1137
|
+
}
|
|
1138
|
+
const localMetadata = await readLocalSyncMetadata(paths, safeAccount);
|
|
1139
|
+
const remote = await readRemoteAccountMetadata(safeAccount, config, env).then(
|
|
1140
|
+
(value) => ({ ...value, missing: false, unavailable: null as string | null }),
|
|
1141
|
+
(error: unknown) => {
|
|
1142
|
+
const message = errorMessage(error);
|
|
1143
|
+
if (message.includes('was not found')) {
|
|
1144
|
+
return { metadata: null, missing: true, unavailable: null as string | null };
|
|
1145
|
+
}
|
|
1146
|
+
return { metadata: null, missing: false, unavailable: message };
|
|
1147
|
+
},
|
|
1148
|
+
);
|
|
1149
|
+
if (remote.unavailable) {
|
|
1150
|
+
return { action: 'skipped', account: safeAccount, item: itemTitle(config, safeAccount), backend: config.backend, reason: `remote unavailable: ${remote.unavailable}` };
|
|
1151
|
+
}
|
|
1152
|
+
if (remote.missing) {
|
|
1153
|
+
if (localMetadata) {
|
|
1154
|
+
return { action: 'skipped', account: safeAccount, item: itemTitle(config, safeAccount), backend: config.backend, reason: 'remote missing after previous sync' };
|
|
1155
|
+
}
|
|
1156
|
+
const pushed = await syncPushAccount(safeAccount, { env, paths });
|
|
1157
|
+
return { action: 'pushed', account: pushed.account, item: pushed.item, backend: pushed.backend, reason: 'remote missing' };
|
|
1158
|
+
}
|
|
1159
|
+
const state = classifyHashes(localHash, localMetadata, remote.metadata);
|
|
1160
|
+
if (state === 'in-sync') {
|
|
1161
|
+
return { action: 'skipped', account: safeAccount, item: itemTitle(config, safeAccount), backend: config.backend, reason: 'in-sync' };
|
|
1162
|
+
}
|
|
1163
|
+
if (state === 'diverged') {
|
|
1164
|
+
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);
|
|
1165
|
+
}
|
|
1166
|
+
if (state !== 'local-newer') {
|
|
1167
|
+
return { action: 'skipped', account: safeAccount, item: itemTitle(config, safeAccount), backend: config.backend, reason: state };
|
|
1168
|
+
}
|
|
1169
|
+
const pushed = await syncPushAccount(safeAccount, { env, paths });
|
|
1170
|
+
return { action: 'pushed', account: pushed.account, item: pushed.item, backend: pushed.backend, reason: state };
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
export async function writebackAndAutoPushCurrentAccount(
|
|
1174
|
+
options: RemoteCliOptions = {},
|
|
1175
|
+
): Promise<AutoSyncResult | null> {
|
|
1176
|
+
const paths = remotePaths(options);
|
|
1177
|
+
const writeback = await writebackCurrentAccount({ paths });
|
|
1178
|
+
if (!writeback.performed || !writeback.account) {
|
|
1179
|
+
return null;
|
|
1180
|
+
}
|
|
1181
|
+
return await autoPushAccountIfChanged(writeback.account, options);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
799
1184
|
export async function setupOnePasswordProfiles(
|
|
800
1185
|
input: SetupOnePasswordProfilesInput,
|
|
801
1186
|
options: RemoteCliOptions = {},
|
|
@@ -869,6 +1254,8 @@ export async function inspectSyncStatus(
|
|
|
869
1254
|
}
|
|
870
1255
|
}
|
|
871
1256
|
|
|
1257
|
+
const sync = await inspectAccountSyncState(accountName, config, env, paths);
|
|
1258
|
+
|
|
872
1259
|
statuses.push({
|
|
873
1260
|
account: accountName,
|
|
874
1261
|
item: remoteItem,
|
|
@@ -880,6 +1267,7 @@ export async function inspectSyncStatus(
|
|
|
880
1267
|
presence,
|
|
881
1268
|
error: remoteError,
|
|
882
1269
|
},
|
|
1270
|
+
sync,
|
|
883
1271
|
});
|
|
884
1272
|
}
|
|
885
1273
|
|