atris 3.72.0 → 3.72.1
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/auth.js +29 -2
- package/lib/site-publish.js +16 -2
- package/lib/site.js +17 -7
- package/lib/zen-apply.js +129 -21
- package/lib/zen.js +15 -1
- package/package.json +1 -1
- package/utils/api.js +15 -2
- package/utils/auth.js +8 -7
package/commands/auth.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const { AGENT_TOKEN_EXPIRED_DETAIL, decodeJwtClaims, loadCredentials, saveCredentials, saveAgentClientCredentials, preserveAgentClientLogin, deleteCredentials, getCredentialsPath, openBrowser, promptUser, displayAccountSummary, ensureValidCredentials, loadProfile, listProfiles, profileNameFromEmail, deleteProfile, saveProfile, getTokenExpiryEpochSeconds, getTerminalSessionId, setSessionProfile, getSessionProfile, clearSessionProfile, cleanStaleSessions, getSessionsDir } = require('../utils/auth');
|
|
2
|
-
const { getAppBaseUrl, apiRequestJson } = require('../utils/api');
|
|
2
|
+
const { getAppBaseUrl, getApiBaseUrl, apiRequestJson } = require('../utils/api');
|
|
3
3
|
const { isNonInteractive, wantsJson } = require('../lib/noninteractive');
|
|
4
4
|
const { hasFlag, readFlag } = require('../lib/arg-parser');
|
|
5
5
|
const agentClient = require('../utils/agent-client');
|
|
@@ -17,6 +17,7 @@ const COMMERCE_SCOPES = [COMMERCE_QUOTE_SCOPE, 'transactions:read'];
|
|
|
17
17
|
const COMMERCE_MAX_USD_LIMIT = 500;
|
|
18
18
|
const COMMERCE_NEVER_PAYS = 'this key can ask for prices and start your approval, it can never pay.';
|
|
19
19
|
const NO_STORED_JWT_MESSAGE = 'not signed in. run atris login first.';
|
|
20
|
+
let envBilledAuthCache = null;
|
|
20
21
|
|
|
21
22
|
function firstNonEmptyString(...values) {
|
|
22
23
|
for (const value of values) {
|
|
@@ -171,6 +172,7 @@ function persistMintedAgentToken(credentials, token, extras = {}) {
|
|
|
171
172
|
refresh_token: extras.refresh_token || credentials.refresh_token || null,
|
|
172
173
|
...slot,
|
|
173
174
|
};
|
|
175
|
+
if (credentials.source === 'env') return next;
|
|
174
176
|
if (credentials.source_profile) {
|
|
175
177
|
saveProfile(credentials.source_profile, next);
|
|
176
178
|
return next;
|
|
@@ -270,7 +272,9 @@ async function mintScopedAgentToken(requested = {}, deps = {}) {
|
|
|
270
272
|
ok: true,
|
|
271
273
|
token: minted,
|
|
272
274
|
meta,
|
|
273
|
-
storedIn:
|
|
275
|
+
storedIn: credentials.source === 'env'
|
|
276
|
+
? 'process memory'
|
|
277
|
+
: `${credentials.source_profile ? `profile ${credentials.source_profile}` : '~/.atris/credentials.json'}, ${isCommerceKey(meta.scopes) ? 'commerce_agent_token' : 'agent_token'}`,
|
|
274
278
|
};
|
|
275
279
|
}
|
|
276
280
|
|
|
@@ -306,10 +310,33 @@ async function ensureBilledCommandAuth(scope, deps = {}) {
|
|
|
306
310
|
return { ok: false, error: NO_STORED_JWT_MESSAGE };
|
|
307
311
|
}
|
|
308
312
|
|
|
313
|
+
let cache;
|
|
314
|
+
if (credentials.source === 'env') {
|
|
315
|
+
const baseUrl = getApiBaseUrl();
|
|
316
|
+
if (!envBilledAuthCache || envBilledAuthCache.login !== credentials.token || envBilledAuthCache.api !== api || envBilledAuthCache.baseUrl !== baseUrl) {
|
|
317
|
+
envBilledAuthCache = { login: credentials.token, api, baseUrl, tokens: new Map() };
|
|
318
|
+
}
|
|
319
|
+
cache = envBilledAuthCache.tokens;
|
|
320
|
+
const cached = cache.get(wanted);
|
|
321
|
+
if (!deps.forceMint && cached && cached.expiry > Date.now()) {
|
|
322
|
+
return { ok: true, token: cached.token, minted: false, credentials, meta: cached.meta };
|
|
323
|
+
}
|
|
324
|
+
// A rejected or expired token must stay evicted even if its replacement fails.
|
|
325
|
+
cache.delete(wanted);
|
|
326
|
+
}
|
|
327
|
+
|
|
309
328
|
const minted = await mint({ scopes: [wanted] }, deps);
|
|
310
329
|
if (!minted.ok) {
|
|
311
330
|
return { ok: false, error: minted.error || NO_STORED_JWT_MESSAGE };
|
|
312
331
|
}
|
|
332
|
+
if (cache) {
|
|
333
|
+
const claims = decodeJwtClaims(minted.token);
|
|
334
|
+
const expiry = claims?.exp ? claims.exp * 1000 : Date.parse(minted.meta?.expiresAt);
|
|
335
|
+
const scopes = claims?.scopes || minted.meta?.scopes || [];
|
|
336
|
+
if (Array.isArray(scopes) && scopes.includes(wanted) && Number.isFinite(expiry) && expiry > Date.now()) {
|
|
337
|
+
cache.set(wanted, { token: minted.token, meta: minted.meta, expiry });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
313
340
|
return {
|
|
314
341
|
ok: true,
|
|
315
342
|
token: minted.token,
|
package/lib/site-publish.js
CHANGED
|
@@ -322,15 +322,29 @@ async function run(argv, deps = {}) {
|
|
|
322
322
|
let response;
|
|
323
323
|
let parsed;
|
|
324
324
|
try {
|
|
325
|
-
|
|
325
|
+
const requestOptions = {
|
|
326
326
|
method: 'POST',
|
|
327
|
+
redirect: 'manual',
|
|
327
328
|
headers: {
|
|
328
329
|
Authorization: `Bearer ${token}`,
|
|
329
330
|
Accept: 'application/json',
|
|
330
331
|
'Content-Type': 'application/json',
|
|
331
332
|
},
|
|
332
333
|
body: JSON.stringify(body),
|
|
333
|
-
}
|
|
334
|
+
};
|
|
335
|
+
let requestUrl = url;
|
|
336
|
+
for (let redirects = 0; ; redirects++) {
|
|
337
|
+
response = await fetchFn(requestUrl, requestOptions);
|
|
338
|
+
const location = response.headers && response.headers.get('location');
|
|
339
|
+
if (![301, 302, 303, 307, 308].includes(response.status) || !location) break;
|
|
340
|
+
if (response.body) await response.body.cancel();
|
|
341
|
+
const target = new URL(location, requestUrl);
|
|
342
|
+
if (target.origin !== new URL(url).origin) {
|
|
343
|
+
throw new Error(`refusing cross-origin redirect to ${target.host || target.protocol}`);
|
|
344
|
+
}
|
|
345
|
+
if (redirects >= 20) throw new Error('too many redirects');
|
|
346
|
+
requestUrl = target.toString();
|
|
347
|
+
}
|
|
334
348
|
parsed = await readResponse(response);
|
|
335
349
|
} catch (error) {
|
|
336
350
|
errorLog(`publish failed: ${error.message}`);
|
package/lib/site.js
CHANGED
|
@@ -97,18 +97,28 @@ const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': '
|
|
|
97
97
|
// minimal static preview server for a built site dir (no deps)
|
|
98
98
|
function serveSite(dir, port = 4321) {
|
|
99
99
|
const root = path.resolve(dir);
|
|
100
|
+
let realRoot = root;
|
|
101
|
+
try { realRoot = fs.realpathSync(root); } catch { /* missing folder: every request 404s */ }
|
|
100
102
|
const server = http.createServer((req, res) => {
|
|
101
|
-
let rel
|
|
103
|
+
let rel;
|
|
104
|
+
try { rel = decodeURIComponent((req.url || '/').split('?')[0]); }
|
|
105
|
+
catch { res.writeHead(400); res.end('bad request'); return; }
|
|
102
106
|
if (rel.endsWith('/')) rel += 'index.html';
|
|
103
107
|
const file = path.normalize(path.join(root, rel));
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
res.end(
|
|
108
|
+
// compare against root + separator so a sibling like "site-evil" can't pass for "site"
|
|
109
|
+
if (file !== root && !file.startsWith(root + path.sep)) { res.writeHead(403); res.end('forbidden'); return; }
|
|
110
|
+
// Resolve symlinks too: a link inside the site folder must not reach files outside it.
|
|
111
|
+
fs.realpath(file, (linkErr, real) => {
|
|
112
|
+
if (linkErr) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('not found'); return; }
|
|
113
|
+
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) { res.writeHead(403); res.end('forbidden'); return; }
|
|
114
|
+
fs.readFile(real, (err, data) => {
|
|
115
|
+
if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('not found'); return; }
|
|
116
|
+
res.writeHead(200, { 'Content-Type': MIME[path.extname(real)] || 'application/octet-stream' });
|
|
117
|
+
res.end(data);
|
|
118
|
+
});
|
|
109
119
|
});
|
|
110
120
|
});
|
|
111
|
-
return new Promise((resolve) => server.listen(port, () => resolve({ server, url: `http://localhost:${port}` })));
|
|
121
|
+
return new Promise((resolve) => server.listen(port, '127.0.0.1', () => resolve({ server, url: `http://localhost:${port}` })));
|
|
112
122
|
}
|
|
113
123
|
|
|
114
124
|
module.exports = { buildSite, serveSite, collectMd, slugFor, firstHeading, firstParagraph };
|
package/lib/zen-apply.js
CHANGED
|
@@ -284,26 +284,94 @@ function sameBytes(a, b) {
|
|
|
284
284
|
}
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
+
// A live journal only grows, so two versions of one day's log hold no real
|
|
288
|
+
// conflict: keep the incoming log and add each line of yours it lacks, in
|
|
289
|
+
// your order. Archived journals are finished and keep the usual handling.
|
|
290
|
+
function isJournal(p) {
|
|
291
|
+
return /(^|\/)logs\/.+\.md$/.test(p) && !/(^|\/)archive\//.test(p);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function joinJournal(file, mine) {
|
|
295
|
+
let incoming;
|
|
296
|
+
let yours;
|
|
297
|
+
try {
|
|
298
|
+
incoming = fs.readFileSync(file, 'utf8');
|
|
299
|
+
yours = fs.readFileSync(mine, 'utf8');
|
|
300
|
+
} catch {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
const have = new Set(incoming.split('\n').map((line) => line.trim()).filter(Boolean));
|
|
304
|
+
const extra = yours.split('\n').filter((line) => line.trim() && !have.has(line.trim()));
|
|
305
|
+
if (extra.length) fs.writeFileSync(file, `${incoming.replace(/\n*$/, '\n')}\n${extra.join('\n')}\n`);
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Notes moved to a new path between two commits, old path to new.
|
|
310
|
+
function movedNotes(root, from, to) {
|
|
311
|
+
const out = runGit(['diff', '--name-status', '-z', '-M', from, to], root).stdout || '';
|
|
312
|
+
const parts = out.split('\0');
|
|
313
|
+
const moves = new Map();
|
|
314
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
315
|
+
if (/^[RC]\d*$/.test(parts[i])) {
|
|
316
|
+
if (parts[i][0] === 'R') moves.set(parts[i + 1], parts[i + 2]);
|
|
317
|
+
i += 2;
|
|
318
|
+
} else if (parts[i]) {
|
|
319
|
+
i += 1;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return moves;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Carry your edits to a note onto the path it moved to, with a 3-way merge
|
|
326
|
+
// against the version you started from. Only a clean merge is written.
|
|
327
|
+
function followMove(root, fromCommit, from, to, mine) {
|
|
328
|
+
const dest = path.join(root, to);
|
|
329
|
+
const base = runGit(['show', `${fromCommit}:${from}`], root);
|
|
330
|
+
if (base.status !== 0 || !fs.existsSync(mine) || !fs.existsSync(dest)) return false;
|
|
331
|
+
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-zen-move-'));
|
|
332
|
+
try {
|
|
333
|
+
fs.writeFileSync(path.join(work, 'base'), base.stdout);
|
|
334
|
+
const merged = spawnSync('git', ['merge-file', '-p', dest, path.join(work, 'base'), mine], { encoding: 'utf8' });
|
|
335
|
+
if (merged.status !== 0) return false;
|
|
336
|
+
fs.writeFileSync(dest, merged.stdout);
|
|
337
|
+
return true;
|
|
338
|
+
} finally {
|
|
339
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
287
343
|
// Run a fast-forward or reset. If uncommitted team notes are in the way, save
|
|
288
|
-
// them, move, then lay them back on top.
|
|
289
|
-
|
|
344
|
+
// them, move, then lay them back on top. `from` is where the saved changes
|
|
345
|
+
// start: dropping unpushed commits or leaving a side branch passes the base,
|
|
346
|
+
// so a note committed there and edited again since comes back whole, not
|
|
347
|
+
// just the later edit.
|
|
348
|
+
function moveKeepingNotes(root, args, target, tag, from = 'HEAD') {
|
|
290
349
|
const first = runGit(args, root);
|
|
291
|
-
if (first.status === 0) return { ok: true, notes: [] };
|
|
350
|
+
if (first.status === 0) return { ok: true, notes: [], tracked: [], landed: [] };
|
|
292
351
|
|
|
293
|
-
|
|
352
|
+
// Without rename detection a note moved upstream shows up under its old
|
|
353
|
+
// path too, so an edit to it is saved instead of blocking the move.
|
|
354
|
+
const changed = new Set((gitOut(['diff', '--name-only', '--no-renames', 'HEAD', target], root) || '').split('\n').filter(Boolean));
|
|
355
|
+
const moves = movedNotes(root, 'HEAD', target);
|
|
356
|
+
const fromCommit = gitOut(['rev-parse', '--verify', '--quiet', `${from}^{commit}`], root);
|
|
294
357
|
const dirty = dirtyEntries(root);
|
|
295
|
-
|
|
358
|
+
// A new note that was only staged has nothing in HEAD to go back to, so it
|
|
359
|
+
// is unstaged and handled like any untracked note.
|
|
360
|
+
const added = dirty.filter((e) => e.xy[0] === 'A' && changed.has(e.path)).map((e) => e.path);
|
|
361
|
+
if (added.length) runGit(['reset', '-q', '--', ...added], root);
|
|
362
|
+
const tracked = dirty.filter((e) => e.xy !== '??' && e.xy[0] !== 'A' && changed.has(e.path)).map((e) => e.path);
|
|
296
363
|
const untracked = dirty
|
|
297
364
|
.filter((e) => e.xy === '??')
|
|
298
365
|
.flatMap((e) => (e.path.endsWith('/') ? untrackedFiles(root, e.path) : [e.path]))
|
|
299
|
-
.filter((p) => changed.has(p))
|
|
366
|
+
.filter((p) => changed.has(p))
|
|
367
|
+
.concat(added);
|
|
300
368
|
if (!tracked.length && !untracked.length) return { ok: false, error: firstLine(first.stderr) };
|
|
301
369
|
|
|
302
370
|
const dir = path.join(commonDir(root), 'atris-zen', tag);
|
|
303
371
|
fs.mkdirSync(dir, { recursive: true });
|
|
304
372
|
const patchFile = path.join(dir, 'team-notes.patch');
|
|
305
373
|
if (tracked.length) {
|
|
306
|
-
const diff = runGit(['diff', '--binary',
|
|
374
|
+
const diff = runGit(['diff', '--binary', from, '--', ...tracked], root);
|
|
307
375
|
fs.writeFileSync(patchFile, diff.stdout);
|
|
308
376
|
for (const p of tracked) {
|
|
309
377
|
if (!fs.existsSync(path.join(root, p))) continue;
|
|
@@ -322,12 +390,25 @@ function moveKeepingNotes(root, args, target, tag) {
|
|
|
322
390
|
|
|
323
391
|
const second = runGit(args, root);
|
|
324
392
|
const notes = [];
|
|
393
|
+
let landed = [];
|
|
325
394
|
if (tracked.length) {
|
|
326
395
|
const { clashed, missed } = applyNotes(root, patchFile, tracked);
|
|
327
|
-
|
|
396
|
+
landed = tracked.filter((p) => !clashed.includes(p) && !missed.includes(p));
|
|
397
|
+
for (const p of clashed) {
|
|
398
|
+
const mine = path.join(dir, 'mine', p);
|
|
399
|
+
if (isJournal(p) && joinJournal(path.join(root, p), mine)) notes.push(`joined your ${p} onto the new one`);
|
|
400
|
+
else notes.push(`kept the new ${p}; your copy is in ${mine}`);
|
|
401
|
+
}
|
|
328
402
|
for (const p of missed) {
|
|
329
403
|
const mine = path.join(dir, 'mine', p);
|
|
330
|
-
|
|
404
|
+
const to = moves.get(p);
|
|
405
|
+
if (to && fromCommit && followMove(root, fromCommit, p, to, mine)) {
|
|
406
|
+
notes.push(`${p} moved to ${to}, and your edits went with it`);
|
|
407
|
+
} else if (to) {
|
|
408
|
+
notes.push(`${p} moved to ${to} and your edits clash there; your copy is in ${fs.existsSync(mine) ? mine : patchFile}`);
|
|
409
|
+
} else {
|
|
410
|
+
notes.push(`${p} no longer fits the new changes; your copy is in ${fs.existsSync(mine) ? mine : patchFile}`);
|
|
411
|
+
}
|
|
331
412
|
}
|
|
332
413
|
}
|
|
333
414
|
for (const p of untracked) {
|
|
@@ -335,32 +416,56 @@ function moveKeepingNotes(root, args, target, tag) {
|
|
|
335
416
|
if (fs.existsSync(path.join(root, p))) {
|
|
336
417
|
// A copy that matches the incoming file loses nothing, so say nothing.
|
|
337
418
|
if (sameBytes(saved, path.join(root, p))) fs.rmSync(saved, { force: true });
|
|
419
|
+
else if (isJournal(p) && joinJournal(path.join(root, p), saved)) notes.push(`joined your ${p} onto the new one`);
|
|
338
420
|
else notes.push(`kept the new ${p}; your copy is in ${saved}`);
|
|
339
421
|
} else {
|
|
340
422
|
fs.mkdirSync(path.dirname(path.join(root, p)), { recursive: true });
|
|
341
423
|
fs.copyFileSync(saved, path.join(root, p));
|
|
342
424
|
}
|
|
343
425
|
}
|
|
344
|
-
if (second.status !== 0) return { ok: false, error: firstLine(second.stderr), notes };
|
|
345
|
-
return { ok: true, notes };
|
|
426
|
+
if (second.status !== 0) return { ok: false, error: firstLine(second.stderr), notes, tracked, landed };
|
|
427
|
+
return { ok: true, notes, tracked, landed };
|
|
346
428
|
}
|
|
347
429
|
|
|
348
430
|
// Unpushed commits can carry team-note edits. Their commits move to a backup
|
|
349
431
|
// branch, but the notes stay in the tree, uncommitted, like any team note.
|
|
350
|
-
|
|
351
|
-
|
|
432
|
+
// A note that was also edited since came back with the move already, commit
|
|
433
|
+
// and all, so it is counted and not laid down twice.
|
|
434
|
+
function carryTeamNotes(root, base, commit, teamState, tag, moved = { tracked: [], landed: [] }) {
|
|
435
|
+
const touched = (gitOut(['diff', '--name-only', base, commit], root) || '')
|
|
352
436
|
.split('\n').filter((p) => p && isTeamState(p, teamState));
|
|
353
|
-
|
|
437
|
+
const already = touched.filter((p) => moved.landed.includes(p)).length;
|
|
438
|
+
const files = touched.filter((p) => !moved.tracked.includes(p));
|
|
439
|
+
if (!files.length) return { carried: already, notes: [] };
|
|
354
440
|
const diff = runGit(['diff', '--binary', base, commit, '--', ...files], root);
|
|
355
|
-
if (diff.status !== 0 || !diff.stdout) return { carried:
|
|
441
|
+
if (diff.status !== 0 || !diff.stdout) return { carried: already, notes: [] };
|
|
356
442
|
const dir = path.join(commonDir(root), 'atris-zen', tag);
|
|
357
443
|
fs.mkdirSync(dir, { recursive: true });
|
|
358
444
|
const patchFile = path.join(dir, 'unpushed-team-notes.patch');
|
|
359
445
|
fs.writeFileSync(patchFile, diff.stdout);
|
|
360
446
|
const { clashed, missed } = applyNotes(root, patchFile, files);
|
|
361
447
|
const notes = clashed.map((p) => `kept the new ${p}; yours is on ${commit}`);
|
|
362
|
-
|
|
363
|
-
|
|
448
|
+
// A note moved upstream takes the commit's edits to its new path.
|
|
449
|
+
const moves = missed.length ? movedNotes(root, base, 'HEAD') : new Map();
|
|
450
|
+
let followed = 0;
|
|
451
|
+
for (const p of missed) {
|
|
452
|
+
const to = moves.get(p);
|
|
453
|
+
const theirs = to ? runGit(['show', `${commit}:${p}`], root) : null;
|
|
454
|
+
const mine = path.join(dir, 'unpushed', p);
|
|
455
|
+
if (theirs?.status === 0) {
|
|
456
|
+
fs.mkdirSync(path.dirname(mine), { recursive: true });
|
|
457
|
+
fs.writeFileSync(mine, theirs.stdout);
|
|
458
|
+
}
|
|
459
|
+
if (theirs?.status === 0 && followMove(root, base, p, to, mine)) {
|
|
460
|
+
notes.push(`${p} moved to ${to}, and the edits from ${commit} went with it`);
|
|
461
|
+
followed += 1;
|
|
462
|
+
} else if (to) {
|
|
463
|
+
notes.push(`${p} moved to ${to} and the edits clash there; yours is on ${commit}`);
|
|
464
|
+
} else {
|
|
465
|
+
notes.push(`${p} no longer fits the new changes; yours is on ${commit}`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return { carried: already + followed + files.length - clashed.length - missed.length, notes };
|
|
364
469
|
}
|
|
365
470
|
|
|
366
471
|
function applyZen({ cwd = process.cwd(), fetch = true, freshMinutes, abandonedDays, now = new Date() } = {}) {
|
|
@@ -426,7 +531,10 @@ function applyZen({ cwd = process.cwd(), fetch = true, freshMinutes, abandonedDa
|
|
|
426
531
|
runGit(['update-ref', `refs/heads/${keep}`, 'HEAD'], root);
|
|
427
532
|
saved.push(`refs/heads/${keep}`);
|
|
428
533
|
}
|
|
429
|
-
|
|
534
|
+
// Saved from where the branch left main, so a note committed on the
|
|
535
|
+
// branch and edited again since comes back whole.
|
|
536
|
+
const base = gitOut(['merge-base', 'HEAD', main], root);
|
|
537
|
+
const moved = moveKeepingNotes(root, ['checkout', '-q', main], main, tag, base || 'HEAD');
|
|
430
538
|
notes.push(...(moved.notes || []));
|
|
431
539
|
if (moved.ok) {
|
|
432
540
|
done.push(`moved back to ${main}${branch ? ` (${branch} is kept)` : ''}`);
|
|
@@ -445,12 +553,12 @@ function applyZen({ cwd = process.cwd(), fetch = true, freshMinutes, abandonedDa
|
|
|
445
553
|
} else if (ahead) {
|
|
446
554
|
const keep = `zen-backup/${tag}-unpushed`;
|
|
447
555
|
runGit(['update-ref', `refs/heads/${keep}`, 'HEAD'], root);
|
|
448
|
-
const
|
|
556
|
+
const base = gitOut(['merge-base', keep, before.upstream], root);
|
|
557
|
+
const moved = moveKeepingNotes(root, ['reset', '-q', '--keep', before.upstream], before.upstream, tag, base || 'HEAD');
|
|
449
558
|
notes.push(...(moved.notes || []));
|
|
450
559
|
if (moved.ok) {
|
|
451
560
|
done.push(`moved ${plural(ahead, 'unpushed commit', 'unpushed commits')} to ${keep}`);
|
|
452
|
-
const
|
|
453
|
-
const carried = base ? carryTeamNotes(root, base, keep, teamState, tag) : { carried: 0, notes: [] };
|
|
561
|
+
const carried = base ? carryTeamNotes(root, base, keep, teamState, tag, moved) : { carried: 0, notes: [] };
|
|
454
562
|
notes.push(...carried.notes);
|
|
455
563
|
if (carried.carried) {
|
|
456
564
|
done.push(`kept ${plural(carried.carried, 'team note', 'team notes')} from ${ahead === 1 ? 'it' : 'them'} in the tree`);
|
package/lib/zen.js
CHANGED
|
@@ -233,9 +233,23 @@ function headMovedSince(checkout, cutoff) {
|
|
|
233
233
|
return Math.max(mtime(path.join(dir, 'logs', 'HEAD')), mtime(path.join(dir, 'HEAD'))) > cutoff;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
// A checkout whose core.bare got set to true still has its .git folder and
|
|
237
|
+
// every file, but git stops looking at the files, so "not a git repository"
|
|
238
|
+
// would send the reader the wrong way. Name it, with the one-line undo.
|
|
239
|
+
const BARE_FLIPPED = 'git here is set to bare (core.bare is true), so it ignores the files in this folder; git config core.bare false undoes it';
|
|
240
|
+
|
|
241
|
+
function flippedToBare(cwd) {
|
|
242
|
+
if (gitOut(['rev-parse', '--is-bare-repository'], cwd) !== 'true') return false;
|
|
243
|
+
const gitDir = gitOut(['rev-parse', '--absolute-git-dir'], cwd);
|
|
244
|
+
if (!gitDir || path.basename(gitDir) !== '.git') return false;
|
|
245
|
+
const folder = path.dirname(gitDir);
|
|
246
|
+
const here = fs.realpathSync(cwd);
|
|
247
|
+
return here === folder || here.startsWith(folder + path.sep);
|
|
248
|
+
}
|
|
249
|
+
|
|
236
250
|
function checkZen({ cwd = process.cwd(), fetch = true, freshMinutes = freshMinutesFromEnv(), now = Date.now() } = {}) {
|
|
237
251
|
const root = gitOut(['rev-parse', '--show-toplevel'], cwd);
|
|
238
|
-
if (!root) return { ok: false, error: 'not a git repository' };
|
|
252
|
+
if (!root) return { ok: false, error: flippedToBare(cwd) ? BARE_FLIPPED : 'not a git repository' };
|
|
239
253
|
|
|
240
254
|
const remote = gitOut(['remote'], root)?.split('\n').includes('origin') ? 'origin' : null;
|
|
241
255
|
let fetched = null;
|
package/package.json
CHANGED
package/utils/api.js
CHANGED
|
@@ -77,8 +77,21 @@ function httpRequest(urlString, options) {
|
|
|
77
77
|
const req = transport.request(requestOptions, (res) => {
|
|
78
78
|
// Follow redirects (301, 302, 307, 308)
|
|
79
79
|
if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location) {
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
res.resume();
|
|
81
|
+
let redirectUrl;
|
|
82
|
+
try {
|
|
83
|
+
redirectUrl = new URL(res.headers.location, parsed);
|
|
84
|
+
} catch {
|
|
85
|
+
reject(new Error('invalid redirect target'));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Headers and bodies can both contain credentials. Never replay them
|
|
89
|
+
// to another origin, including a different port or an HTTPS downgrade.
|
|
90
|
+
if (redirectUrl.origin !== parsed.origin) {
|
|
91
|
+
reject(new Error(`refusing cross-origin redirect to ${redirectUrl.host || redirectUrl.protocol}`));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
resolve(httpRequest(redirectUrl.toString(), options));
|
|
82
95
|
return;
|
|
83
96
|
}
|
|
84
97
|
|
package/utils/auth.js
CHANGED
|
@@ -2,7 +2,7 @@ const os = require('os');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const { stateHome } = require('../lib/state-home');
|
|
4
4
|
const fs = require('fs');
|
|
5
|
-
const {
|
|
5
|
+
const { execFile } = require('child_process');
|
|
6
6
|
const readline = require('readline');
|
|
7
7
|
const agentClient = require('./agent-client');
|
|
8
8
|
|
|
@@ -12,19 +12,20 @@ const agentClient = require('./agent-client');
|
|
|
12
12
|
*/
|
|
13
13
|
function openBrowser(url) {
|
|
14
14
|
const platform = os.platform();
|
|
15
|
-
// Sanitize URL to prevent shell injection — only allow valid URL characters
|
|
16
|
-
const sanitizedUrl = url.replace(/[^a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]/g, '');
|
|
17
15
|
let command;
|
|
16
|
+
let args = [url];
|
|
18
17
|
|
|
19
18
|
if (platform === 'darwin') {
|
|
20
|
-
command =
|
|
19
|
+
command = 'open';
|
|
21
20
|
} else if (platform === 'win32') {
|
|
22
|
-
command =
|
|
21
|
+
command = 'rundll32.exe';
|
|
22
|
+
args = ['url.dll,FileProtocolHandler', url];
|
|
23
23
|
} else {
|
|
24
|
-
command =
|
|
24
|
+
command = 'xdg-open';
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
// Pass the URL as data, including on Windows, without a command shell.
|
|
28
|
+
execFile(command, args, (error) => {
|
|
28
29
|
if (error) {
|
|
29
30
|
console.log(`\nCouldn't open browser automatically. Please visit:\n${url}`);
|
|
30
31
|
}
|