@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptcellar/pc",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
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",
@@ -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
- setApiKey(result.api_key);
185
- setApiUrl(apiUrl);
186
- setAccountUrl(accountUrl);
187
- setVaultAvailable(true);
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() {
@@ -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
 
@@ -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
- // Keychain unavailable use file fallback
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
  });
@@ -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
+ });