@promptcellar/pc 0.5.3 → 0.5.4
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/README.md +5 -0
- package/package.json +1 -1
- package/src/commands/login.js +27 -4
- package/src/lib/config.js +22 -2
- package/src/lib/context.js +15 -1
- 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
CHANGED
|
@@ -103,6 +103,11 @@ Configuration is stored in:
|
|
|
103
103
|
- Linux: `~/.config/promptcellar-nodejs/`
|
|
104
104
|
- Windows: `%APPDATA%/promptcellar-nodejs/`
|
|
105
105
|
|
|
106
|
+
Security flags and requirements:
|
|
107
|
+
- `PC_VAULT_KEY`: overrides the vault key used by the CLI.
|
|
108
|
+
- `PC_VAULT_KEY_FALLBACK=1`: enables file fallback when the keychain is locked.
|
|
109
|
+
- `apiUrl` and `accountUrl` must use HTTPS (`http://localhost` is allowed).
|
|
110
|
+
|
|
106
111
|
## API
|
|
107
112
|
|
|
108
113
|
The CLI communicates with your Prompt Cellar instance. Get your API key from:
|
package/package.json
CHANGED
package/src/commands/login.js
CHANGED
|
@@ -2,6 +2,7 @@ import { randomBytes } from 'crypto';
|
|
|
2
2
|
import ora from 'ora';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import {
|
|
5
|
+
normalizeAndValidateUrl,
|
|
5
6
|
setApiKey, setApiUrl, setAccountUrl,
|
|
6
7
|
setVaultAvailable,
|
|
7
8
|
isLoggedIn
|
|
@@ -123,6 +124,26 @@ export async function finalizeDeviceLogin({
|
|
|
123
124
|
return exchangeTokenForApiKeyFn(apiUrl, approval.access_token, deviceName);
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
export function persistLoginState({
|
|
128
|
+
apiUrl,
|
|
129
|
+
accountUrl,
|
|
130
|
+
apiKey,
|
|
131
|
+
vaultAvailable = true,
|
|
132
|
+
normalizeFn = normalizeAndValidateUrl,
|
|
133
|
+
setApiUrlFn = setApiUrl,
|
|
134
|
+
setAccountUrlFn = setAccountUrl,
|
|
135
|
+
setApiKeyFn = setApiKey,
|
|
136
|
+
setVaultAvailableFn = setVaultAvailable,
|
|
137
|
+
}) {
|
|
138
|
+
const normalizedApiUrl = normalizeFn(apiUrl);
|
|
139
|
+
const normalizedAccountUrl = normalizeFn(accountUrl);
|
|
140
|
+
|
|
141
|
+
setApiUrlFn(normalizedApiUrl);
|
|
142
|
+
setAccountUrlFn(normalizedAccountUrl);
|
|
143
|
+
setApiKeyFn(apiKey);
|
|
144
|
+
setVaultAvailableFn(vaultAvailable);
|
|
145
|
+
}
|
|
146
|
+
|
|
126
147
|
function getDeviceName() {
|
|
127
148
|
const os = process.platform;
|
|
128
149
|
const hostname = process.env.HOSTNAME || process.env.COMPUTERNAME || 'CLI';
|
|
@@ -181,10 +202,12 @@ export async function login(options) {
|
|
|
181
202
|
});
|
|
182
203
|
|
|
183
204
|
// Step 5: Store credentials
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
205
|
+
persistLoginState({
|
|
206
|
+
apiUrl,
|
|
207
|
+
accountUrl,
|
|
208
|
+
apiKey: result.api_key,
|
|
209
|
+
vaultAvailable: true,
|
|
210
|
+
});
|
|
188
211
|
|
|
189
212
|
spinner.succeed(chalk.green('Logged in successfully!'));
|
|
190
213
|
console.log(`\nWelcome, ${chalk.cyan(result.email)}!`);
|
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
|
@@ -9,6 +9,20 @@ function execGit(...args) {
|
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export function sanitizeGitRemote(remote) {
|
|
13
|
+
if (!remote) return remote;
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(remote);
|
|
16
|
+
url.username = '';
|
|
17
|
+
url.password = '';
|
|
18
|
+
url.search = '';
|
|
19
|
+
url.hash = '';
|
|
20
|
+
return `${url.origin}${url.pathname}`;
|
|
21
|
+
} catch {
|
|
22
|
+
return remote.replace(/\/\/[^@]+@/, '//');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
12
26
|
export function getGitContext() {
|
|
13
27
|
const level = getCaptureLevel();
|
|
14
28
|
|
|
@@ -28,7 +42,7 @@ export function getGitContext() {
|
|
|
28
42
|
|
|
29
43
|
// Rich: add remote URL and commit hash
|
|
30
44
|
if (level === 'rich' && topLevel) {
|
|
31
|
-
context.git_remote_url = execGit('remote', 'get-url', 'origin');
|
|
45
|
+
context.git_remote_url = sanitizeGitRemote(execGit('remote', 'get-url', 'origin'));
|
|
32
46
|
context.git_commit = execGit('rev-parse', 'HEAD');
|
|
33
47
|
}
|
|
34
48
|
|
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
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { sanitizeGitRemote } from '../src/lib/context.js';
|
|
4
|
+
|
|
5
|
+
describe('sanitizeGitRemote', () => {
|
|
6
|
+
it('strips credentials and query/fragment', () => {
|
|
7
|
+
const input = 'https://token:secret@github.com/org/repo.git?foo=bar#frag';
|
|
8
|
+
const output = sanitizeGitRemote(input);
|
|
9
|
+
assert.equal(output, 'https://github.com/org/repo.git');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('removes userinfo from https URL', () => {
|
|
13
|
+
const input = 'https://token@github.com/org/repo.git';
|
|
14
|
+
const output = sanitizeGitRemote(input);
|
|
15
|
+
assert.equal(output, 'https://github.com/org/repo.git');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('leaves scp-style remotes intact', () => {
|
|
19
|
+
const input = 'git@github.com:org/repo.git';
|
|
20
|
+
const output = sanitizeGitRemote(input);
|
|
21
|
+
assert.equal(output, input);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -118,4 +118,32 @@ describe('device login helpers', () => {
|
|
|
118
118
|
assert.equal(exchangeCalled, false);
|
|
119
119
|
});
|
|
120
120
|
});
|
|
121
|
+
|
|
122
|
+
describe('persistLoginState', () => {
|
|
123
|
+
it('does not persist when validation fails', () => {
|
|
124
|
+
const { persistLoginState } = login;
|
|
125
|
+
assert.equal(typeof persistLoginState, 'function', 'persistLoginState should be exported');
|
|
126
|
+
|
|
127
|
+
const calls = [];
|
|
128
|
+
const normalizeFn = () => {
|
|
129
|
+
throw new Error('invalid url');
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
assert.throws(
|
|
133
|
+
() => persistLoginState({
|
|
134
|
+
apiUrl: 'http://bad',
|
|
135
|
+
accountUrl: 'http://bad',
|
|
136
|
+
apiKey: 'api-key',
|
|
137
|
+
normalizeFn,
|
|
138
|
+
setApiUrlFn: () => calls.push('apiUrl'),
|
|
139
|
+
setAccountUrlFn: () => calls.push('accountUrl'),
|
|
140
|
+
setApiKeyFn: () => calls.push('apiKey'),
|
|
141
|
+
setVaultAvailableFn: () => calls.push('vaultAvailable'),
|
|
142
|
+
}),
|
|
143
|
+
/invalid url/i,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
assert.deepEqual(calls, []);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
121
149
|
});
|
package/tests/keychain.test.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import { describe, it } from 'node:test';
|
|
1
|
+
import { describe, it, mock } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
+
import keytar from 'keytar';
|
|
3
4
|
import * as keychain from '../src/lib/keychain.js';
|
|
5
|
+
import { loadVaultKey, storeVaultKey } from '../src/lib/keychain.js';
|
|
6
|
+
|
|
7
|
+
const ORIGINAL_ENV = { ...process.env };
|
|
8
|
+
|
|
9
|
+
function resetEnv() {
|
|
10
|
+
process.env = { ...ORIGINAL_ENV };
|
|
11
|
+
}
|
|
4
12
|
|
|
5
13
|
describe('requireVaultKey', () => {
|
|
6
14
|
it('returns key when available', async () => {
|
|
@@ -38,3 +46,28 @@ describe('requireVaultKey', () => {
|
|
|
38
46
|
assert.equal(key, null);
|
|
39
47
|
});
|
|
40
48
|
});
|
|
49
|
+
|
|
50
|
+
describe('vault key env handling', () => {
|
|
51
|
+
it('prefers PC_VAULT_KEY when set', async () => {
|
|
52
|
+
resetEnv();
|
|
53
|
+
process.env.PC_VAULT_KEY = 'env-key';
|
|
54
|
+
mock.method(keytar, 'getPassword', async () => {
|
|
55
|
+
throw new Error('should not call keytar');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const key = await loadVaultKey();
|
|
59
|
+
assert.equal(key, 'env-key');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('throws when keytar fails and fallback disabled', async () => {
|
|
63
|
+
resetEnv();
|
|
64
|
+
mock.method(keytar, 'setPassword', async () => {
|
|
65
|
+
throw new Error('keytar locked');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await assert.rejects(
|
|
69
|
+
() => storeVaultKey('vault-key'),
|
|
70
|
+
/keytar locked/i,
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
});
|