@promptcellar/pc 0.5.3 → 0.5.5
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/hooks/codex-capture.js +9 -9
- package/hooks/gemini-capture.js +5 -9
- package/hooks/prompt-capture.js +5 -9
- package/package.json +2 -1
- package/src/commands/login.js +34 -115
- package/src/commands/save.js +5 -2
- package/src/lib/api.js +28 -47
- package/src/lib/config.js +22 -2
- package/src/lib/context.js +14 -59
- package/src/lib/crypto.js +1 -39
- package/src/lib/device-transfer.js +1 -43
- package/src/lib/keychain.js +7 -2
- package/tests/config.test.js +29 -0
- package/tests/context.test.js +23 -0
- package/tests/device-login.test.js +28 -0
- package/tests/keychain.test.js +34 -1
- package/README.md +0 -114
- package/docs/plans/2026-01-30-wa-auth-integration-design.md +0 -118
- package/docs/plans/2026-01-30-wa-auth-integration-plan.md +0 -776
- package/docs/plans/2026-02-03-multi-prompt-capture-design.md +0 -501
package/hooks/codex-capture.js
CHANGED
|
@@ -84,13 +84,6 @@ async function main() {
|
|
|
84
84
|
process.exit(0);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
// Build base context once
|
|
88
|
-
const baseContext = getFullContext('codex');
|
|
89
|
-
if (event.cwd) {
|
|
90
|
-
baseContext.working_directory = event.cwd;
|
|
91
|
-
}
|
|
92
|
-
baseContext.session_id = threadId;
|
|
93
|
-
|
|
94
87
|
// Capture each new message
|
|
95
88
|
for (const message of newMessages) {
|
|
96
89
|
const content = extractContent(message);
|
|
@@ -98,10 +91,17 @@ async function main() {
|
|
|
98
91
|
continue;
|
|
99
92
|
}
|
|
100
93
|
|
|
101
|
-
const
|
|
94
|
+
const promptText = content.trim();
|
|
95
|
+
const context = getFullContext('codex', null, {
|
|
96
|
+
cwd: event.cwd || process.cwd(),
|
|
97
|
+
sessionId: threadId,
|
|
98
|
+
promptText,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const { encrypted_content, content_iv } = encryptPrompt(promptText, vaultKey);
|
|
102
102
|
|
|
103
103
|
await capturePrompt({
|
|
104
|
-
...
|
|
104
|
+
...context,
|
|
105
105
|
encrypted_content,
|
|
106
106
|
content_iv
|
|
107
107
|
});
|
package/hooks/gemini-capture.js
CHANGED
|
@@ -72,15 +72,11 @@ async function main() {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
// Build context
|
|
75
|
-
const context = getFullContext('gemini'
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
if (event.session_id) {
|
|
82
|
-
context.session_id = event.session_id;
|
|
83
|
-
}
|
|
75
|
+
const context = getFullContext('gemini', null, {
|
|
76
|
+
cwd: event.cwd || process.cwd(),
|
|
77
|
+
sessionId: event.session_id,
|
|
78
|
+
promptText: content,
|
|
79
|
+
});
|
|
84
80
|
|
|
85
81
|
const vaultKey = await requireVaultKey({ silent: true });
|
|
86
82
|
if (!vaultKey) {
|
package/hooks/prompt-capture.js
CHANGED
|
@@ -56,15 +56,11 @@ async function main() {
|
|
|
56
56
|
process.exit(0);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
const context = getFullContext('claude-code'
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
if (event.session_id) {
|
|
66
|
-
context.session_id = event.session_id;
|
|
67
|
-
}
|
|
59
|
+
const context = getFullContext('claude-code', null, {
|
|
60
|
+
cwd: event.cwd || process.cwd(),
|
|
61
|
+
sessionId: event.session_id,
|
|
62
|
+
promptText: promptContent,
|
|
63
|
+
});
|
|
68
64
|
|
|
69
65
|
const vaultKey = await requireVaultKey({ silent: true });
|
|
70
66
|
if (!vaultKey) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptcellar/pc",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
4
4
|
"description": "CLI for Prompt Cellar - sync prompts between your terminal and the cloud",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"node": ">=18.0.0"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
+
"@promptcellar/core": "*",
|
|
37
38
|
"commander": "^12.0.0",
|
|
38
39
|
"conf": "^12.0.0",
|
|
39
40
|
"chalk": "^5.3.0",
|
package/src/commands/login.js
CHANGED
|
@@ -1,126 +1,45 @@
|
|
|
1
|
-
import { randomBytes } from 'crypto';
|
|
2
1
|
import ora from 'ora';
|
|
3
2
|
import chalk from 'chalk';
|
|
4
3
|
import {
|
|
4
|
+
normalizeAndValidateUrl,
|
|
5
5
|
setApiKey, setApiUrl, setAccountUrl,
|
|
6
6
|
setVaultAvailable,
|
|
7
7
|
isLoggedIn
|
|
8
8
|
} from '../lib/config.js';
|
|
9
9
|
import { generateTransferKeyPair, decryptTransfer } from '../lib/device-transfer.js';
|
|
10
|
-
import { b64urlEncode } from '../lib/crypto.js';
|
|
11
10
|
import { storeVaultKey } from '../lib/keychain.js';
|
|
11
|
+
import {
|
|
12
|
+
setClientId,
|
|
13
|
+
requestDeviceCode,
|
|
14
|
+
pollForApproval,
|
|
15
|
+
exchangeTokenForApiKey,
|
|
16
|
+
finalizeDeviceLogin,
|
|
17
|
+
} from '@promptcellar/core/auth';
|
|
18
|
+
|
|
19
|
+
// Re-export auth functions so existing CLI tests can import from this module
|
|
20
|
+
export { setClientId, requestDeviceCode, finalizeDeviceLogin };
|
|
12
21
|
|
|
13
22
|
const DEFAULT_API_URL = 'https://prompts.weldedanvil.com';
|
|
14
23
|
const DEFAULT_ACCOUNT_URL = 'https://account.weldedanvil.com';
|
|
15
|
-
let clientId = 'prompts-prod';
|
|
16
|
-
|
|
17
|
-
export function setClientId(id) { clientId = id; }
|
|
18
|
-
|
|
19
|
-
export async function requestDeviceCode(accountUrl, transferPublicKey) {
|
|
20
|
-
const payload = { client_id: clientId };
|
|
21
|
-
if (transferPublicKey) {
|
|
22
|
-
payload.transfer_public_key = transferPublicKey;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const response = await fetch(`${accountUrl}/device/code`, {
|
|
26
|
-
method: 'POST',
|
|
27
|
-
headers: { 'Content-Type': 'application/json' },
|
|
28
|
-
body: JSON.stringify(payload),
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
if (!response.ok) {
|
|
32
|
-
const data = await response.json().catch(() => ({}));
|
|
33
|
-
throw new Error(data.error || 'Failed to request device code');
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
return response.json();
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async function pollForApproval(accountUrl, deviceCode, expiresIn) {
|
|
40
|
-
const expiresAt = Date.now() + (expiresIn || 600) * 1000;
|
|
41
|
-
const interval = 5000;
|
|
42
|
-
|
|
43
|
-
while (Date.now() < expiresAt) {
|
|
44
|
-
await sleep(interval);
|
|
45
|
-
|
|
46
|
-
const response = await fetch(`${accountUrl}/device/poll`, {
|
|
47
|
-
method: 'POST',
|
|
48
|
-
headers: { 'Content-Type': 'application/json' },
|
|
49
|
-
body: JSON.stringify({ device_code: deviceCode }),
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
const data = await response.json();
|
|
53
|
-
|
|
54
|
-
if (data.status === 'approved' && data.access_token) {
|
|
55
|
-
return data;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
if (data.status === 'expired') {
|
|
59
|
-
throw new Error('Authorization request expired. Please try again.');
|
|
60
|
-
}
|
|
61
24
|
|
|
62
|
-
|
|
63
|
-
const error = data.error || 'unknown';
|
|
64
|
-
if (error.startsWith('vault_missing')) {
|
|
65
|
-
throw new Error(`Vault error: ${error}`);
|
|
66
|
-
}
|
|
67
|
-
if (error === 'missing_transfer_key') {
|
|
68
|
-
throw new Error('Transfer key missing. Please try again.');
|
|
69
|
-
}
|
|
70
|
-
throw new Error(`Authorization rejected: ${error}`);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// status === 'pending' -- keep polling
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
throw new Error('Authorization request timed out. Please try again.');
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async function exchangeTokenForApiKey(apiUrl, jwt, deviceName) {
|
|
80
|
-
const response = await fetch(`${apiUrl}/api/v1/auth/device-token`, {
|
|
81
|
-
method: 'POST',
|
|
82
|
-
headers: {
|
|
83
|
-
'Authorization': `Bearer ${jwt}`,
|
|
84
|
-
'Content-Type': 'application/json',
|
|
85
|
-
},
|
|
86
|
-
body: JSON.stringify({ device_name: deviceName }),
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
if (!response.ok) {
|
|
90
|
-
const data = await response.json().catch(() => ({}));
|
|
91
|
-
throw new Error(data.error || 'Failed to exchange token');
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
return response.json();
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export async function finalizeDeviceLogin({
|
|
98
|
-
approval,
|
|
99
|
-
privateKeyPem,
|
|
25
|
+
export function persistLoginState({
|
|
100
26
|
apiUrl,
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
27
|
+
accountUrl,
|
|
28
|
+
apiKey,
|
|
29
|
+
vaultAvailable = true,
|
|
30
|
+
normalizeFn = normalizeAndValidateUrl,
|
|
31
|
+
setApiUrlFn = setApiUrl,
|
|
32
|
+
setAccountUrlFn = setAccountUrl,
|
|
33
|
+
setApiKeyFn = setApiKey,
|
|
34
|
+
setVaultAvailableFn = setVaultAvailable,
|
|
105
35
|
}) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (!approval?.access_token) {
|
|
111
|
-
throw new Error('Missing access token. Please reauthorize.');
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
let masterKeyB64;
|
|
115
|
-
if (approval.vault_transfer === 'cli_generate') {
|
|
116
|
-
// No browser extensions available — generate master key locally
|
|
117
|
-
masterKeyB64 = b64urlEncode(randomBytes(32));
|
|
118
|
-
} else {
|
|
119
|
-
masterKeyB64 = decryptTransferFn(approval.vault_transfer, privateKeyPem);
|
|
120
|
-
}
|
|
121
|
-
await storeVaultKeyFn(masterKeyB64);
|
|
36
|
+
const normalizedApiUrl = normalizeFn(apiUrl);
|
|
37
|
+
const normalizedAccountUrl = normalizeFn(accountUrl);
|
|
122
38
|
|
|
123
|
-
|
|
39
|
+
setApiUrlFn(normalizedApiUrl);
|
|
40
|
+
setAccountUrlFn(normalizedAccountUrl);
|
|
41
|
+
setApiKeyFn(apiKey);
|
|
42
|
+
setVaultAvailableFn(vaultAvailable);
|
|
124
43
|
}
|
|
125
44
|
|
|
126
45
|
function getDeviceName() {
|
|
@@ -130,10 +49,6 @@ function getDeviceName() {
|
|
|
130
49
|
return `${osNames[os] || os} - ${hostname}`;
|
|
131
50
|
}
|
|
132
51
|
|
|
133
|
-
function sleep(ms) {
|
|
134
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
135
|
-
}
|
|
136
|
-
|
|
137
52
|
export async function login(options) {
|
|
138
53
|
const apiUrl = options?.url || DEFAULT_API_URL;
|
|
139
54
|
const accountUrl = options?.accountUrl || DEFAULT_ACCOUNT_URL;
|
|
@@ -178,13 +93,17 @@ export async function login(options) {
|
|
|
178
93
|
privateKeyPem,
|
|
179
94
|
apiUrl,
|
|
180
95
|
deviceName,
|
|
96
|
+
decryptTransferFn: decryptTransfer,
|
|
97
|
+
storeVaultKeyFn: storeVaultKey,
|
|
181
98
|
});
|
|
182
99
|
|
|
183
100
|
// Step 5: Store credentials
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
101
|
+
persistLoginState({
|
|
102
|
+
apiUrl,
|
|
103
|
+
accountUrl,
|
|
104
|
+
apiKey: result.api_key,
|
|
105
|
+
vaultAvailable: true,
|
|
106
|
+
});
|
|
188
107
|
|
|
189
108
|
spinner.succeed(chalk.green('Logged in successfully!'));
|
|
190
109
|
console.log(`\nWelcome, ${chalk.cyan(result.email)}!`);
|
package/src/commands/save.js
CHANGED
|
@@ -34,7 +34,10 @@ export async function save(options) {
|
|
|
34
34
|
content = promptContent;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
const
|
|
37
|
+
const trimmedContent = content.trim();
|
|
38
|
+
const context = getFullContext(options.tool || 'manual', options.model, {
|
|
39
|
+
promptText: trimmedContent,
|
|
40
|
+
});
|
|
38
41
|
|
|
39
42
|
const spinner = ora('Saving prompt...').start();
|
|
40
43
|
|
|
@@ -47,7 +50,7 @@ export async function save(options) {
|
|
|
47
50
|
console.log(chalk.red(error.message));
|
|
48
51
|
return;
|
|
49
52
|
}
|
|
50
|
-
const { encrypted_content, content_iv } = encryptPrompt(
|
|
53
|
+
const { encrypted_content, content_iv } = encryptPrompt(trimmedContent, vaultKey);
|
|
51
54
|
|
|
52
55
|
const result = await capturePrompt({
|
|
53
56
|
encrypted_content,
|
package/src/lib/api.js
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
1
|
import { getApiKey, getApiUrl } from './config.js';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
capturePrompt as coreCapturePrompt,
|
|
4
|
+
listCaptured as coreListCaptured,
|
|
5
|
+
getPrompt as coreGetPrompt,
|
|
6
|
+
healthCheck as coreHealthCheck,
|
|
7
|
+
} from '@promptcellar/core/api';
|
|
8
|
+
|
|
9
|
+
function requireAuth() {
|
|
4
10
|
const apiKey = getApiKey();
|
|
5
11
|
const apiUrl = getApiUrl();
|
|
12
|
+
if (!apiKey) throw new Error('Not logged in. Run: pc login');
|
|
13
|
+
return { apiKey, apiUrl };
|
|
14
|
+
}
|
|
6
15
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const url = `${apiUrl}${endpoint}`;
|
|
12
|
-
const headers = {
|
|
13
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
14
|
-
'Content-Type': 'application/json',
|
|
15
|
-
...options.headers
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
const response = await fetch(url, {
|
|
19
|
-
...options,
|
|
20
|
-
headers
|
|
21
|
-
});
|
|
16
|
+
export async function capturePrompt(data) {
|
|
17
|
+
const { apiUrl, apiKey } = requireAuth();
|
|
18
|
+
return coreCapturePrompt(apiUrl, apiKey, data);
|
|
19
|
+
}
|
|
22
20
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
export async function listCaptured(options) {
|
|
22
|
+
const { apiUrl, apiKey } = requireAuth();
|
|
23
|
+
return coreListCaptured(apiUrl, apiKey, options);
|
|
24
|
+
}
|
|
27
25
|
|
|
28
|
-
|
|
26
|
+
export async function getPrompt(slug) {
|
|
27
|
+
const { apiUrl, apiKey } = requireAuth();
|
|
28
|
+
return coreGetPrompt(apiUrl, apiKey, slug);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export async function testConnection() {
|
|
@@ -37,35 +37,16 @@ export async function testConnection() {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
try {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return { success: true };
|
|
43
|
-
}
|
|
44
|
-
return { success: false, error: 'Server not responding' };
|
|
40
|
+
await coreHealthCheck(apiUrl);
|
|
41
|
+
return { success: true };
|
|
45
42
|
} catch (error) {
|
|
46
43
|
return { success: false, error: error.message };
|
|
47
44
|
}
|
|
48
45
|
}
|
|
49
46
|
|
|
50
|
-
export async function
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
body: JSON.stringify(data)
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export async function listCaptured(options = {}) {
|
|
58
|
-
const params = new URLSearchParams();
|
|
59
|
-
if (options.page) params.set('page', options.page);
|
|
60
|
-
if (options.tool) params.set('tool', options.tool);
|
|
61
|
-
if (options.starred) params.set('starred', 'true');
|
|
62
|
-
|
|
63
|
-
const query = params.toString();
|
|
64
|
-
return request(`/api/v1/captured${query ? '?' + query : ''}`);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export async function getPrompt(slug) {
|
|
68
|
-
return request(`/api/v1/prompts/${slug}`);
|
|
47
|
+
export async function healthCheck() {
|
|
48
|
+
const apiUrl = getApiUrl();
|
|
49
|
+
return coreHealthCheck(apiUrl);
|
|
69
50
|
}
|
|
70
51
|
|
|
71
|
-
export default { testConnection, capturePrompt, listCaptured, getPrompt };
|
|
52
|
+
export default { testConnection, capturePrompt, listCaptured, getPrompt, healthCheck };
|
package/src/lib/config.js
CHANGED
|
@@ -50,8 +50,28 @@ export function getApiUrl() {
|
|
|
50
50
|
return config.get('apiUrl');
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
export function normalizeAndValidateUrl(rawUrl) {
|
|
54
|
+
if (typeof rawUrl !== 'string' || !rawUrl.trim()) {
|
|
55
|
+
throw new Error('URL is required');
|
|
56
|
+
}
|
|
57
|
+
let url;
|
|
58
|
+
try {
|
|
59
|
+
url = new URL(rawUrl);
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error('Invalid URL');
|
|
62
|
+
}
|
|
63
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
64
|
+
throw new Error('URL must be http or https');
|
|
65
|
+
}
|
|
66
|
+
const isLocalhost = ['localhost', '127.0.0.1'].includes(url.hostname);
|
|
67
|
+
if (url.protocol !== 'https:' && !isLocalhost) {
|
|
68
|
+
throw new Error('URL must use https');
|
|
69
|
+
}
|
|
70
|
+
return url.origin;
|
|
71
|
+
}
|
|
72
|
+
|
|
53
73
|
export function setApiUrl(url) {
|
|
54
|
-
config.set('apiUrl', url);
|
|
74
|
+
config.set('apiUrl', normalizeAndValidateUrl(url));
|
|
55
75
|
}
|
|
56
76
|
|
|
57
77
|
export function getCaptureLevel() {
|
|
@@ -79,7 +99,7 @@ export function getAccountUrl() {
|
|
|
79
99
|
}
|
|
80
100
|
|
|
81
101
|
export function setAccountUrl(url) {
|
|
82
|
-
config.set('accountUrl', url);
|
|
102
|
+
config.set('accountUrl', normalizeAndValidateUrl(url));
|
|
83
103
|
}
|
|
84
104
|
|
|
85
105
|
export function isVaultAvailable() {
|
package/src/lib/context.js
CHANGED
|
@@ -1,69 +1,24 @@
|
|
|
1
|
-
import { execFileSync } from 'child_process';
|
|
2
1
|
import { getCaptureLevel, getSessionId } from './config.js';
|
|
2
|
+
import {
|
|
3
|
+
getGitContext as coreGitContext,
|
|
4
|
+
getFullContext as coreFullContext,
|
|
5
|
+
} from '@promptcellar/core/context';
|
|
3
6
|
|
|
4
|
-
|
|
5
|
-
try {
|
|
6
|
-
return execFileSync('git', args, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
7
|
-
} catch {
|
|
8
|
-
return null;
|
|
9
|
-
}
|
|
10
|
-
}
|
|
7
|
+
export { sanitizeGitRemote } from '@promptcellar/core/context';
|
|
11
8
|
|
|
12
9
|
export function getGitContext() {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
// Minimal: no git context
|
|
16
|
-
if (level === 'minimal') {
|
|
17
|
-
return {};
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const context = {};
|
|
21
|
-
|
|
22
|
-
// Standard: repo name and branch
|
|
23
|
-
const topLevel = execGit('rev-parse', '--show-toplevel');
|
|
24
|
-
if (topLevel) {
|
|
25
|
-
context.git_repo = topLevel.split('/').pop();
|
|
26
|
-
context.git_branch = execGit('rev-parse', '--abbrev-ref', 'HEAD');
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Rich: add remote URL and commit hash
|
|
30
|
-
if (level === 'rich' && topLevel) {
|
|
31
|
-
context.git_remote_url = execGit('remote', 'get-url', 'origin');
|
|
32
|
-
context.git_commit = execGit('rev-parse', 'HEAD');
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return context;
|
|
10
|
+
return coreGitContext(process.cwd(), getCaptureLevel());
|
|
36
11
|
}
|
|
37
12
|
|
|
38
|
-
export function getFullContext(tool, model) {
|
|
39
|
-
|
|
40
|
-
const sessionId = getSessionId();
|
|
41
|
-
|
|
42
|
-
const context = {
|
|
13
|
+
export function getFullContext(tool, model, overrides = {}) {
|
|
14
|
+
return coreFullContext({
|
|
43
15
|
tool,
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Standard: add working directory and model
|
|
53
|
-
context.working_directory = process.cwd();
|
|
54
|
-
if (model) {
|
|
55
|
-
context.model = model;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Add git context
|
|
59
|
-
Object.assign(context, getGitContext());
|
|
60
|
-
|
|
61
|
-
// Rich: add session ID
|
|
62
|
-
if (level === 'rich') {
|
|
63
|
-
context.session_id = sessionId;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return context;
|
|
16
|
+
model,
|
|
17
|
+
captureLevel: getCaptureLevel(),
|
|
18
|
+
sessionId: getSessionId(),
|
|
19
|
+
cwd: process.cwd(),
|
|
20
|
+
...overrides,
|
|
21
|
+
});
|
|
67
22
|
}
|
|
68
23
|
|
|
69
24
|
export default { getGitContext, getFullContext };
|
package/src/lib/crypto.js
CHANGED
|
@@ -1,39 +1 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
function b64urlEncode(buffer) {
|
|
4
|
-
return Buffer.from(buffer)
|
|
5
|
-
.toString('base64')
|
|
6
|
-
.replace(/\+/g, '-')
|
|
7
|
-
.replace(/\//g, '_')
|
|
8
|
-
.replace(/=+$/, '');
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function b64urlDecode(str) {
|
|
12
|
-
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
|
13
|
-
while (base64.length % 4) base64 += '=';
|
|
14
|
-
return Buffer.from(base64, 'base64');
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function encryptPrompt(plaintext, keyBase64url) {
|
|
18
|
-
const key = b64urlDecode(keyBase64url);
|
|
19
|
-
if (key.length !== 32) {
|
|
20
|
-
throw new Error('Invalid vault key length');
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const iv = randomBytes(12);
|
|
24
|
-
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
25
|
-
|
|
26
|
-
const encoded = Buffer.from(plaintext, 'utf8');
|
|
27
|
-
const encrypted = Buffer.concat([cipher.update(encoded), cipher.final()]);
|
|
28
|
-
const authTag = cipher.getAuthTag();
|
|
29
|
-
|
|
30
|
-
// AES-GCM ciphertext = encrypted + authTag (WebCrypto format)
|
|
31
|
-
const ciphertext = Buffer.concat([encrypted, authTag]);
|
|
32
|
-
|
|
33
|
-
return {
|
|
34
|
-
encrypted_content: b64urlEncode(ciphertext),
|
|
35
|
-
content_iv: b64urlEncode(iv),
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export { b64urlEncode, b64urlDecode };
|
|
1
|
+
export { encryptPrompt, decryptPrompt, b64urlEncode, b64urlDecode } from '@promptcellar/core/crypto';
|
|
@@ -1,43 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { promisify } from 'util';
|
|
3
|
-
import { b64urlEncode, b64urlDecode } from './crypto.js';
|
|
4
|
-
|
|
5
|
-
const generateKeyPairAsync = promisify(generateKeyPair);
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Generate an RSA-OAEP 2048 keypair for device-transfer envelope encryption.
|
|
9
|
-
* Returns { publicKeySpkiB64url, privateKeyPem }.
|
|
10
|
-
*/
|
|
11
|
-
export async function generateTransferKeyPair() {
|
|
12
|
-
const { publicKey, privateKey } = await generateKeyPairAsync('rsa', {
|
|
13
|
-
modulusLength: 2048,
|
|
14
|
-
publicKeyEncoding: { type: 'spki', format: 'der' },
|
|
15
|
-
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
return {
|
|
19
|
-
publicKeySpkiB64url: b64urlEncode(publicKey),
|
|
20
|
-
privateKeyPem: privateKey,
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Decrypt a transfer payload encrypted with RSA-OAEP + SHA-256.
|
|
26
|
-
* @param {string} ciphertextB64url - base64url-encoded ciphertext
|
|
27
|
-
* @param {string} privateKeyPem - PEM-encoded PKCS#8 private key
|
|
28
|
-
* @returns {string} base64url-encoded master key
|
|
29
|
-
*/
|
|
30
|
-
export function decryptTransfer(ciphertextB64url, privateKeyPem) {
|
|
31
|
-
const ciphertext = b64urlDecode(ciphertextB64url);
|
|
32
|
-
|
|
33
|
-
const plaintext = privateDecrypt(
|
|
34
|
-
{
|
|
35
|
-
key: privateKeyPem,
|
|
36
|
-
oaepHash: 'sha256',
|
|
37
|
-
padding: constants.RSA_PKCS1_OAEP_PADDING,
|
|
38
|
-
},
|
|
39
|
-
ciphertext,
|
|
40
|
-
);
|
|
41
|
-
|
|
42
|
-
return b64urlEncode(plaintext);
|
|
43
|
-
}
|
|
1
|
+
export { generateTransferKeyPair, decryptTransfer } from '@promptcellar/core/auth';
|
package/src/lib/keychain.js
CHANGED
|
@@ -43,19 +43,24 @@ export async function storeVaultKey(keyB64url) {
|
|
|
43
43
|
try {
|
|
44
44
|
await keytar.setPassword(SERVICE, ACCOUNT, keyB64url);
|
|
45
45
|
return;
|
|
46
|
-
} catch {
|
|
47
|
-
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (process.env.PC_VAULT_KEY_FALLBACK !== '1') {
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
48
50
|
}
|
|
49
51
|
writeFallback(keyB64url);
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
export async function loadVaultKey() {
|
|
55
|
+
const envVaultKey = process.env.PC_VAULT_KEY;
|
|
56
|
+
if (envVaultKey) return envVaultKey;
|
|
53
57
|
try {
|
|
54
58
|
const val = await keytar.getPassword(SERVICE, ACCOUNT);
|
|
55
59
|
if (val) return val;
|
|
56
60
|
} catch {
|
|
57
61
|
// Keychain unavailable — try file fallback
|
|
58
62
|
}
|
|
63
|
+
if (process.env.PC_VAULT_KEY_FALLBACK !== '1') return null;
|
|
59
64
|
return readFallback();
|
|
60
65
|
}
|
|
61
66
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { normalizeAndValidateUrl } from '../src/lib/config.js';
|
|
4
|
+
|
|
5
|
+
describe('normalizeAndValidateUrl', () => {
|
|
6
|
+
it('accepts https URLs', () => {
|
|
7
|
+
const url = normalizeAndValidateUrl('https://prompts.weldedanvil.com');
|
|
8
|
+
assert.equal(url, 'https://prompts.weldedanvil.com');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('accepts localhost http URLs', () => {
|
|
12
|
+
const url = normalizeAndValidateUrl('http://localhost:3000');
|
|
13
|
+
assert.equal(url, 'http://localhost:3000');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('rejects non-https for non-local hosts', () => {
|
|
17
|
+
assert.throws(
|
|
18
|
+
() => normalizeAndValidateUrl('http://example.com'),
|
|
19
|
+
/https/i,
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('rejects non-http schemes', () => {
|
|
24
|
+
assert.throws(
|
|
25
|
+
() => normalizeAndValidateUrl('ftp://example.com'),
|
|
26
|
+
/http/i,
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
});
|