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.
- package/LICENSE +21 -0
- package/README.md +393 -0
- package/bin/sig.js +65 -0
- package/dist/auth-manager.d.ts +90 -0
- package/dist/auth-manager.js +262 -0
- package/dist/browser/adapters/playwright.adapter.d.ts +14 -0
- package/dist/browser/adapters/playwright.adapter.js +188 -0
- package/dist/browser/flows/form-login.flow.d.ts +6 -0
- package/dist/browser/flows/form-login.flow.js +35 -0
- package/dist/browser/flows/header-capture.d.ts +23 -0
- package/dist/browser/flows/header-capture.js +104 -0
- package/dist/browser/flows/hybrid-flow.d.ts +37 -0
- package/dist/browser/flows/hybrid-flow.js +104 -0
- package/dist/browser/flows/oauth-consent.flow.d.ts +20 -0
- package/dist/browser/flows/oauth-consent.flow.js +170 -0
- package/dist/cli/commands/doctor.d.ts +6 -0
- package/dist/cli/commands/doctor.js +263 -0
- package/dist/cli/commands/get.d.ts +2 -0
- package/dist/cli/commands/get.js +83 -0
- package/dist/cli/commands/init.d.ts +6 -0
- package/dist/cli/commands/init.js +244 -0
- package/dist/cli/commands/login.d.ts +2 -0
- package/dist/cli/commands/login.js +77 -0
- package/dist/cli/commands/logout.d.ts +2 -0
- package/dist/cli/commands/logout.js +11 -0
- package/dist/cli/commands/providers.d.ts +2 -0
- package/dist/cli/commands/providers.js +30 -0
- package/dist/cli/commands/remote.d.ts +1 -0
- package/dist/cli/commands/remote.js +67 -0
- package/dist/cli/commands/request.d.ts +2 -0
- package/dist/cli/commands/request.js +82 -0
- package/dist/cli/commands/status.d.ts +2 -0
- package/dist/cli/commands/status.js +41 -0
- package/dist/cli/commands/sync.d.ts +2 -0
- package/dist/cli/commands/sync.js +62 -0
- package/dist/cli/formatters.d.ts +3 -0
- package/dist/cli/formatters.js +25 -0
- package/dist/cli/main.d.ts +8 -0
- package/dist/cli/main.js +125 -0
- package/dist/config/generator.d.ts +24 -0
- package/dist/config/generator.js +97 -0
- package/dist/config/loader.d.ts +21 -0
- package/dist/config/loader.js +54 -0
- package/dist/config/schema.d.ts +44 -0
- package/dist/config/schema.js +8 -0
- package/dist/config/validator.d.ts +15 -0
- package/dist/config/validator.js +228 -0
- package/dist/core/errors.d.ts +57 -0
- package/dist/core/errors.js +107 -0
- package/dist/core/interfaces/auth-strategy.d.ts +48 -0
- package/dist/core/interfaces/auth-strategy.js +1 -0
- package/dist/core/interfaces/browser-adapter.d.ts +73 -0
- package/dist/core/interfaces/browser-adapter.js +1 -0
- package/dist/core/interfaces/provider.d.ts +15 -0
- package/dist/core/interfaces/provider.js +1 -0
- package/dist/core/interfaces/storage.d.ts +21 -0
- package/dist/core/interfaces/storage.js +1 -0
- package/dist/core/result.d.ts +21 -0
- package/dist/core/result.js +16 -0
- package/dist/core/types.d.ts +128 -0
- package/dist/core/types.js +6 -0
- package/dist/deps.d.ts +20 -0
- package/dist/deps.js +54 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +37 -0
- package/dist/providers/auto-provision.d.ts +9 -0
- package/dist/providers/auto-provision.js +27 -0
- package/dist/providers/config-loader.d.ts +7 -0
- package/dist/providers/config-loader.js +7 -0
- package/dist/providers/provider-registry.d.ts +19 -0
- package/dist/providers/provider-registry.js +68 -0
- package/dist/storage/cached-storage.d.ts +24 -0
- package/dist/storage/cached-storage.js +57 -0
- package/dist/storage/directory-storage.d.ts +25 -0
- package/dist/storage/directory-storage.js +184 -0
- package/dist/storage/memory-storage.d.ts +14 -0
- package/dist/storage/memory-storage.js +27 -0
- package/dist/strategies/api-token.strategy.d.ts +6 -0
- package/dist/strategies/api-token.strategy.js +63 -0
- package/dist/strategies/basic-auth.strategy.d.ts +6 -0
- package/dist/strategies/basic-auth.strategy.js +41 -0
- package/dist/strategies/cookie.strategy.d.ts +6 -0
- package/dist/strategies/cookie.strategy.js +118 -0
- package/dist/strategies/oauth2.strategy.d.ts +6 -0
- package/dist/strategies/oauth2.strategy.js +134 -0
- package/dist/strategies/registry.d.ts +13 -0
- package/dist/strategies/registry.js +25 -0
- package/dist/sync/remote-config.d.ts +8 -0
- package/dist/sync/remote-config.js +49 -0
- package/dist/sync/sync-engine.d.ts +10 -0
- package/dist/sync/sync-engine.js +96 -0
- package/dist/sync/transports/ssh.d.ts +18 -0
- package/dist/sync/transports/ssh.js +115 -0
- package/dist/sync/types.d.ts +17 -0
- package/dist/sync/types.js +1 -0
- package/dist/utils/duration.d.ts +9 -0
- package/dist/utils/duration.js +34 -0
- package/dist/utils/http.d.ts +4 -0
- package/dist/utils/http.js +10 -0
- package/dist/utils/jwt.d.ts +15 -0
- package/dist/utils/jwt.js +30 -0
- package/package.json +56 -0
- package/src/auth-manager.ts +331 -0
- package/src/browser/adapters/playwright.adapter.ts +247 -0
- package/src/browser/flows/form-login.flow.ts +35 -0
- package/src/browser/flows/header-capture.ts +128 -0
- package/src/browser/flows/hybrid-flow.ts +165 -0
- package/src/browser/flows/oauth-consent.flow.ts +200 -0
- package/src/cli/commands/doctor.ts +301 -0
- package/src/cli/commands/get.ts +96 -0
- package/src/cli/commands/init.ts +289 -0
- package/src/cli/commands/login.ts +94 -0
- package/src/cli/commands/logout.ts +17 -0
- package/src/cli/commands/providers.ts +39 -0
- package/src/cli/commands/remote.ts +71 -0
- package/src/cli/commands/request.ts +97 -0
- package/src/cli/commands/status.ts +48 -0
- package/src/cli/commands/sync.ts +71 -0
- package/src/cli/formatters.ts +31 -0
- package/src/cli/main.ts +144 -0
- package/src/config/generator.ts +122 -0
- package/src/config/loader.ts +70 -0
- package/src/config/schema.ts +75 -0
- package/src/config/validator.ts +281 -0
- package/src/core/errors.ts +182 -0
- package/src/core/interfaces/auth-strategy.ts +65 -0
- package/src/core/interfaces/browser-adapter.ts +81 -0
- package/src/core/interfaces/provider.ts +19 -0
- package/src/core/interfaces/storage.ts +26 -0
- package/src/core/result.ts +24 -0
- package/src/core/types.ts +194 -0
- package/src/deps.ts +80 -0
- package/src/index.ts +109 -0
- package/src/providers/auto-provision.ts +30 -0
- package/src/providers/config-loader.ts +8 -0
- package/src/providers/provider-registry.ts +79 -0
- package/src/storage/cached-storage.ts +72 -0
- package/src/storage/directory-storage.ts +204 -0
- package/src/storage/memory-storage.ts +35 -0
- package/src/strategies/api-token.strategy.ts +87 -0
- package/src/strategies/basic-auth.strategy.ts +64 -0
- package/src/strategies/cookie.strategy.ts +153 -0
- package/src/strategies/oauth2.strategy.ts +178 -0
- package/src/strategies/registry.ts +34 -0
- package/src/sync/remote-config.ts +60 -0
- package/src/sync/sync-engine.ts +113 -0
- package/src/sync/transports/ssh.ts +130 -0
- package/src/sync/types.ts +15 -0
- package/src/utils/duration.ts +34 -0
- package/src/utils/http.ts +11 -0
- package/src/utils/jwt.ts +39 -0
- 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
|
+
}
|