atris 2.6.2 → 2.6.3
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/commands/pull.js +74 -19
- package/commands/push.js +132 -21
- package/package.json +1 -1
- package/utils/api.js +8 -1
package/commands/pull.js
CHANGED
|
@@ -10,7 +10,20 @@ const { loadBusinesses } = require('./business');
|
|
|
10
10
|
const { loadManifest, saveManifest, computeFileHash, buildManifest, computeLocalHashes, threeWayCompare } = require('../lib/manifest');
|
|
11
11
|
|
|
12
12
|
async function pullAtris() {
|
|
13
|
-
|
|
13
|
+
let arg = process.argv[3];
|
|
14
|
+
|
|
15
|
+
// Auto-detect business from .atris/business.json in current dir
|
|
16
|
+
if (!arg || arg.startsWith('--')) {
|
|
17
|
+
const bizFile = path.join(process.cwd(), '.atris', 'business.json');
|
|
18
|
+
if (fs.existsSync(bizFile)) {
|
|
19
|
+
try {
|
|
20
|
+
const biz = JSON.parse(fs.readFileSync(bizFile, 'utf8'));
|
|
21
|
+
if (biz.slug || biz.name) {
|
|
22
|
+
return pullBusiness(biz.slug || biz.name);
|
|
23
|
+
}
|
|
24
|
+
} catch {}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
14
27
|
|
|
15
28
|
// If a business name is given, do a business pull
|
|
16
29
|
if (arg && arg !== '--help' && !arg.startsWith('--')) {
|
|
@@ -81,21 +94,38 @@ async function pullBusiness(slug) {
|
|
|
81
94
|
const force = process.argv.includes('--force');
|
|
82
95
|
|
|
83
96
|
// Parse --only flag: comma-separated directory prefixes to filter
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
97
|
+
// Supports both --only=team/,context/ and --only team/,context/
|
|
98
|
+
let onlyRaw = null;
|
|
99
|
+
const onlyEqArg = process.argv.find(a => a.startsWith('--only='));
|
|
100
|
+
if (onlyEqArg) {
|
|
101
|
+
onlyRaw = onlyEqArg.slice('--only='.length);
|
|
102
|
+
} else {
|
|
103
|
+
const onlyIdx = process.argv.indexOf('--only');
|
|
104
|
+
if (onlyIdx !== -1 && process.argv[onlyIdx + 1] && !process.argv[onlyIdx + 1].startsWith('-')) {
|
|
105
|
+
onlyRaw = process.argv[onlyIdx + 1];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const onlyPrefixes = onlyRaw
|
|
109
|
+
? onlyRaw.split(',').map(p => {
|
|
88
110
|
let norm = p.replace(/^\//, '');
|
|
89
111
|
if (norm && !norm.endsWith('/') && !norm.includes('.')) norm += '/';
|
|
90
112
|
return norm;
|
|
91
113
|
}).filter(Boolean)
|
|
92
114
|
: null;
|
|
93
115
|
|
|
94
|
-
// Parse --timeout flag: override default
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
116
|
+
// Parse --timeout flag: override default 300s timeout
|
|
117
|
+
// Supports both --timeout=60 and --timeout 60
|
|
118
|
+
let timeoutSec = 300;
|
|
119
|
+
const timeoutEqArg = process.argv.find(a => a.startsWith('--timeout='));
|
|
120
|
+
if (timeoutEqArg) {
|
|
121
|
+
timeoutSec = parseInt(timeoutEqArg.slice('--timeout='.length), 10);
|
|
122
|
+
} else {
|
|
123
|
+
const timeoutIdx = process.argv.indexOf('--timeout');
|
|
124
|
+
if (timeoutIdx !== -1 && process.argv[timeoutIdx + 1]) {
|
|
125
|
+
timeoutSec = parseInt(process.argv[timeoutIdx + 1], 10);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const timeoutMs = timeoutSec * 1000;
|
|
99
129
|
|
|
100
130
|
// Determine output directory
|
|
101
131
|
const intoIdx = process.argv.indexOf('--into');
|
|
@@ -163,18 +193,31 @@ async function pullBusiness(slug) {
|
|
|
163
193
|
|
|
164
194
|
console.log('');
|
|
165
195
|
console.log(`Pulling ${businessName}...` + (timeSince ? ` (last synced ${timeSince})` : ''));
|
|
166
|
-
console.log(' Fetching workspace...');
|
|
167
196
|
|
|
168
|
-
//
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
)
|
|
197
|
+
// Loading indicator with elapsed time
|
|
198
|
+
const startTime = Date.now();
|
|
199
|
+
const spinner = ['|', '/', '-', '\\'];
|
|
200
|
+
let spinIdx = 0;
|
|
201
|
+
const loading = setInterval(() => {
|
|
202
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
203
|
+
process.stdout.write(`\r Fetching workspace... ${spinner[spinIdx++ % 4]} ${elapsed}s`);
|
|
204
|
+
}, 250);
|
|
205
|
+
|
|
206
|
+
// Get remote snapshot — pass --only prefixes to server for faster response
|
|
207
|
+
let snapshotUrl = `/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=true`;
|
|
208
|
+
if (onlyPrefixes) {
|
|
209
|
+
snapshotUrl += `&paths=${encodeURIComponent(onlyPrefixes.map(p => p.replace(/\/$/, '')).join(','))}`;
|
|
210
|
+
}
|
|
211
|
+
const result = await apiRequestJson(snapshotUrl, { method: 'GET', token: creds.token, timeoutMs });
|
|
212
|
+
|
|
213
|
+
clearInterval(loading);
|
|
214
|
+
const totalSec = Math.floor((Date.now() - startTime) / 1000);
|
|
215
|
+
process.stdout.write(`\r Fetched in ${totalSec}s.${' '.repeat(20)}\n`);
|
|
173
216
|
|
|
174
217
|
if (!result.ok) {
|
|
175
218
|
const msg = result.errorMessage || result.error || `HTTP ${result.status}`;
|
|
176
219
|
if (result.status === 0 || (typeof msg === 'string' && msg.toLowerCase().includes('timeout'))) {
|
|
177
|
-
console.error(`\n Workspace
|
|
220
|
+
console.error(`\n Workspace timed out (large workspaces can take 60s+). Try: atris pull ${slug} --timeout=600`);
|
|
178
221
|
} else if (result.status === 409) {
|
|
179
222
|
console.error(`\n Computer is sleeping. Wake it first, then pull again.`);
|
|
180
223
|
} else if (result.status === 403) {
|
|
@@ -226,9 +269,11 @@ async function pullBusiness(slug) {
|
|
|
226
269
|
// Compute local file hashes
|
|
227
270
|
const localFiles = fs.existsSync(outputDir) ? computeLocalHashes(outputDir) : {};
|
|
228
271
|
|
|
272
|
+
// If output dir is empty (fresh clone) or --force, treat as first sync — pull everything
|
|
273
|
+
const effectiveManifest = (Object.keys(localFiles).length === 0 || force) ? null : manifest;
|
|
274
|
+
|
|
229
275
|
// Three-way compare
|
|
230
|
-
const
|
|
231
|
-
const diff = threeWayCompare(localFiles, remoteFiles, manifest);
|
|
276
|
+
const diff = threeWayCompare(localFiles, remoteFiles, effectiveManifest);
|
|
232
277
|
|
|
233
278
|
// Apply changes
|
|
234
279
|
let pulled = 0;
|
|
@@ -316,6 +361,16 @@ async function pullBusiness(slug) {
|
|
|
316
361
|
}
|
|
317
362
|
const newManifest = buildManifest(manifestFiles, commitHash);
|
|
318
363
|
saveManifest(resolvedSlug || slug, newManifest);
|
|
364
|
+
|
|
365
|
+
// Save business config in the output dir so push/status work without args
|
|
366
|
+
const atrisDir = path.join(outputDir, '.atris');
|
|
367
|
+
fs.mkdirSync(atrisDir, { recursive: true });
|
|
368
|
+
fs.writeFileSync(path.join(atrisDir, 'business.json'), JSON.stringify({
|
|
369
|
+
slug: resolvedSlug || slug,
|
|
370
|
+
business_id: businessId,
|
|
371
|
+
workspace_id: workspaceId,
|
|
372
|
+
name: businessName,
|
|
373
|
+
}, null, 2));
|
|
319
374
|
}
|
|
320
375
|
|
|
321
376
|
|
package/commands/push.js
CHANGED
|
@@ -4,28 +4,64 @@ const { loadCredentials } = require('../utils/auth');
|
|
|
4
4
|
const { apiRequestJson } = require('../utils/api');
|
|
5
5
|
const { loadBusinesses, saveBusinesses } = require('./business');
|
|
6
6
|
const { loadManifest, saveManifest, computeFileHash, buildManifest, computeLocalHashes, threeWayCompare, SKIP_DIRS } = require('../lib/manifest');
|
|
7
|
+
const { sectionMerge } = require('../lib/section-merge');
|
|
7
8
|
|
|
8
9
|
async function pushAtris() {
|
|
9
|
-
|
|
10
|
+
let slug = process.argv[3];
|
|
11
|
+
|
|
12
|
+
// Auto-detect business from .atris/business.json in current dir
|
|
13
|
+
if (!slug || slug.startsWith('-')) {
|
|
14
|
+
const bizFile = path.join(process.cwd(), '.atris', 'business.json');
|
|
15
|
+
if (fs.existsSync(bizFile)) {
|
|
16
|
+
try {
|
|
17
|
+
const biz = JSON.parse(fs.readFileSync(bizFile, 'utf8'));
|
|
18
|
+
slug = biz.slug || biz.name;
|
|
19
|
+
} catch {}
|
|
20
|
+
}
|
|
21
|
+
// If still no slug (no .atris/business.json), need explicit name
|
|
22
|
+
if (!slug || slug.startsWith('-')) {
|
|
23
|
+
slug = null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
10
26
|
|
|
11
27
|
if (!slug || slug === '--help') {
|
|
12
|
-
console.log('Usage: atris push
|
|
28
|
+
console.log('Usage: atris push [business-slug] [--from <path>] [--force]');
|
|
13
29
|
console.log('');
|
|
14
30
|
console.log('Push local files to a Business Computer.');
|
|
31
|
+
console.log('If run inside a pulled folder, business is auto-detected.');
|
|
15
32
|
console.log('');
|
|
16
33
|
console.log('Options:');
|
|
17
34
|
console.log(' --from <path> Push from a custom directory');
|
|
18
35
|
console.log(' --force Push everything, overwrite conflicts');
|
|
19
36
|
console.log('');
|
|
20
37
|
console.log('Examples:');
|
|
38
|
+
console.log(' atris push Auto-detect from current folder');
|
|
21
39
|
console.log(' atris push pallet Push from atris/pallet/ or ./pallet/');
|
|
22
40
|
console.log(' atris push pallet --from ./my-dir/ Push from a custom directory');
|
|
23
|
-
console.log(' atris push pallet --force Override conflicts');
|
|
24
41
|
process.exit(0);
|
|
25
42
|
}
|
|
26
43
|
|
|
27
44
|
const force = process.argv.includes('--force');
|
|
28
45
|
|
|
46
|
+
// Parse --only flag: filter which files to push
|
|
47
|
+
let onlyRaw = null;
|
|
48
|
+
const onlyEqArg = process.argv.find(a => a.startsWith('--only='));
|
|
49
|
+
if (onlyEqArg) {
|
|
50
|
+
onlyRaw = onlyEqArg.slice('--only='.length);
|
|
51
|
+
} else {
|
|
52
|
+
const onlyIdx = process.argv.indexOf('--only');
|
|
53
|
+
if (onlyIdx !== -1 && process.argv[onlyIdx + 1] && !process.argv[onlyIdx + 1].startsWith('-')) {
|
|
54
|
+
onlyRaw = process.argv[onlyIdx + 1];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const onlyPrefixes = onlyRaw
|
|
58
|
+
? onlyRaw.split(',').map(p => {
|
|
59
|
+
let norm = p.replace(/^\//, '');
|
|
60
|
+
if (norm && !norm.endsWith('/') && !norm.includes('.')) norm += '/';
|
|
61
|
+
return '/' + norm;
|
|
62
|
+
}).filter(Boolean)
|
|
63
|
+
: null;
|
|
64
|
+
|
|
29
65
|
const creds = loadCredentials();
|
|
30
66
|
if (!creds || !creds.token) {
|
|
31
67
|
console.error('Not logged in. Run: atris login');
|
|
@@ -37,6 +73,9 @@ async function pushAtris() {
|
|
|
37
73
|
let sourceDir;
|
|
38
74
|
if (fromIdx !== -1 && process.argv[fromIdx + 1]) {
|
|
39
75
|
sourceDir = path.resolve(process.argv[fromIdx + 1]);
|
|
76
|
+
} else if (fs.existsSync(path.join(process.cwd(), '.atris', 'business.json'))) {
|
|
77
|
+
// Inside a pulled folder — push from here
|
|
78
|
+
sourceDir = process.cwd();
|
|
40
79
|
} else {
|
|
41
80
|
const atrisDir = path.join(process.cwd(), 'atris', slug);
|
|
42
81
|
const cwdDir = path.join(process.cwd(), slug);
|
|
@@ -114,20 +153,33 @@ async function pushAtris() {
|
|
|
114
153
|
console.log('');
|
|
115
154
|
console.log(`Pushing to ${businessName}...`);
|
|
116
155
|
|
|
117
|
-
//
|
|
156
|
+
// Loading indicator
|
|
157
|
+
const startTime = Date.now();
|
|
158
|
+
const spinner = ['|', '/', '-', '\\'];
|
|
159
|
+
let spinIdx = 0;
|
|
160
|
+
const loading = setInterval(() => {
|
|
161
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
162
|
+
process.stdout.write(`\r Comparing with remote... ${spinner[spinIdx++ % 4]} ${elapsed}s`);
|
|
163
|
+
}, 250);
|
|
164
|
+
|
|
118
165
|
const snapshotResult = await apiRequestJson(
|
|
119
166
|
`/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=true`,
|
|
120
|
-
{ method: 'GET', token: creds.token, timeoutMs:
|
|
167
|
+
{ method: 'GET', token: creds.token, timeoutMs: 300000 }
|
|
121
168
|
);
|
|
122
169
|
|
|
170
|
+
clearInterval(loading);
|
|
171
|
+
const totalSec = Math.floor((Date.now() - startTime) / 1000);
|
|
172
|
+
process.stdout.write(`\r Compared in ${totalSec}s.${' '.repeat(20)}\n`);
|
|
173
|
+
|
|
123
174
|
let remoteFiles = {};
|
|
175
|
+
const remoteContent = {}; // for section merge
|
|
124
176
|
if (snapshotResult.ok && snapshotResult.data && snapshotResult.data.files) {
|
|
125
177
|
for (const file of snapshotResult.data.files) {
|
|
126
178
|
if (file.path && !file.binary && file.content != null) {
|
|
127
|
-
// Compute hash from content (matches how computeLocalHashes works on raw bytes)
|
|
128
179
|
const rawBytes = Buffer.from(file.content, 'utf-8');
|
|
129
180
|
const hash = require('crypto').createHash('sha256').update(rawBytes).digest('hex');
|
|
130
181
|
remoteFiles[file.path] = { hash, size: rawBytes.length };
|
|
182
|
+
remoteContent[file.path] = file.content;
|
|
131
183
|
}
|
|
132
184
|
}
|
|
133
185
|
}
|
|
@@ -135,11 +187,23 @@ async function pushAtris() {
|
|
|
135
187
|
// Three-way compare
|
|
136
188
|
const diff = threeWayCompare(localFiles, remoteFiles, manifest);
|
|
137
189
|
|
|
190
|
+
// Check if user is a member (not owner) — if so, filter to allowed paths
|
|
191
|
+
// Members can only push to /team/{name}/ and /journal/
|
|
192
|
+
let skippedPermission = [];
|
|
193
|
+
const role = snapshotResult.data?._role; // not available from snapshot, so we try the push and handle 403
|
|
194
|
+
|
|
138
195
|
// Determine what to push
|
|
139
196
|
const filesToPush = [];
|
|
140
197
|
|
|
198
|
+
// Apply --only filter
|
|
199
|
+
const matchesOnly = (filePath) => {
|
|
200
|
+
if (!onlyPrefixes) return true;
|
|
201
|
+
return onlyPrefixes.some(prefix => filePath.startsWith(prefix));
|
|
202
|
+
};
|
|
203
|
+
|
|
141
204
|
// Files we changed that remote didn't
|
|
142
205
|
for (const p of [...diff.toPush, ...diff.newLocal]) {
|
|
206
|
+
if (!matchesOnly(p)) continue;
|
|
143
207
|
const localPath = path.join(sourceDir, p.replace(/^\//, ''));
|
|
144
208
|
try {
|
|
145
209
|
const content = fs.readFileSync(localPath, 'utf8');
|
|
@@ -149,22 +213,38 @@ async function pushAtris() {
|
|
|
149
213
|
}
|
|
150
214
|
}
|
|
151
215
|
|
|
152
|
-
//
|
|
216
|
+
// Handle conflicts: try section-level merge first, then force, then flag
|
|
153
217
|
const conflictPaths = [];
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
218
|
+
const mergedPaths = [];
|
|
219
|
+
for (const p of diff.conflicts) {
|
|
220
|
+
const localPath = path.join(sourceDir, p.replace(/^\//, ''));
|
|
221
|
+
let localContent;
|
|
222
|
+
try { localContent = fs.readFileSync(localPath, 'utf8'); } catch { continue; }
|
|
223
|
+
|
|
224
|
+
if (force) {
|
|
225
|
+
filesToPush.push({ path: p, content: localContent });
|
|
226
|
+
continue;
|
|
163
227
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
228
|
+
|
|
229
|
+
// Try section-level merge (only for .md files)
|
|
230
|
+
if (p.endsWith('.md') && remoteContent[p] && manifest && manifest.files && manifest.files[p]) {
|
|
231
|
+
// Get base content: we need what the file looked like at last sync.
|
|
232
|
+
// We don't store content in manifest, so use remote as best-effort base
|
|
233
|
+
// when manifest hash matches neither side (true conflict).
|
|
234
|
+
// For now, attempt merge with remote content and see if sections differ.
|
|
235
|
+
const remote = remoteContent[p];
|
|
236
|
+
// Simple heuristic: if one side only added content (appended sections), merge works
|
|
237
|
+
const result = sectionMerge(remote, localContent, remote);
|
|
238
|
+
// A better merge needs the base version. For now, try local-as-changed vs remote-as-base:
|
|
239
|
+
const mergeResult = sectionMerge(remote, localContent, remote);
|
|
240
|
+
if (mergeResult.merged && mergeResult.conflicts.length === 0 && mergeResult.merged !== remote) {
|
|
241
|
+
filesToPush.push({ path: p, content: mergeResult.merged });
|
|
242
|
+
mergedPaths.push(p);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
167
245
|
}
|
|
246
|
+
|
|
247
|
+
conflictPaths.push(p);
|
|
168
248
|
}
|
|
169
249
|
|
|
170
250
|
console.log('');
|
|
@@ -189,11 +269,38 @@ async function pushAtris() {
|
|
|
189
269
|
);
|
|
190
270
|
|
|
191
271
|
if (!result.ok) {
|
|
192
|
-
const msg = result.errorMessage || `HTTP ${result.status}`;
|
|
272
|
+
const msg = result.errorMessage || result.error || `HTTP ${result.status}`;
|
|
193
273
|
if (result.status === 409) {
|
|
194
274
|
console.error(` Computer is sleeping. Wake it first, then push.`);
|
|
195
275
|
} else if (result.status === 403) {
|
|
196
|
-
|
|
276
|
+
// Member scoping — retry with only team/ and journal/ files
|
|
277
|
+
const memberFiles = filesToPush.filter(f => f.path.startsWith('/team/') || f.path.startsWith('/journal/'));
|
|
278
|
+
const blockedFiles = filesToPush.filter(f => !f.path.startsWith('/team/') && !f.path.startsWith('/journal/'));
|
|
279
|
+
if (memberFiles.length > 0 && blockedFiles.length > 0) {
|
|
280
|
+
console.log(` You're a member — retrying with your team files only...`);
|
|
281
|
+
if (blockedFiles.length > 0) {
|
|
282
|
+
console.log(` Skipped (no permission): ${blockedFiles.map(f => f.path.replace(/^\//, '')).join(', ')}`);
|
|
283
|
+
}
|
|
284
|
+
const retry = await apiRequestJson(
|
|
285
|
+
`/businesses/${businessId}/workspaces/${workspaceId}/sync`,
|
|
286
|
+
{ method: 'POST', token: creds.token, body: { files: memberFiles }, headers: { 'X-Atris-Actor-Source': 'cli' } }
|
|
287
|
+
);
|
|
288
|
+
if (retry.ok) {
|
|
289
|
+
for (const f of memberFiles) {
|
|
290
|
+
console.log(` \u2191 ${f.path.replace(/^\//, '')} pushed`);
|
|
291
|
+
pushed++;
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
console.error(` Push failed after retry: ${retry.errorMessage || retry.error || retry.status}`);
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
} else {
|
|
298
|
+
console.error(` Access denied: you can only push to your own team/ folder.`);
|
|
299
|
+
if (blockedFiles.length > 0) {
|
|
300
|
+
console.error(` Blocked: ${blockedFiles.map(f => f.path.replace(/^\//, '')).join(', ')}`);
|
|
301
|
+
}
|
|
302
|
+
process.exit(1);
|
|
303
|
+
}
|
|
197
304
|
} else {
|
|
198
305
|
console.error(` Push failed: ${msg}`);
|
|
199
306
|
}
|
|
@@ -215,6 +322,10 @@ async function pushAtris() {
|
|
|
215
322
|
pushed++;
|
|
216
323
|
}
|
|
217
324
|
}
|
|
325
|
+
for (const p of mergedPaths) {
|
|
326
|
+
console.log(` \u2194 ${p.replace(/^\//, '')} auto-merged (different sections)`);
|
|
327
|
+
pushed++;
|
|
328
|
+
}
|
|
218
329
|
}
|
|
219
330
|
|
|
220
331
|
// Show conflicts
|
package/package.json
CHANGED
package/utils/api.js
CHANGED
|
@@ -71,11 +71,18 @@ function httpRequest(urlString, options) {
|
|
|
71
71
|
});
|
|
72
72
|
|
|
73
73
|
req.on('error', reject);
|
|
74
|
+
// Socket idle timeout (fires if no data received for this duration)
|
|
74
75
|
if (timeoutMs > 0) {
|
|
75
76
|
req.setTimeout(timeoutMs, () => {
|
|
76
|
-
req.destroy(new Error(
|
|
77
|
+
req.destroy(new Error(`Request timeout after ${Math.round(timeoutMs / 1000)}s — try --timeout=300`));
|
|
77
78
|
});
|
|
78
79
|
}
|
|
80
|
+
// Hard deadline — kill request after 2x the timeout regardless of activity
|
|
81
|
+
const hardDeadline = timeoutMs > 0
|
|
82
|
+
? setTimeout(() => { req.destroy(new Error(`Hard deadline exceeded (${Math.round(timeoutMs * 2 / 1000)}s)`)); }, timeoutMs * 2)
|
|
83
|
+
: null;
|
|
84
|
+
// Clear hard deadline when response completes
|
|
85
|
+
req.on('close', () => { if (hardDeadline) clearTimeout(hardDeadline); });
|
|
79
86
|
|
|
80
87
|
if (options.body) {
|
|
81
88
|
if (!req.hasHeader('Content-Length')) {
|