@papercraneai/cli 1.4.3 → 1.5.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.
package/bin/papercrane.js CHANGED
@@ -4,17 +4,59 @@ import { Command } from 'commander';
4
4
  import chalk from 'chalk';
5
5
  import readline from 'readline';
6
6
  import fs from 'fs/promises';
7
- import { ProxyAgent, setGlobalDispatcher } from 'undici';
7
+ import http from 'http';
8
+ import { http as httpClient } from '../lib/axios-client.js';
8
9
  import { setApiKey, clearConfig, isLoggedIn, setDefaultWorkspace, getDefaultWorkspace } from '../lib/config.js';
9
10
  import { validateApiKey } from '../lib/cloud-client.js';
10
11
  import { listFunctions, getFunction, runFunction, formatDescribe, formatDescribeRoot, formatFlat, formatResult, formatUnconnected } from '../lib/function-client.js';
11
12
  import { listWorkspaces, resolveWorkspaceId, getFileTree, readFile, writeFile, editFile, deleteFile, getLocalWorkspacePath, pullWorkspace, pushWorkspace } from '../lib/environment-client.js';
12
13
 
13
- // Configure proxy support for environments like Claude's container
14
- // Node.js native fetch() doesn't respect HTTP_PROXY/HTTPS_PROXY env vars
15
- const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
16
- if (proxyUrl) {
17
- setGlobalDispatcher(new ProxyAgent(proxyUrl));
14
+ /**
15
+ * Test if browser can actually open URLs by starting a local server and
16
+ * checking if we receive a request when we try to open it.
17
+ * @param {number} timeout - Max time to wait in ms
18
+ * @returns {Promise<boolean>} - true if browser can open URLs
19
+ */
20
+ async function canOpenBrowser(timeout = 2500) {
21
+ return new Promise((resolve) => {
22
+ let resolved = false;
23
+ const done = (result) => {
24
+ if (!resolved) {
25
+ resolved = true;
26
+ resolve(result);
27
+ }
28
+ };
29
+
30
+ const server = http.createServer((req, res) => {
31
+ res.writeHead(200, { 'Content-Type': 'text/html' });
32
+ res.end('<html><body><script>window.close()</script>Checking browser... you can close this tab.</body></html>');
33
+ server.close();
34
+ done(true);
35
+ });
36
+
37
+ server.on('error', () => done(false));
38
+
39
+ server.listen(0, '127.0.0.1', async () => {
40
+ const port = server.address().port;
41
+ const checkUrl = `http://127.0.0.1:${port}/browser-check`;
42
+
43
+ try {
44
+ const open = (await import('open')).default;
45
+ await open(checkUrl);
46
+ } catch {
47
+ // open() threw an error
48
+ server.close();
49
+ done(false);
50
+ return;
51
+ }
52
+
53
+ // Wait for browser to hit our server
54
+ setTimeout(() => {
55
+ server.close();
56
+ done(false);
57
+ }, timeout);
58
+ });
59
+ });
18
60
  }
19
61
 
20
62
  const program = new Command();
@@ -29,8 +71,8 @@ program
29
71
  .description('Login to Papercrane. Opens browser for authentication, or use --api-key for direct login.')
30
72
  .option('--api-key <key>', 'API key for direct login (skips browser)')
31
73
  .option('--url <url>', 'API base URL (saves to config)')
32
- .option('--nowait', 'Print auth URL and exit immediately (for AI assistants)')
33
- .option('--check', 'Check if pending login was completed')
74
+ .option('--print-url', 'Print auth URL and exit (use with --complete after authorizing)')
75
+ .option('--complete', 'Complete a pending login started with --print-url')
34
76
  .action(async (options) => {
35
77
  try {
36
78
  const { getApiBaseUrl, setApiBaseUrl, getPendingSession, setPendingSession, clearPendingSession } = await import('../lib/config.js');
@@ -61,18 +103,20 @@ program
61
103
  return;
62
104
  }
63
105
 
64
- // --check: Check if pending login was completed
65
- if (options.check) {
106
+ // --complete: Check if pending login was completed
107
+ if (options.complete) {
66
108
  const pending = await getPendingSession();
67
109
  if (!pending) {
68
- console.log(chalk.yellow('No pending login session. Run: papercrane login --nowait'));
110
+ console.log(chalk.yellow('No pending login session. Run: papercrane login --print-url'));
69
111
  process.exit(1);
70
112
  }
71
113
 
72
- const statusRes = await fetch(`${pending.baseUrl}/api/cli-auth/status?session=${encodeURIComponent(pending.session)}`);
114
+ const statusRes = await httpClient.get(`${pending.baseUrl}/api/cli-auth/status?session=${encodeURIComponent(pending.session)}`, {
115
+ validateStatus: () => true
116
+ });
73
117
 
74
- if (statusRes.ok) {
75
- const data = await statusRes.json();
118
+ if (statusRes.status >= 200 && statusRes.status < 300) {
119
+ const data = statusRes.data;
76
120
  if (data.status === 'authorized') {
77
121
  await setApiKey(data.key);
78
122
  await setApiBaseUrl(pending.baseUrl);
@@ -83,7 +127,22 @@ program
83
127
  }
84
128
  }
85
129
 
86
- console.log(chalk.yellow('Not yet authorized. Please click the login URL, then run: papercrane login --check'));
130
+ console.log(chalk.yellow('Not yet authorized. Please click the login URL, then run: papercrane login --complete'));
131
+ process.exit(1);
132
+ }
133
+
134
+ // Check server connectivity before starting auth flow
135
+ // Extract domain from baseUrl for allowlist message
136
+ const urlDomain = new URL(baseUrl).hostname;
137
+ try {
138
+ await httpClient.get(`${baseUrl}/api/cli-auth/status?session=connectivity-check`, { timeout: 5000 });
139
+ } catch (connectError) {
140
+ console.log(chalk.yellow('\n⚠️ Cannot reach Papercrane server.'));
141
+ console.log(chalk.dim(`\nCould not connect to: ${baseUrl}`));
142
+ console.log(chalk.dim('\nIf you\'re using Claude Desktop or Claude app:'));
143
+ console.log(chalk.dim(' 1. Go to Settings → Capabilities → Domain allowlist'));
144
+ console.log(chalk.dim(` 2. Add: ${urlDomain}`));
145
+ console.log(chalk.dim(' 3. Try again\n'));
87
146
  process.exit(1);
88
147
  }
89
148
 
@@ -93,39 +152,52 @@ program
93
152
  const session = generateState();
94
153
 
95
154
  // Initialize session on server
96
- const initRes = await fetch(`${baseUrl}/api/cli-auth/init`, {
97
- method: 'POST',
155
+ const initRes = await httpClient.post(`${baseUrl}/api/cli-auth/init`, { session }, {
98
156
  headers: { 'Content-Type': 'application/json' },
99
- body: JSON.stringify({ session })
157
+ validateStatus: () => true
100
158
  });
101
159
 
102
- if (!initRes.ok) {
160
+ if (initRes.status < 200 || initRes.status >= 300) {
103
161
  throw new Error('Failed to initialize login session');
104
162
  }
105
- // Drain response body to avoid keeping the event loop alive (undici)
106
- await initRes.arrayBuffer();
107
163
 
108
164
  const authUrl = `${baseUrl}/cli-auth?session=${session}`;
109
165
 
110
- // --nowait: Print URL and exit immediately (for AI assistants)
111
- if (options.nowait) {
166
+ // --print-url: Print URL and exit immediately (for AI assistants)
167
+ if (options.printUrl) {
112
168
  await setPendingSession({ session, baseUrl });
113
169
  console.log(chalk.cyan('\nOpen this URL to authenticate:\n'));
114
170
  console.log(` ${authUrl}\n`);
115
- console.log(chalk.dim('After authorizing, run: papercrane login --check\n'));
171
+ console.log(chalk.dim('After authorizing, run: papercrane login --complete\n'));
116
172
  process.exit(0);
117
173
  }
118
174
 
175
+ // Test if browser can actually open URLs
176
+ console.log(chalk.dim('\nChecking browser availability...'));
177
+ const browserWorks = await canOpenBrowser();
178
+
119
179
  console.log(chalk.cyan('\nOpen this URL to authenticate:\n'));
120
180
  console.log(` ${authUrl}\n`);
121
181
 
122
- // Try to open browser automatically
182
+ if (!browserWorks) {
183
+ // Browser can't open - save session and exit (non-blocking)
184
+ await setPendingSession({ session, baseUrl });
185
+ console.log(chalk.yellow('Browser could not be opened automatically.'));
186
+ console.log(chalk.dim('\nPlease open the URL above manually, then run: papercrane login --complete\n'));
187
+ process.exit(0);
188
+ }
189
+
190
+ // Browser works - open the auth URL and poll
123
191
  try {
124
192
  const open = (await import('open')).default;
125
193
  await open(authUrl);
126
194
  console.log(chalk.dim('(Browser opened automatically)'));
127
195
  } catch {
128
- console.log(chalk.yellow('Could not open browser automatically. Please click or copy the URL above.'));
196
+ // Shouldn't happen since canOpenBrowser passed, but handle it
197
+ await setPendingSession({ session, baseUrl });
198
+ console.log(chalk.yellow('Could not open browser.'));
199
+ console.log(chalk.dim('\nPlease open the URL above manually, then run: papercrane login --complete\n'));
200
+ process.exit(0);
129
201
  }
130
202
 
131
203
  console.log(chalk.dim('\nWaiting for authorization... (press Ctrl+C to cancel)\n'));
@@ -136,10 +208,12 @@ program
136
208
  const startTime = Date.now();
137
209
 
138
210
  while (Date.now() - startTime < timeout) {
139
- const statusRes = await fetch(`${baseUrl}/api/cli-auth/status?session=${encodeURIComponent(session)}`);
211
+ const statusRes = await httpClient.get(`${baseUrl}/api/cli-auth/status?session=${encodeURIComponent(session)}`, {
212
+ validateStatus: () => true
213
+ });
140
214
 
141
- if (statusRes.ok) {
142
- const data = await statusRes.json();
215
+ if (statusRes.status >= 200 && statusRes.status < 300) {
216
+ const data = statusRes.data;
143
217
 
144
218
  if (data.status === 'authorized') {
145
219
  // Save the API key
@@ -0,0 +1,14 @@
1
+ import axios from "axios"
2
+ import { HttpsProxyAgent } from "https-proxy-agent"
3
+
4
+ const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY
5
+
6
+ const axiosConfig = proxyUrl
7
+ ? {
8
+ httpAgent: new HttpsProxyAgent(proxyUrl),
9
+ httpsAgent: new HttpsProxyAgent(proxyUrl),
10
+ proxy: false
11
+ }
12
+ : {}
13
+
14
+ export const http = axios.create(axiosConfig)
@@ -1,4 +1,4 @@
1
- import axios from 'axios';
1
+ import { http } from './axios-client.js';
2
2
  import { getApiKey, getApiBaseUrl } from './config.js';
3
3
 
4
4
  /**
@@ -16,7 +16,7 @@ export async function fetchCloudCredentials(provider, instanceName = 'Default')
16
16
  const baseUrl = await getApiBaseUrl();
17
17
 
18
18
  try {
19
- const response = await axios.get(`${baseUrl}/api/sdk/credentials/${provider}/${instanceName}`, {
19
+ const response = await http.get(`${baseUrl}/api/sdk/credentials/${provider}/${instanceName}`, {
20
20
  headers: {
21
21
  'Authorization': `Bearer ${apiKey}`
22
22
  }
@@ -52,7 +52,7 @@ export async function listCloudCredentials() {
52
52
  const baseUrl = await getApiBaseUrl();
53
53
 
54
54
  try {
55
- const response = await axios.get(`${baseUrl}/api/sdk/credentials`, {
55
+ const response = await http.get(`${baseUrl}/api/sdk/credentials`, {
56
56
  headers: {
57
57
  'Authorization': `Bearer ${apiKey}`
58
58
  }
@@ -84,7 +84,7 @@ export async function validateApiKey() {
84
84
  // We expect either a 404 (integration not found, but key is valid)
85
85
  // or a 200 (key is valid and integration exists)
86
86
  // A 401 means the key is invalid
87
- await axios.get(`${baseUrl}/api/sdk/credentials/google/Default`, {
87
+ await http.get(`${baseUrl}/api/sdk/credentials/google/Default`, {
88
88
  headers: {
89
89
  'Authorization': `Bearer ${apiKey}`
90
90
  }
@@ -120,7 +120,7 @@ export async function refreshCloudCredentials(provider, instanceName = 'Default'
120
120
  const baseUrl = await getApiBaseUrl();
121
121
 
122
122
  try {
123
- const response = await axios.post(`${baseUrl}/api/sdk/credentials/${provider}/${instanceName}/refresh`, {}, {
123
+ const response = await http.post(`${baseUrl}/api/sdk/credentials/${provider}/${instanceName}/refresh`, {}, {
124
124
  headers: {
125
125
  'Authorization': `Bearer ${apiKey}`
126
126
  }
@@ -155,7 +155,7 @@ export async function pushCredentials(provider, credentialId, credentials, scope
155
155
 
156
156
  const baseUrl = await getApiBaseUrl();
157
157
 
158
- const response = await axios.post(`${baseUrl}/api/sdk/credentials/push`, {
158
+ const response = await http.post(`${baseUrl}/api/sdk/credentials/push`, {
159
159
  provider,
160
160
  credential_id: credentialId,
161
161
  credentials,
@@ -183,11 +183,11 @@ export async function pullCredentials() {
183
183
 
184
184
  const baseUrl = await getApiBaseUrl();
185
185
 
186
- const response = await axios.get(`${baseUrl}/api/sdk/credentials/pull`, {
186
+ const response = await http.get(`${baseUrl}/api/sdk/credentials/pull`, {
187
187
  headers: {
188
188
  'Authorization': `Bearer ${apiKey}`
189
189
  }
190
190
  });
191
191
 
192
192
  return response.data.credentials || [];
193
- }
193
+ }
package/lib/config.js CHANGED
@@ -125,7 +125,7 @@ export async function clearDefaultWorkspace() {
125
125
  }
126
126
 
127
127
  /**
128
- * Get pending login session (for --nowait / --check flow)
128
+ * Get pending login session (for --print-url / --complete flow)
129
129
  * @returns {Promise<{session: string, baseUrl: string}|null>}
130
130
  */
131
131
  export async function getPendingSession() {
@@ -1,4 +1,4 @@
1
- import axios from 'axios';
1
+ import { http } from './axios-client.js';
2
2
  import { homedir } from 'os';
3
3
  import { join } from 'path';
4
4
  import { getApiKey, getApiBaseUrl, getDefaultWorkspace, setDefaultWorkspace } from './config.js';
@@ -62,7 +62,7 @@ export async function listWorkspaces() {
62
62
  const headers = await getAuthHeaders();
63
63
  const baseUrl = await getApiBaseUrl();
64
64
 
65
- const response = await axios.get(`${baseUrl}/sdk/workspaces`, { headers });
65
+ const response = await http.get(`${baseUrl}/sdk/workspaces`, { headers });
66
66
  return response.data.workspaces;
67
67
  }
68
68
 
@@ -80,7 +80,7 @@ export async function getFileTree(workspaceId, path = '') {
80
80
  ? `${baseUrl}/sdk/workspaces/${workspaceId}/files?path=${encodeURIComponent(path)}`
81
81
  : `${baseUrl}/sdk/workspaces/${workspaceId}/files`;
82
82
 
83
- const response = await axios.get(url, { headers });
83
+ const response = await http.get(url, { headers });
84
84
  return response.data;
85
85
  }
86
86
 
@@ -94,7 +94,7 @@ export async function readFile(workspaceId, path) {
94
94
  const headers = await getAuthHeaders();
95
95
  const baseUrl = await getApiBaseUrl();
96
96
 
97
- const response = await axios.get(
97
+ const response = await http.get(
98
98
  `${baseUrl}/sdk/workspaces/${workspaceId}/files/read?path=${encodeURIComponent(path)}`,
99
99
  { headers }
100
100
  );
@@ -112,7 +112,7 @@ export async function writeFile(workspaceId, path, content) {
112
112
  const headers = await getAuthHeaders();
113
113
  const baseUrl = await getApiBaseUrl();
114
114
 
115
- const response = await axios.post(
115
+ const response = await http.post(
116
116
  `${baseUrl}/sdk/workspaces/${workspaceId}/files/write`,
117
117
  { path, content },
118
118
  { headers }
@@ -133,7 +133,7 @@ export async function editFile(workspaceId, path, oldString, newString, replaceA
133
133
  const headers = await getAuthHeaders();
134
134
  const baseUrl = await getApiBaseUrl();
135
135
 
136
- const response = await axios.post(
136
+ const response = await http.post(
137
137
  `${baseUrl}/sdk/workspaces/${workspaceId}/files/edit`,
138
138
  { path, old_string: oldString, new_string: newString, replace_all: replaceAll },
139
139
  { headers }
@@ -151,7 +151,7 @@ export async function deleteFile(workspaceId, path) {
151
151
  const headers = await getAuthHeaders();
152
152
  const baseUrl = await getApiBaseUrl();
153
153
 
154
- const response = await axios.delete(
154
+ const response = await http.delete(
155
155
  `${baseUrl}/sdk/workspaces/${workspaceId}/files?path=${encodeURIComponent(path)}`,
156
156
  { headers }
157
157
  );
@@ -1,4 +1,4 @@
1
- import axios from 'axios';
1
+ import { http } from './axios-client.js';
2
2
  import chalk from 'chalk';
3
3
  import { saveFacebookCredentials } from './storage.js';
4
4
 
@@ -46,7 +46,7 @@ export async function handleFacebookAuth(scopes, appId, clientToken) {
46
46
 
47
47
  // Step 1: Request device code
48
48
  console.log(chalk.cyan('📱 Requesting device code...'));
49
- const deviceResponse = await axios.post(DEVICE_LOGIN_URL, null, {
49
+ const deviceResponse = await http.post(DEVICE_LOGIN_URL, null, {
50
50
  params: {
51
51
  access_token: accessToken,
52
52
  scope: scopeString
@@ -80,7 +80,7 @@ export async function handleFacebookAuth(scopes, appId, clientToken) {
80
80
  await new Promise(resolve => setTimeout(resolve, pollInterval));
81
81
 
82
82
  try {
83
- const statusResponse = await axios.post(DEVICE_STATUS_URL, null, {
83
+ const statusResponse = await http.post(DEVICE_STATUS_URL, null, {
84
84
  params: {
85
85
  access_token: accessToken,
86
86
  code: code
@@ -145,4 +145,4 @@ export async function handleFacebookAuth(scopes, appId, clientToken) {
145
145
  throw error;
146
146
  }
147
147
  }
148
- }
148
+ }
@@ -1,4 +1,4 @@
1
- import axios from "axios"
1
+ import { http } from "./axios-client.js"
2
2
  import chalk from "chalk"
3
3
  import { getApiKey, getApiBaseUrl } from "./config.js"
4
4
 
@@ -55,7 +55,7 @@ async function functionRequest(
55
55
 
56
56
  // For POST requests, use streaming to handle large/streaming responses
57
57
  if (method === "POST") {
58
- const response = await axios({
58
+ const response = await http({
59
59
  method,
60
60
  url,
61
61
  headers: {
@@ -125,7 +125,7 @@ async function functionRequest(
125
125
  }
126
126
 
127
127
  // GET requests - use regular axios behavior
128
- const response = await axios({
128
+ const response = await http({
129
129
  method,
130
130
  url,
131
131
  headers: {
@@ -1,4 +1,4 @@
1
- import axios from 'axios';
1
+ import { http } from './axios-client.js';
2
2
  import chalk from 'chalk';
3
3
  import open from 'open';
4
4
  import { generatePKCE } from './pkce.js';
@@ -88,7 +88,7 @@ export async function handleGoogleAuth(scopes, clientId, clientSecret) {
88
88
  // Exchange authorization code for tokens
89
89
  console.log(chalk.cyan('🔄 Exchanging authorization code for tokens...'));
90
90
 
91
- const tokenResponse = await axios.post(
91
+ const tokenResponse = await http.post(
92
92
  GOOGLE_TOKEN_URL,
93
93
  new URLSearchParams({
94
94
  code,
@@ -131,4 +131,4 @@ export async function handleGoogleAuth(scopes, clientId, clientSecret) {
131
131
  throw error;
132
132
  }
133
133
  }
134
- }
134
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papercraneai/cli",
3
- "version": "1.4.3",
3
+ "version": "1.5.0",
4
4
  "description": "CLI tool for managing OAuth credentials for LLM integrations",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -29,8 +29,8 @@
29
29
  "commander": "^12.0.0",
30
30
  "axios": "^1.6.0",
31
31
  "chalk": "^4.1.2",
32
+ "https-proxy-agent": "^7.0.4",
32
33
  "inquirer": "^8.2.6",
33
- "open": "^8.4.2",
34
- "undici": "^6.0.0"
34
+ "open": "^8.4.2"
35
35
  }
36
36
  }