signet-auth 1.0.0-beta.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.

Potentially problematic release.


This version of signet-auth might be problematic. Click here for more details.

Files changed (152) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +393 -0
  3. package/bin/sig.js +65 -0
  4. package/dist/auth-manager.d.ts +90 -0
  5. package/dist/auth-manager.js +262 -0
  6. package/dist/browser/adapters/playwright.adapter.d.ts +14 -0
  7. package/dist/browser/adapters/playwright.adapter.js +188 -0
  8. package/dist/browser/flows/form-login.flow.d.ts +6 -0
  9. package/dist/browser/flows/form-login.flow.js +35 -0
  10. package/dist/browser/flows/header-capture.d.ts +23 -0
  11. package/dist/browser/flows/header-capture.js +104 -0
  12. package/dist/browser/flows/hybrid-flow.d.ts +37 -0
  13. package/dist/browser/flows/hybrid-flow.js +104 -0
  14. package/dist/browser/flows/oauth-consent.flow.d.ts +20 -0
  15. package/dist/browser/flows/oauth-consent.flow.js +170 -0
  16. package/dist/cli/commands/doctor.d.ts +6 -0
  17. package/dist/cli/commands/doctor.js +263 -0
  18. package/dist/cli/commands/get.d.ts +2 -0
  19. package/dist/cli/commands/get.js +83 -0
  20. package/dist/cli/commands/init.d.ts +6 -0
  21. package/dist/cli/commands/init.js +244 -0
  22. package/dist/cli/commands/login.d.ts +2 -0
  23. package/dist/cli/commands/login.js +77 -0
  24. package/dist/cli/commands/logout.d.ts +2 -0
  25. package/dist/cli/commands/logout.js +11 -0
  26. package/dist/cli/commands/providers.d.ts +2 -0
  27. package/dist/cli/commands/providers.js +30 -0
  28. package/dist/cli/commands/remote.d.ts +1 -0
  29. package/dist/cli/commands/remote.js +67 -0
  30. package/dist/cli/commands/request.d.ts +2 -0
  31. package/dist/cli/commands/request.js +82 -0
  32. package/dist/cli/commands/status.d.ts +2 -0
  33. package/dist/cli/commands/status.js +41 -0
  34. package/dist/cli/commands/sync.d.ts +2 -0
  35. package/dist/cli/commands/sync.js +62 -0
  36. package/dist/cli/formatters.d.ts +3 -0
  37. package/dist/cli/formatters.js +25 -0
  38. package/dist/cli/main.d.ts +8 -0
  39. package/dist/cli/main.js +125 -0
  40. package/dist/config/generator.d.ts +24 -0
  41. package/dist/config/generator.js +97 -0
  42. package/dist/config/loader.d.ts +21 -0
  43. package/dist/config/loader.js +54 -0
  44. package/dist/config/schema.d.ts +44 -0
  45. package/dist/config/schema.js +8 -0
  46. package/dist/config/validator.d.ts +15 -0
  47. package/dist/config/validator.js +228 -0
  48. package/dist/core/errors.d.ts +57 -0
  49. package/dist/core/errors.js +107 -0
  50. package/dist/core/interfaces/auth-strategy.d.ts +48 -0
  51. package/dist/core/interfaces/auth-strategy.js +1 -0
  52. package/dist/core/interfaces/browser-adapter.d.ts +73 -0
  53. package/dist/core/interfaces/browser-adapter.js +1 -0
  54. package/dist/core/interfaces/provider.d.ts +15 -0
  55. package/dist/core/interfaces/provider.js +1 -0
  56. package/dist/core/interfaces/storage.d.ts +21 -0
  57. package/dist/core/interfaces/storage.js +1 -0
  58. package/dist/core/result.d.ts +21 -0
  59. package/dist/core/result.js +16 -0
  60. package/dist/core/types.d.ts +128 -0
  61. package/dist/core/types.js +6 -0
  62. package/dist/deps.d.ts +20 -0
  63. package/dist/deps.js +54 -0
  64. package/dist/index.d.ts +35 -0
  65. package/dist/index.js +37 -0
  66. package/dist/providers/auto-provision.d.ts +9 -0
  67. package/dist/providers/auto-provision.js +27 -0
  68. package/dist/providers/config-loader.d.ts +7 -0
  69. package/dist/providers/config-loader.js +7 -0
  70. package/dist/providers/provider-registry.d.ts +19 -0
  71. package/dist/providers/provider-registry.js +68 -0
  72. package/dist/storage/cached-storage.d.ts +24 -0
  73. package/dist/storage/cached-storage.js +57 -0
  74. package/dist/storage/directory-storage.d.ts +25 -0
  75. package/dist/storage/directory-storage.js +184 -0
  76. package/dist/storage/memory-storage.d.ts +14 -0
  77. package/dist/storage/memory-storage.js +27 -0
  78. package/dist/strategies/api-token.strategy.d.ts +6 -0
  79. package/dist/strategies/api-token.strategy.js +63 -0
  80. package/dist/strategies/basic-auth.strategy.d.ts +6 -0
  81. package/dist/strategies/basic-auth.strategy.js +41 -0
  82. package/dist/strategies/cookie.strategy.d.ts +6 -0
  83. package/dist/strategies/cookie.strategy.js +118 -0
  84. package/dist/strategies/oauth2.strategy.d.ts +6 -0
  85. package/dist/strategies/oauth2.strategy.js +134 -0
  86. package/dist/strategies/registry.d.ts +13 -0
  87. package/dist/strategies/registry.js +25 -0
  88. package/dist/sync/remote-config.d.ts +8 -0
  89. package/dist/sync/remote-config.js +49 -0
  90. package/dist/sync/sync-engine.d.ts +10 -0
  91. package/dist/sync/sync-engine.js +96 -0
  92. package/dist/sync/transports/ssh.d.ts +18 -0
  93. package/dist/sync/transports/ssh.js +115 -0
  94. package/dist/sync/types.d.ts +17 -0
  95. package/dist/sync/types.js +1 -0
  96. package/dist/utils/duration.d.ts +9 -0
  97. package/dist/utils/duration.js +34 -0
  98. package/dist/utils/http.d.ts +4 -0
  99. package/dist/utils/http.js +10 -0
  100. package/dist/utils/jwt.d.ts +15 -0
  101. package/dist/utils/jwt.js +30 -0
  102. package/package.json +56 -0
  103. package/src/auth-manager.ts +331 -0
  104. package/src/browser/adapters/playwright.adapter.ts +247 -0
  105. package/src/browser/flows/form-login.flow.ts +35 -0
  106. package/src/browser/flows/header-capture.ts +128 -0
  107. package/src/browser/flows/hybrid-flow.ts +165 -0
  108. package/src/browser/flows/oauth-consent.flow.ts +200 -0
  109. package/src/cli/commands/doctor.ts +301 -0
  110. package/src/cli/commands/get.ts +96 -0
  111. package/src/cli/commands/init.ts +289 -0
  112. package/src/cli/commands/login.ts +94 -0
  113. package/src/cli/commands/logout.ts +17 -0
  114. package/src/cli/commands/providers.ts +39 -0
  115. package/src/cli/commands/remote.ts +71 -0
  116. package/src/cli/commands/request.ts +97 -0
  117. package/src/cli/commands/status.ts +48 -0
  118. package/src/cli/commands/sync.ts +71 -0
  119. package/src/cli/formatters.ts +31 -0
  120. package/src/cli/main.ts +144 -0
  121. package/src/config/generator.ts +122 -0
  122. package/src/config/loader.ts +70 -0
  123. package/src/config/schema.ts +75 -0
  124. package/src/config/validator.ts +281 -0
  125. package/src/core/errors.ts +182 -0
  126. package/src/core/interfaces/auth-strategy.ts +65 -0
  127. package/src/core/interfaces/browser-adapter.ts +81 -0
  128. package/src/core/interfaces/provider.ts +19 -0
  129. package/src/core/interfaces/storage.ts +26 -0
  130. package/src/core/result.ts +24 -0
  131. package/src/core/types.ts +194 -0
  132. package/src/deps.ts +80 -0
  133. package/src/index.ts +109 -0
  134. package/src/providers/auto-provision.ts +30 -0
  135. package/src/providers/config-loader.ts +8 -0
  136. package/src/providers/provider-registry.ts +79 -0
  137. package/src/storage/cached-storage.ts +72 -0
  138. package/src/storage/directory-storage.ts +204 -0
  139. package/src/storage/memory-storage.ts +35 -0
  140. package/src/strategies/api-token.strategy.ts +87 -0
  141. package/src/strategies/basic-auth.strategy.ts +64 -0
  142. package/src/strategies/cookie.strategy.ts +153 -0
  143. package/src/strategies/oauth2.strategy.ts +178 -0
  144. package/src/strategies/registry.ts +34 -0
  145. package/src/sync/remote-config.ts +60 -0
  146. package/src/sync/sync-engine.ts +113 -0
  147. package/src/sync/transports/ssh.ts +130 -0
  148. package/src/sync/types.ts +15 -0
  149. package/src/utils/duration.ts +34 -0
  150. package/src/utils/http.ts +11 -0
  151. package/src/utils/jwt.ts +39 -0
  152. package/tsconfig.json +20 -0
@@ -0,0 +1,118 @@
1
+ import { ok, err } from '../core/result.js';
2
+ import { BrowserError } from '../core/errors.js';
3
+ import { parseDuration } from '../utils/duration.js';
4
+ import { runHybridFlow } from '../browser/flows/hybrid-flow.js';
5
+ import { isLoginPage } from '../browser/flows/form-login.flow.js';
6
+ import { hasOAuthTokens } from '../browser/flows/oauth-consent.flow.js';
7
+ const DEFAULT_TTL = '24h';
8
+ /**
9
+ * Cookie-based authentication strategy.
10
+ * Launches a browser, navigates to the login page, waits for user auth,
11
+ * then extracts cookies from the authenticated session.
12
+ */
13
+ class CookieStrategy {
14
+ ttlMs;
15
+ requiredCookies;
16
+ strategyConfig;
17
+ constructor(config) {
18
+ this.strategyConfig = config;
19
+ this.ttlMs = parseDuration(config.ttl ?? DEFAULT_TTL);
20
+ this.requiredCookies = config.requiredCookies ?? [];
21
+ }
22
+ validate(credential) {
23
+ if (credential.type !== 'cookie')
24
+ return ok(false);
25
+ // Check TTL based on obtainedAt
26
+ const obtainedAt = new Date(credential.obtainedAt).getTime();
27
+ if (Date.now() - obtainedAt > this.ttlMs) {
28
+ return ok(false);
29
+ }
30
+ // Check individual cookie expiry
31
+ const now = Date.now() / 1000;
32
+ const hasExpired = credential.cookies.some(c => c.expires > 0 && c.expires < now);
33
+ if (hasExpired)
34
+ return ok(false);
35
+ // Ensure we have at least one cookie
36
+ if (credential.cookies.length === 0)
37
+ return ok(false);
38
+ return ok(true);
39
+ }
40
+ async authenticate(provider, context) {
41
+ const adapter = context.browserAdapter;
42
+ if (!provider.entryUrl) {
43
+ return err(new BrowserError(`Provider "${provider.id}" requires an entryUrl for cookie authentication.`, provider.id));
44
+ }
45
+ return await runHybridFlow(adapter, {
46
+ entryUrl: provider.entryUrl,
47
+ browserConfig: context.browserConfig,
48
+ forceVisible: provider.forceVisible ?? false,
49
+ // Cookie auth needs networkidle to ensure all post-SSO Set-Cookie responses arrive
50
+ waitUntil: 'networkidle',
51
+ xHeaders: provider.xHeaders,
52
+ providerDomains: provider.domains,
53
+ isAuthenticated: async (page) => {
54
+ // If requiredCookies is set, auth is complete only when those cookies exist
55
+ if (this.requiredCookies.length > 0) {
56
+ const urls = provider.domains.map(d => `https://${d}/`);
57
+ const cookies = await page.cookies(urls);
58
+ const cookieNames = new Set(cookies.map(c => c.name));
59
+ return this.requiredCookies.every(name => cookieNames.has(name));
60
+ }
61
+ // Default: auth is complete when we're no longer on a login page
62
+ const onLoginPage = await isLoginPage(page);
63
+ return !onLoginPage;
64
+ },
65
+ extractCredentials: async (page, xHeaders, meta) => {
66
+ // Only extract cookies matching this provider's domains (not all cookies from the shared profile)
67
+ // Include both domain roots AND current page URL (to capture path-scoped cookies like /wiki)
68
+ const urls = provider.domains.map(d => `https://${d}/`);
69
+ const currentUrl = page.url();
70
+ if (currentUrl && !urls.includes(currentUrl))
71
+ urls.push(currentUrl);
72
+ const cookies = await page.cookies(urls);
73
+ if (cookies.length === 0) {
74
+ return err(new BrowserError('No cookies found after authentication.', provider.id));
75
+ }
76
+ // Probe for OAuth tokens in browser storage (strategy mismatch detection)
77
+ const oauthTokensDetected = await hasOAuthTokens(page).catch(() => false);
78
+ const credential = {
79
+ type: 'cookie',
80
+ cookies,
81
+ obtainedAt: new Date().toISOString(),
82
+ ...(xHeaders && Object.keys(xHeaders).length > 0 ? { xHeaders } : {}),
83
+ };
84
+ // Attach diagnostics metadata for post-auth validation
85
+ credential.__diagnostics = {
86
+ authDetectedImmediately: meta?.immediateAuth ?? false,
87
+ oauthTokensDetected,
88
+ cookiesExtracted: cookies.length,
89
+ };
90
+ return ok(credential);
91
+ },
92
+ });
93
+ }
94
+ async refresh() {
95
+ // Cookies can't be refreshed — must re-authenticate via browser
96
+ return ok(null);
97
+ }
98
+ applyToRequest(credential) {
99
+ if (credential.type !== 'cookie')
100
+ return {};
101
+ const cookieStr = credential.cookies
102
+ .map(c => `${c.name}=${c.value}`)
103
+ .join('; ');
104
+ // Apply x-headers first, then set Cookie so it always wins
105
+ const headers = { ...credential.xHeaders };
106
+ headers['Cookie'] = cookieStr;
107
+ return headers;
108
+ }
109
+ }
110
+ export class CookieStrategyFactory {
111
+ name = 'cookie';
112
+ create(config) {
113
+ if (config.strategy !== 'cookie') {
114
+ throw new Error(`CookieStrategyFactory received wrong config type: ${config.strategy}`);
115
+ }
116
+ return new CookieStrategy(config);
117
+ }
118
+ }
@@ -0,0 +1,6 @@
1
+ import type { IAuthStrategy, IAuthStrategyFactory } from '../core/interfaces/auth-strategy.js';
2
+ import type { StrategyConfig } from '../config/schema.js';
3
+ export declare class OAuth2StrategyFactory implements IAuthStrategyFactory {
4
+ readonly name = "oauth2";
5
+ create(config: StrategyConfig): IAuthStrategy;
6
+ }
@@ -0,0 +1,134 @@
1
+ import { ok, err } from '../core/result.js';
2
+ import { BrowserError, RefreshError } from '../core/errors.js';
3
+ import { runHybridFlow } from '../browser/flows/hybrid-flow.js';
4
+ import { extractOAuthTokens, hasOAuthTokens } from '../browser/flows/oauth-consent.flow.js';
5
+ import { isLoginPage } from '../browser/flows/form-login.flow.js';
6
+ const EXPIRY_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
7
+ /**
8
+ * OAuth2 authentication strategy.
9
+ * Supports browser-based authorization with token extraction from localStorage,
10
+ * and silent refresh using refresh tokens.
11
+ */
12
+ class OAuth2Strategy {
13
+ strategyConfig;
14
+ constructor(config) {
15
+ this.strategyConfig = config;
16
+ }
17
+ validate(credential) {
18
+ if (credential.type !== 'bearer')
19
+ return ok(false);
20
+ if (!credential.accessToken || credential.accessToken.trim() === '') {
21
+ return ok(false);
22
+ }
23
+ // Check expiry with buffer
24
+ if (credential.expiresAt) {
25
+ const expiresAtMs = new Date(credential.expiresAt).getTime();
26
+ if (Date.now() + EXPIRY_BUFFER_MS >= expiresAtMs) {
27
+ return ok(false);
28
+ }
29
+ }
30
+ return ok(true);
31
+ }
32
+ async authenticate(provider, context) {
33
+ const adapter = context.browserAdapter;
34
+ if (!provider.entryUrl) {
35
+ return err(new BrowserError(`Provider "${provider.id}" requires an entryUrl for OAuth2 authentication.`, provider.id));
36
+ }
37
+ return await runHybridFlow(adapter, {
38
+ entryUrl: provider.entryUrl,
39
+ browserConfig: context.browserConfig,
40
+ forceVisible: provider.forceVisible ?? false,
41
+ xHeaders: provider.xHeaders,
42
+ providerDomains: provider.domains,
43
+ isAuthenticated: async (page) => {
44
+ const onLogin = await isLoginPage(page);
45
+ if (onLogin)
46
+ return false;
47
+ return await hasOAuthTokens(page, this.strategyConfig.audiences);
48
+ },
49
+ extractCredentials: async (page, xHeaders, meta) => {
50
+ const result = await extractOAuthTokens(page, {
51
+ audiences: this.strategyConfig.audiences,
52
+ extractRefreshToken: true,
53
+ maxRetries: 8, // Up to 16s of waiting for MSAL to store tokens
54
+ });
55
+ // Attach captured headers to the bearer credential
56
+ if (result.ok && xHeaders && Object.keys(xHeaders).length > 0) {
57
+ const cred = result.value;
58
+ cred.xHeaders = xHeaders;
59
+ }
60
+ // Attach diagnostics metadata for post-auth validation
61
+ if (result.ok) {
62
+ result.value.__diagnostics = {
63
+ authDetectedImmediately: meta?.immediateAuth ?? false,
64
+ oauthTokensDetected: true,
65
+ cookiesExtracted: 0,
66
+ };
67
+ }
68
+ return result;
69
+ },
70
+ });
71
+ }
72
+ async refresh(credential) {
73
+ if (credential.type !== 'bearer')
74
+ return ok(null);
75
+ if (!credential.refreshToken)
76
+ return ok(null);
77
+ if (!this.strategyConfig.tokenEndpoint || !this.strategyConfig.clientId) {
78
+ return ok(null); // Can't refresh without endpoint and client ID
79
+ }
80
+ try {
81
+ const body = new URLSearchParams({
82
+ grant_type: 'refresh_token',
83
+ client_id: this.strategyConfig.clientId,
84
+ refresh_token: credential.refreshToken,
85
+ });
86
+ if (this.strategyConfig.scopes && this.strategyConfig.scopes.length > 0) {
87
+ body.set('scope', this.strategyConfig.scopes.join(' '));
88
+ }
89
+ const response = await fetch(this.strategyConfig.tokenEndpoint, {
90
+ method: 'POST',
91
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
92
+ body: body.toString(),
93
+ });
94
+ if (!response.ok) {
95
+ const errorBody = await response.text();
96
+ return err(new RefreshError(credential.tokenEndpoint ?? 'unknown', `Token refresh failed (${response.status}): ${errorBody}`));
97
+ }
98
+ const tokenResponse = await response.json();
99
+ const expiresAt = tokenResponse.expires_in
100
+ ? new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
101
+ : undefined;
102
+ const refreshed = {
103
+ type: 'bearer',
104
+ accessToken: tokenResponse.access_token,
105
+ refreshToken: tokenResponse.refresh_token ?? credential.refreshToken,
106
+ expiresAt,
107
+ scopes: tokenResponse.scope?.split(' '),
108
+ tokenEndpoint: credential.tokenEndpoint,
109
+ ...(credential.xHeaders ? { xHeaders: credential.xHeaders } : {}),
110
+ };
111
+ return ok(refreshed);
112
+ }
113
+ catch (e) {
114
+ return err(new RefreshError(credential.tokenEndpoint ?? 'unknown', e.message));
115
+ }
116
+ }
117
+ applyToRequest(credential) {
118
+ if (credential.type !== 'bearer')
119
+ return {};
120
+ // Apply x-headers first, then set Authorization so it always wins
121
+ const headers = { ...credential.xHeaders };
122
+ headers['Authorization'] = `Bearer ${credential.accessToken}`;
123
+ return headers;
124
+ }
125
+ }
126
+ export class OAuth2StrategyFactory {
127
+ name = 'oauth2';
128
+ create(config) {
129
+ if (config.strategy !== 'oauth2') {
130
+ throw new Error(`OAuth2StrategyFactory received wrong config type: ${config.strategy}`);
131
+ }
132
+ return new OAuth2Strategy(config);
133
+ }
134
+ }
@@ -0,0 +1,13 @@
1
+ import type { IAuthStrategy, IAuthStrategyFactory } from '../core/interfaces/auth-strategy.js';
2
+ import type { StrategyConfig } from '../config/schema.js';
3
+ /**
4
+ * Registry that maps strategy names to their factories.
5
+ * Built-in strategies are registered at startup; users can add custom ones.
6
+ */
7
+ export declare class StrategyRegistry {
8
+ private factories;
9
+ register(factory: IAuthStrategyFactory): void;
10
+ get(name: string, config: StrategyConfig): IAuthStrategy;
11
+ has(name: string): boolean;
12
+ list(): string[];
13
+ }
@@ -0,0 +1,25 @@
1
+ import { ConfigError } from '../core/errors.js';
2
+ /**
3
+ * Registry that maps strategy names to their factories.
4
+ * Built-in strategies are registered at startup; users can add custom ones.
5
+ */
6
+ export class StrategyRegistry {
7
+ factories = new Map();
8
+ register(factory) {
9
+ this.factories.set(factory.name, factory);
10
+ }
11
+ get(name, config) {
12
+ const factory = this.factories.get(name);
13
+ if (!factory) {
14
+ const available = Array.from(this.factories.keys()).join(', ');
15
+ throw new ConfigError(`Unknown strategy "${name}". Available strategies: ${available || 'none'}`);
16
+ }
17
+ return factory.create(config);
18
+ }
19
+ has(name) {
20
+ return this.factories.has(name);
21
+ }
22
+ list() {
23
+ return Array.from(this.factories.keys());
24
+ }
25
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Remote configuration — reads/writes from the unified ~/.signet/config.yaml.
3
+ */
4
+ import type { RemoteConfig } from './types.js';
5
+ export declare function getRemotes(): Promise<RemoteConfig[]>;
6
+ export declare function getRemote(name: string): Promise<RemoteConfig | null>;
7
+ export declare function addRemote(remote: RemoteConfig): Promise<void>;
8
+ export declare function removeRemote(name: string): Promise<boolean>;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Remote configuration — reads/writes from the unified ~/.signet/config.yaml.
3
+ */
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ import YAML from 'yaml';
8
+ const CONFIG_PATH = path.join(os.homedir(), '.signet', 'config.yaml');
9
+ async function loadRawConfig() {
10
+ try {
11
+ const content = await fs.readFile(CONFIG_PATH, 'utf-8');
12
+ return YAML.parse(content) ?? {};
13
+ }
14
+ catch (e) {
15
+ if (e.code === 'ENOENT')
16
+ return {};
17
+ throw e;
18
+ }
19
+ }
20
+ async function saveRawConfig(config) {
21
+ await fs.mkdir(path.dirname(CONFIG_PATH), { recursive: true });
22
+ await fs.writeFile(CONFIG_PATH, YAML.stringify(config), 'utf-8');
23
+ }
24
+ export async function getRemotes() {
25
+ const config = await loadRawConfig();
26
+ if (!config.remotes)
27
+ return [];
28
+ return Object.entries(config.remotes).map(([name, r]) => ({ name, ...r }));
29
+ }
30
+ export async function getRemote(name) {
31
+ const remotes = await getRemotes();
32
+ return remotes.find(r => r.name === name) ?? null;
33
+ }
34
+ export async function addRemote(remote) {
35
+ const config = await loadRawConfig();
36
+ if (!config.remotes)
37
+ config.remotes = {};
38
+ const { name, ...rest } = remote;
39
+ config.remotes[name] = rest;
40
+ await saveRawConfig(config);
41
+ }
42
+ export async function removeRemote(name) {
43
+ const config = await loadRawConfig();
44
+ if (!config.remotes || !config.remotes[name])
45
+ return false;
46
+ delete config.remotes[name];
47
+ await saveRawConfig(config);
48
+ return true;
49
+ }
@@ -0,0 +1,10 @@
1
+ import type { IStorage } from '../core/interfaces/storage.js';
2
+ import type { RemoteConfig, SyncResult } from './types.js';
3
+ export declare class SyncEngine {
4
+ private readonly storage;
5
+ private readonly remote;
6
+ private readonly transport;
7
+ constructor(storage: IStorage, remote: RemoteConfig);
8
+ push(providerIds?: string[], force?: boolean): Promise<SyncResult>;
9
+ pull(providerIds?: string[], force?: boolean): Promise<SyncResult>;
10
+ }
@@ -0,0 +1,96 @@
1
+ import { SshTransport } from './transports/ssh.js';
2
+ function sanitizeId(providerId) {
3
+ return providerId.replace(/[^a-zA-Z0-9._-]/g, '_');
4
+ }
5
+ export class SyncEngine {
6
+ storage;
7
+ remote;
8
+ transport;
9
+ constructor(storage, remote) {
10
+ this.storage = storage;
11
+ this.remote = remote;
12
+ this.transport = new SshTransport();
13
+ }
14
+ async push(providerIds, force = false) {
15
+ const result = { pushed: [], pulled: [], skipped: [], errors: [] };
16
+ // Get local entries
17
+ const localEntries = await this.storage.list();
18
+ const toPush = providerIds
19
+ ? localEntries.filter(e => providerIds.includes(e.providerId))
20
+ : localEntries;
21
+ if (toPush.length === 0) {
22
+ return result;
23
+ }
24
+ // Get remote entries for conflict detection
25
+ const remoteEntries = await this.transport.listRemote(this.remote);
26
+ const remoteMap = new Map(remoteEntries.map(e => [e.providerId, e]));
27
+ for (const entry of toPush) {
28
+ try {
29
+ const filename = `${sanitizeId(entry.providerId)}.json`;
30
+ const remoteEntry = remoteMap.get(entry.providerId);
31
+ // Conflict detection: skip if remote is newer
32
+ if (remoteEntry && !force) {
33
+ const localTime = new Date(entry.updatedAt).getTime();
34
+ const remoteTime = new Date(remoteEntry.updatedAt).getTime();
35
+ if (remoteTime > localTime) {
36
+ result.skipped.push(entry.providerId);
37
+ continue;
38
+ }
39
+ }
40
+ const stored = await this.storage.get(entry.providerId);
41
+ if (!stored)
42
+ continue;
43
+ await this.transport.writeRemote(this.remote, filename, stored);
44
+ result.pushed.push(entry.providerId);
45
+ }
46
+ catch (e) {
47
+ result.errors.push({
48
+ providerId: entry.providerId,
49
+ error: e.message,
50
+ });
51
+ }
52
+ }
53
+ return result;
54
+ }
55
+ async pull(providerIds, force = false) {
56
+ const result = { pushed: [], pulled: [], skipped: [], errors: [] };
57
+ // Get remote entries
58
+ const remoteEntries = await this.transport.listRemote(this.remote);
59
+ const toPull = providerIds
60
+ ? remoteEntries.filter(e => providerIds.includes(e.providerId))
61
+ : remoteEntries;
62
+ if (toPull.length === 0) {
63
+ return result;
64
+ }
65
+ for (const entry of toPull) {
66
+ try {
67
+ // Conflict detection
68
+ if (!force) {
69
+ const local = await this.storage.get(entry.providerId);
70
+ if (local) {
71
+ const localTime = new Date(local.updatedAt).getTime();
72
+ const remoteTime = new Date(entry.updatedAt).getTime();
73
+ if (localTime > remoteTime) {
74
+ result.skipped.push(entry.providerId);
75
+ continue;
76
+ }
77
+ }
78
+ }
79
+ const stored = await this.transport.readRemote(this.remote, entry.filename);
80
+ if (!stored) {
81
+ result.errors.push({ providerId: entry.providerId, error: 'Failed to read from remote' });
82
+ continue;
83
+ }
84
+ await this.storage.set(entry.providerId, stored);
85
+ result.pulled.push(entry.providerId);
86
+ }
87
+ catch (e) {
88
+ result.errors.push({
89
+ providerId: entry.providerId,
90
+ error: e.message,
91
+ });
92
+ }
93
+ }
94
+ return result;
95
+ }
96
+ }
@@ -0,0 +1,18 @@
1
+ import type { RemoteConfig } from '../types.js';
2
+ import type { StoredCredential } from '../../core/types.js';
3
+ export declare class SshTransport {
4
+ private sshArgs;
5
+ private remoteTarget;
6
+ private remotePath;
7
+ /** List provider files on the remote */
8
+ listRemote(remote: RemoteConfig): Promise<{
9
+ providerId: string;
10
+ updatedAt: string;
11
+ filename: string;
12
+ }[]>;
13
+ /** Read a single credential from remote */
14
+ readRemote(remote: RemoteConfig, filename: string): Promise<StoredCredential | null>;
15
+ /** Write a credential file to remote via ssh pipe (avoids scp tilde issues) */
16
+ writeRemote(remote: RemoteConfig, filename: string, stored: StoredCredential): Promise<void>;
17
+ private sshWrite;
18
+ }
@@ -0,0 +1,115 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ const execFileAsync = promisify(execFile);
6
+ const DEFAULT_REMOTE_PATH = '~/.signet/credentials';
7
+ export class SshTransport {
8
+ sshArgs(remote) {
9
+ const args = [];
10
+ if (remote.sshKey)
11
+ args.push('-i', remote.sshKey);
12
+ args.push('-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new');
13
+ return args;
14
+ }
15
+ remoteTarget(remote) {
16
+ const user = remote.user ?? os.userInfo().username;
17
+ return `${user}@${remote.host}`;
18
+ }
19
+ remotePath(remote) {
20
+ return remote.path ?? DEFAULT_REMOTE_PATH;
21
+ }
22
+ /** List provider files on the remote */
23
+ async listRemote(remote) {
24
+ const target = this.remoteTarget(remote);
25
+ const rpath = this.remotePath(remote);
26
+ try {
27
+ const { stdout } = await execFileAsync('ssh', [
28
+ ...this.sshArgs(remote),
29
+ target,
30
+ `find ${rpath} -maxdepth 1 -name '*.json' -print 2>/dev/null || true`,
31
+ ]);
32
+ const files = stdout.trim().split('\n').filter(Boolean);
33
+ const entries = [];
34
+ for (const file of files) {
35
+ const filename = path.basename(file);
36
+ try {
37
+ const { stdout: content } = await execFileAsync('ssh', [
38
+ ...this.sshArgs(remote),
39
+ target,
40
+ `cat "${file}"`,
41
+ ]);
42
+ const data = JSON.parse(content);
43
+ entries.push({
44
+ providerId: data.providerId,
45
+ updatedAt: data.updatedAt,
46
+ filename,
47
+ });
48
+ }
49
+ catch {
50
+ // Skip unreadable files
51
+ }
52
+ }
53
+ return entries;
54
+ }
55
+ catch {
56
+ return [];
57
+ }
58
+ }
59
+ /** Read a single credential from remote */
60
+ async readRemote(remote, filename) {
61
+ const target = this.remoteTarget(remote);
62
+ const rpath = this.remotePath(remote);
63
+ try {
64
+ const { stdout } = await execFileAsync('ssh', [
65
+ ...this.sshArgs(remote),
66
+ target,
67
+ `cat ${rpath}/"${filename}"`,
68
+ ]);
69
+ const data = JSON.parse(stdout);
70
+ return {
71
+ credential: data.credential,
72
+ providerId: data.providerId,
73
+ strategy: data.strategy,
74
+ updatedAt: data.updatedAt,
75
+ ...(data.metadata ? { metadata: data.metadata } : {}),
76
+ };
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ /** Write a credential file to remote via ssh pipe (avoids scp tilde issues) */
83
+ async writeRemote(remote, filename, stored) {
84
+ const rpath = this.remotePath(remote);
85
+ const data = {
86
+ version: 1,
87
+ providerId: stored.providerId,
88
+ credential: stored.credential,
89
+ strategy: stored.strategy,
90
+ updatedAt: stored.updatedAt,
91
+ ...(stored.metadata ? { metadata: stored.metadata } : {}),
92
+ };
93
+ const content = JSON.stringify(data, null, 2);
94
+ // Write via ssh stdin pipe — avoids scp's tilde expansion issues
95
+ // The remote shell handles ~ expansion in mkdir and cat redirect
96
+ await this.sshWrite(remote, `mkdir -p ${rpath} && cat > ${rpath}/"${filename}"`, content);
97
+ }
98
+ sshWrite(remote, command, stdin) {
99
+ return new Promise((resolve, reject) => {
100
+ const target = this.remoteTarget(remote);
101
+ const proc = execFile('ssh', [
102
+ ...this.sshArgs(remote),
103
+ target,
104
+ command,
105
+ ], (error) => {
106
+ if (error)
107
+ reject(error);
108
+ else
109
+ resolve();
110
+ });
111
+ proc.stdin?.write(stdin);
112
+ proc.stdin?.end();
113
+ });
114
+ }
115
+ }
@@ -0,0 +1,17 @@
1
+ export interface RemoteConfig {
2
+ name: string;
3
+ type: 'ssh';
4
+ host: string;
5
+ user?: string;
6
+ path?: string;
7
+ sshKey?: string;
8
+ }
9
+ export interface SyncResult {
10
+ pushed: string[];
11
+ pulled: string[];
12
+ skipped: string[];
13
+ errors: {
14
+ providerId: string;
15
+ error: string;
16
+ }[];
17
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Parse human-readable duration strings into milliseconds.
3
+ * Supports: "30s", "5m", "24h", "7d"
4
+ */
5
+ export declare function parseDuration(input: string): number;
6
+ /**
7
+ * Format milliseconds into a human-readable duration.
8
+ */
9
+ export declare function formatDuration(ms: number): string;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Parse human-readable duration strings into milliseconds.
3
+ * Supports: "30s", "5m", "24h", "7d"
4
+ */
5
+ export function parseDuration(input) {
6
+ const match = input.trim().match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/i);
7
+ if (!match) {
8
+ throw new Error(`Invalid duration format: "${input}". Expected format like "30s", "5m", "24h", "7d".`);
9
+ }
10
+ const value = parseFloat(match[1]);
11
+ const unit = match[2].toLowerCase();
12
+ const multipliers = {
13
+ ms: 1,
14
+ s: 1_000,
15
+ m: 60_000,
16
+ h: 3_600_000,
17
+ d: 86_400_000,
18
+ };
19
+ return value * multipliers[unit];
20
+ }
21
+ /**
22
+ * Format milliseconds into a human-readable duration.
23
+ */
24
+ export function formatDuration(ms) {
25
+ if (ms < 1_000)
26
+ return `${ms}ms`;
27
+ if (ms < 60_000)
28
+ return `${Math.round(ms / 1_000)}s`;
29
+ if (ms < 3_600_000)
30
+ return `${Math.round(ms / 60_000)}m`;
31
+ if (ms < 86_400_000)
32
+ return `${(ms / 3_600_000).toFixed(1)}h`;
33
+ return `${(ms / 86_400_000).toFixed(1)}d`;
34
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Build a User-Agent string identifying signet.
3
+ */
4
+ export declare function buildUserAgent(): string;