@bongos/core 1.24.84 → 1.24.86
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/.bongos-core.json +30 -30
- package/docs/copy-inventory.md +17 -16
- package/docs/copy-registry.json +26 -17
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +194 -194
- package/docs/recipes/autobongos-runner-setup.md +1 -0
- package/modules/hall-ui/public/craft-mode.js +4 -1
- package/modules/hall-ui/public/options-menu.js +4 -0
- package/modules/hall-ui/public/settings.css +15 -0
- package/modules/hall-ui/public/shell-game.js +82 -2
- package/modules/hall-ui/public/style.css +32 -0
- package/modules/hall-ui/records/options-menu.md +7 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/bongos/autobongos-bootstrap.sh +24 -2
- package/scripts/bongos/autobongos-host.js +32 -2
- package/scripts/bongos/wedge-remedy.js +9 -2
- package/src/module-api.js +1 -1
- package/tests/autobongos_host.mjs +33 -0
- package/tests/hall_options_menu.mjs +63 -4
- package/tests/page_notes_panel.mjs +9 -1
- package/tests/shell_stat_portability.mjs +34 -0
- package/tests/ship_vendored_instance.mjs +5 -1
- package/tests/update_sweep_home.mjs +11 -5
- package/tests/wedge_remedy.mjs +5 -1
|
@@ -138,6 +138,7 @@ worker decided on its own, what it cost, and where your Claude usage stands.
|
|
|
138
138
|
| **Resting** for hours | That is your Claude usage limit, and it is working as designed. It resumes at the time on the button. |
|
|
139
139
|
| **Running**, but nothing ships for a long time | The tasks in the goals you chose may all be waiting on something. Run the digest above: it says what is on hold and why. |
|
|
140
140
|
| You added the same device twice | Remove one card. One runner per device. |
|
|
141
|
+
| **Repository not found**, and you *are* signed in to GitHub | You are signed in as more than one GitHub account, and the device is using the wrong one. Run `gh auth switch` and pick the account that can see the project, then paste the setup command again. The setup names the account it used, so you can tell which it picked. |
|
|
141
142
|
|
|
142
143
|
## An always-on PC that starts with nobody signed in
|
|
143
144
|
|
|
@@ -472,8 +472,11 @@
|
|
|
472
472
|
function toolNode(t) { return document.getElementById('craft-tool-' + t.id); }
|
|
473
473
|
// A docked tool has no button: in its mode the panel is docked at the edge,
|
|
474
474
|
// out of it the panel goes. Only a tool already loaded needs closing.
|
|
475
|
+
// A screen inside the options menu's frame docks nothing: the menu page around
|
|
476
|
+
// it already docks its own, and a second panel at the frame's edge is a double.
|
|
475
477
|
function applyDocked(mode) {
|
|
476
|
-
var
|
|
478
|
+
var framed = !!(window.OTBShellGame && window.OTBShellGame.embedded && window.OTBShellGame.embedded());
|
|
479
|
+
var want = framed ? [] : toolsFor(mode);
|
|
477
480
|
for (var m in TOOLS) TOOLS[m].forEach(function (t) {
|
|
478
481
|
if (!t.docked) return;
|
|
479
482
|
if (want.indexOf(t) !== -1) loadTool(t).then(function (api) { if (api.dock) api.dock(); }, function () { /* the page still works */ });
|
|
@@ -363,6 +363,10 @@
|
|
|
363
363
|
|
|
364
364
|
// Close the menu (game mode's Escape): back where it was opened from.
|
|
365
365
|
function close() {
|
|
366
|
+
// Opened as the game's options window over a place (shell-game.js): shut the window.
|
|
367
|
+
var holder = null;
|
|
368
|
+
try { holder = window.parent !== window ? window.parent.OTBOptionsOverlay : null; } catch (_) { holder = null; }
|
|
369
|
+
if (holder && holder.isOpen && holder.isOpen()) { holder.close(); return; }
|
|
366
370
|
var to = state.from || closeTo(window.location.search);
|
|
367
371
|
if (to) { window.location.assign(to); return; }
|
|
368
372
|
var ref = '';
|
|
@@ -129,6 +129,21 @@ html[data-fullscreen="on"] .opt-title { display: block; }
|
|
|
129
129
|
.opt-embed__frame { display: block; width: 100%; height: calc(100vh - 96px - var(--opt-screen-h)); min-height: 480px; border: 0; border-radius: var(--press-radius); background: transparent; }
|
|
130
130
|
html[data-fullscreen="on"] .opt-embed__frame { height: calc(100vh - 48px - var(--opt-screen-h)); }
|
|
131
131
|
|
|
132
|
+
/* Inside the game's options window (shell-game.js): the window is the chrome, so the
|
|
133
|
+
menu sits flush at its top and the open screen fills the rest of it. The page is
|
|
134
|
+
drawn at the embed zoom (style.css), so a window-high length divides by it. */
|
|
135
|
+
html[data-embed="on"] .settings-layout { --opt-vh: calc(100vh / 0.82); padding: var(--ds-space-md) var(--ds-space-lg) 0 0; }
|
|
136
|
+
html[data-embed="on"] .opt-rail { top: 0; max-height: calc(var(--opt-vh) - var(--ds-space-md)); margin-top: calc(-1 * var(--ds-space-md)); padding-top: var(--ds-space-lg); }
|
|
137
|
+
html[data-embed="on"] .opt-title { display: block; font-size: var(--ds-text-xl); }
|
|
138
|
+
/* the window's glass is the ground, so the menu adds none; and window-sized words */
|
|
139
|
+
html[data-embed="on"] .opt-rail { background: none; }
|
|
140
|
+
html[data-embed="on"] .opt-search input { min-height: 34px; font-size: var(--ds-text-sm-plus); }
|
|
141
|
+
html[data-embed="on"] .opt-link { min-height: 30px; font-size: var(--ds-text-md); }
|
|
142
|
+
html[data-embed="on"] .opt-screen { font-size: var(--ds-text-xl); }
|
|
143
|
+
html[data-embed="on"] .settings-groups { --opt-screen-h: calc(var(--ds-space-lg) + var(--ds-space-md)); }
|
|
144
|
+
html[data-embed="on"] .settings-groups .scroll__h { font-size: var(--ds-text-md); }
|
|
145
|
+
html[data-embed="on"] .opt-embed__frame { height: calc(var(--opt-vh) - var(--opt-screen-h) - var(--ds-space-xl)); min-height: 0; }
|
|
146
|
+
|
|
132
147
|
@media (max-width: 860px) {
|
|
133
148
|
/* Collapse to one column: the menu sits above the open screen, no longer sticky. */
|
|
134
149
|
.settings-layout { grid-template-columns: minmax(0, 1fr); gap: var(--sp-md); }
|
|
@@ -406,11 +406,91 @@
|
|
|
406
406
|
try { e.preventDefault(); } catch (_) { /* already finished */ }
|
|
407
407
|
var host = embedded ? window.parent : window;
|
|
408
408
|
if (host && host.OTBOptions && typeof host.OTBOptions.escape === 'function') { host.OTBOptions.escape(); return; }
|
|
409
|
+
if (host && host.OTBOptionsOverlay && host.OTBOptionsOverlay.isOpen()) { host.OTBOptionsOverlay.close(); return; }
|
|
409
410
|
if (embedded) return;
|
|
410
|
-
|
|
411
|
-
window.location.assign(menuPath() + '?from=' + encodeURIComponent(here));
|
|
411
|
+
openOverlay();
|
|
412
412
|
}, 0);
|
|
413
413
|
}
|
|
414
|
+
|
|
415
|
+
// ---------- the options window (game mode) ----------
|
|
416
|
+
// In game mode the options menu opens as a window OVER the place you are in, not
|
|
417
|
+
// as a page of its own, so the place stays drawn behind it and its colours show
|
|
418
|
+
// through (the owner's call, 2026-11-11: "WoW sizing, an overlay"). The window
|
|
419
|
+
// is a frame of the menu with ?embed=1; the menu page itself (a plain visit to
|
|
420
|
+
// /settings) is unchanged. It is the hall's shared dialog (OTBDialog.bind,
|
|
421
|
+
// hall-dialog.js): Escape, the scrim, the close button, the focus trap and the
|
|
422
|
+
// focus return are its. The page behind is made inert while it is up, because Tab
|
|
423
|
+
// leaving the frame's last control lands in the page, past the dialog's own trap.
|
|
424
|
+
var overlay = null, dialog = null;
|
|
425
|
+
function overlayOpen() { return !!(dialog && dialog.isOpen()); }
|
|
426
|
+
function closeOverlay() { if (overlayOpen()) dialog.close(); }
|
|
427
|
+
function setInert(on) {
|
|
428
|
+
var kids = document.body && document.body.children ? document.body.children : [];
|
|
429
|
+
for (var i = 0; i < kids.length; i += 1) {
|
|
430
|
+
var k = kids[i];
|
|
431
|
+
if (k === overlay || !k.setAttribute) continue;
|
|
432
|
+
if (on) { if (!k.hasAttribute('inert')) { k.setAttribute('inert', ''); k.setAttribute('data-opt-inert', ''); } }
|
|
433
|
+
else if (k.hasAttribute('data-opt-inert')) { k.removeAttribute('inert'); k.removeAttribute('data-opt-inert'); }
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// `rest` is what follows the menu's path: '?open=x#y', '#access', or ''.
|
|
437
|
+
function openOverlay(rest) {
|
|
438
|
+
try { buildOverlay(rest || ''); } catch (_) {
|
|
439
|
+
// No window can be drawn here: the menu's own page, as before the window existed.
|
|
440
|
+
overlay = null; dialog = null;
|
|
441
|
+
var here = String(window.location.pathname || '') + String(window.location.search || '') + String(window.location.hash || '');
|
|
442
|
+
window.location.assign(menuPath() + (rest || '?from=' + encodeURIComponent(here)));
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function buildOverlay(rest) {
|
|
446
|
+
var src = menuPath() + withEmbed(rest);
|
|
447
|
+
if (!overlay) {
|
|
448
|
+
if (!window.OTBDialog || typeof window.OTBDialog.bind !== 'function') throw new Error('no dialog');
|
|
449
|
+
overlay = document.createElement('div');
|
|
450
|
+
overlay.className = 'opt-window';
|
|
451
|
+
overlay.hidden = true;
|
|
452
|
+
overlay.innerHTML = '<div class="opt-window__box" aria-label="Options">' +
|
|
453
|
+
'<button type="button" class="opt-window__x" data-dialog-close aria-label="Close options">×</button>' +
|
|
454
|
+
'<iframe class="opt-window__frame" title="Options"></iframe></div>';
|
|
455
|
+
document.body.appendChild(overlay);
|
|
456
|
+
var frame0 = overlay.querySelector('.opt-window__frame');
|
|
457
|
+
dialog = window.OTBDialog.bind(overlay, {
|
|
458
|
+
panel: overlay.querySelector('.opt-window__box'),
|
|
459
|
+
lockClass: 'opt-window-open',
|
|
460
|
+
initialFocus: function () { return frame0; },
|
|
461
|
+
onOpen: function () { setInert(true); },
|
|
462
|
+
onClose: function () { setInert(false); },
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
var frame = overlay.querySelector('.opt-window__frame');
|
|
466
|
+
// A fresh address reloads the menu; the same one keeps it as it was left.
|
|
467
|
+
if (frame.getAttribute('src') !== src) frame.setAttribute('src', src);
|
|
468
|
+
dialog.open();
|
|
469
|
+
}
|
|
470
|
+
// '?open=x#y' → '?embed=1&open=x#y'; '#y' → '?embed=1#y'.
|
|
471
|
+
function withEmbed(rest) {
|
|
472
|
+
var h = rest.indexOf('#'), q = h === -1 ? rest : rest.slice(0, h), frag = h === -1 ? '' : rest.slice(h);
|
|
473
|
+
q = q.replace(/^\?/, '');
|
|
474
|
+
return '?embed=1' + (q ? '&' + q : '') + frag;
|
|
475
|
+
}
|
|
476
|
+
// The menu's own page never opens the window (nor a page in a frame: see the if below).
|
|
477
|
+
function overlayHere() { return on() && !(document.body && document.body.getAttribute('data-page') === 'settings'); }
|
|
478
|
+
if (!embedded) {
|
|
479
|
+
window.OTBOptionsOverlay = { open: openOverlay, close: closeOverlay, isOpen: overlayOpen };
|
|
480
|
+
// A link to the menu (the visor's Options row, the profile chip, the start
|
|
481
|
+
// screen's Options) opens the window in game mode instead of leaving the place.
|
|
482
|
+
if (document.addEventListener) document.addEventListener('click', function (e) {
|
|
483
|
+
if (e.defaultPrevented || e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || !overlayHere()) return;
|
|
484
|
+
var a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
|
|
485
|
+
if (!a || a.getAttribute('target')) return;
|
|
486
|
+
var to;
|
|
487
|
+
try { to = new URL(a.getAttribute('href'), window.location.href); } catch (_) { return; }
|
|
488
|
+
if (to.origin !== window.location.origin || to.pathname.replace(/\.html$/, '') !== menuPath()) return;
|
|
489
|
+
e.preventDefault();
|
|
490
|
+
var q = to.search.replace(/([?&])from=[^&#]*&?/, '$1').replace(/[?&]$/, '');
|
|
491
|
+
openOverlay(q + to.hash);
|
|
492
|
+
}, true);
|
|
493
|
+
}
|
|
414
494
|
if (window.addEventListener) window.addEventListener('keydown', onEscape);
|
|
415
495
|
// Inside the menu, a link to ANOTHER page leaves the menu for it; a link within the page stays.
|
|
416
496
|
if (embedded && document.addEventListener) {
|
|
@@ -1398,6 +1398,38 @@ html[data-embed="on"] .app-foot,
|
|
|
1398
1398
|
html[data-embed="on"] .app-topbar { display: none; }
|
|
1399
1399
|
html[data-embed="on"] .app-body { margin-left: 0; }
|
|
1400
1400
|
html[data-embed="on"] .app-content { padding-top: 0; }
|
|
1401
|
+
/* An embedded screen is a sub-window, so it reads at window size, not page size
|
|
1402
|
+
(the owner, 2026-11-11: "WoW sizing"): everything in it is drawn smaller, and it
|
|
1403
|
+
has no ground of its own, so the options window's glass and the place behind
|
|
1404
|
+
show through. */
|
|
1405
|
+
html[data-embed="on"] { zoom: 0.82; }
|
|
1406
|
+
html[data-embed="on"], html[data-embed="on"] body { background: transparent; }
|
|
1407
|
+
/* THE OPTIONS WINDOW (shell-game.js openOverlay): in game mode the menu opens over
|
|
1408
|
+
the place you are in. The scrim only darkens a little, so the place stays legible;
|
|
1409
|
+
the window is night glass, so its colours carry through. */
|
|
1410
|
+
html.opt-window-open { overflow: hidden; }
|
|
1411
|
+
.opt-window { position: fixed; inset: 0; z-index: var(--z-modal); display: grid; place-items: center; }
|
|
1412
|
+
.opt-window[hidden] { display: none; }
|
|
1413
|
+
/* the scrim is the overlay's own ground, so a click on it lands on the dialog root (OTBDialog closes) */
|
|
1414
|
+
.opt-window::before { content: ''; position: absolute; inset: 0; background: var(--scrim); opacity: .33; pointer-events: none; }
|
|
1415
|
+
.opt-window__box {
|
|
1416
|
+
position: relative; box-sizing: border-box;
|
|
1417
|
+
width: min(1040px, calc(100vw - 32px)); height: min(720px, calc(100vh - 48px));
|
|
1418
|
+
background: color-mix(in srgb, var(--bg) 48%, transparent);
|
|
1419
|
+
-webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px);
|
|
1420
|
+
border: var(--line-strong) solid var(--rule);
|
|
1421
|
+
border-radius: var(--press-radius);
|
|
1422
|
+
box-shadow: var(--ds-shadow-pop);
|
|
1423
|
+
}
|
|
1424
|
+
.opt-window__frame { display: block; width: 100%; height: 100%; border: 0; background: transparent; color-scheme: dark; }
|
|
1425
|
+
.opt-window__x {
|
|
1426
|
+
position: absolute; top: var(--ds-space-xs); right: var(--ds-space-xs); z-index: 1;
|
|
1427
|
+
width: 28px; height: 28px; padding: 0; display: grid; place-items: center;
|
|
1428
|
+
font-size: var(--ds-text-2xl); line-height: 1; color: var(--ink-soft);
|
|
1429
|
+
background: transparent; border: var(--line) solid var(--rule-soft); border-radius: var(--press-radius); cursor: pointer;
|
|
1430
|
+
}
|
|
1431
|
+
.opt-window__x:hover { color: var(--ink); background: var(--glass); }
|
|
1432
|
+
.opt-window__x:focus-visible { outline: 0; box-shadow: var(--press-focus); }
|
|
1401
1433
|
/* Said once on entering, since the choice is remembered and a page can open in it. */
|
|
1402
1434
|
.fullscreen-hint {
|
|
1403
1435
|
position: fixed;
|
|
@@ -24,3 +24,10 @@ The menu is a Helm screen, written from the hall tier's tokens alone (`settings.
|
|
|
24
24
|
- **Widths:** the panel is `min(--helm-panel-w, 32%)` of the content column; below 860px it stacks above the screen as before. A long label wraps inside its row.
|
|
25
25
|
- **Not built** (the mock shows them on a Controls screen the hall does not have): the key-binding rows, "Reset all keys", and a visible "Back to the city" button. Escape still closes the menu in game mode.
|
|
26
26
|
- **Tests:** `tests/hall_helm.mjs` ("the options menu is a Helm screen"); the kit states in `settings.states.json` render clean at 1440, 390 and 320.
|
|
27
|
+
|
|
28
|
+
## The options window (owner, 2026-11-11: "WoW sizing, an overlay")
|
|
29
|
+
|
|
30
|
+
- **In game mode the menu opens over the place, not as a page.** Escape away from the menu, or a click on any link to `/settings` (the visor's Options row, the profile chip), draws one window (`shell-game.js` `openOverlay`, `window.OTBOptionsOverlay`), built on the shared dialog (`OTBDialog.bind`: Escape, scrim, close button, focus trap and return), with the page behind made `inert` while it is up, holding the menu framed with `?embed=1`; the place stays drawn behind it. Escape, the scrim or the × shuts it; `options-menu.js` `close()` shuts the window when it is in one. A page that cannot draw it falls back to the menu page. A plain visit to `/settings` is the page, as before.
|
|
31
|
+
- **Window sized, see-through.** The window is `min(1040px, …) × min(720px, …)` of glass at 48% of the ground with a light blur, over the shared `--scrim` at a third. Every embedded screen is drawn at `zoom: 0.82` with no ground of its own (`style.css`), and the menu inside it takes smaller words (`settings.css`, `html[data-embed="on"]`); a window-high length divides by the zoom (`--opt-vh`).
|
|
32
|
+
- **One review palette.** A framed screen docks no artist palette of its own (`craft-mode.js` `applyDocked`); the page around it has one.
|
|
33
|
+
- **Tests:** `tests/hall_options_menu.mjs` ("away from the menu, Escape opens the options as a window…"), `tests/page_notes_panel.mjs` (the frame docks no panel).
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.24.
|
|
3
|
+
"version": "1.24.86",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.24.
|
|
9
|
+
"version": "1.24.86",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.24.
|
|
3
|
+
"version": "1.24.86",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -11607,5 +11607,17 @@
|
|
|
11607
11607
|
"id": "1005195",
|
|
11608
11608
|
"text": "The in-game visor is now minimal: two thin arcs, the place name, your credits, one salmon button for the current action and a quiet row of key hints. It stays readable over a bright daytime sky as well as at night."
|
|
11609
11609
|
}
|
|
11610
|
+
],
|
|
11611
|
+
"1.24.85": [
|
|
11612
|
+
{
|
|
11613
|
+
"id": "1005096",
|
|
11614
|
+
"text": "Autobongos now runs on a Mac for real: the setup command, start, stop and remove all proven on one, and the Mac-only test failures are gone."
|
|
11615
|
+
}
|
|
11616
|
+
],
|
|
11617
|
+
"1.24.86": [
|
|
11618
|
+
{
|
|
11619
|
+
"id": "1005208",
|
|
11620
|
+
"text": "In game mode, Options now opens as a smaller see-through window over where you are, with smaller text, and the artist palette no longer shows twice."
|
|
11621
|
+
}
|
|
11610
11622
|
]
|
|
11611
11623
|
}
|
|
@@ -59,6 +59,28 @@ esac
|
|
|
59
59
|
|
|
60
60
|
have() { command -v "$1" >/dev/null 2>&1; }
|
|
61
61
|
|
|
62
|
+
# github_remedy — what to actually DO when git cannot see the project (task 1005096).
|
|
63
|
+
#
|
|
64
|
+
# THE CIRCLE THIS BREAKS, found on a real Mac. "Sign in to GitHub" is the wrong advice for
|
|
65
|
+
# the commonest way this fails: the person IS signed in — twice — and git picked the other
|
|
66
|
+
# one. git asks its FIRST credential helper, which on a Mac is the login keychain, and that
|
|
67
|
+
# answers with whichever GitHub account is active, not with one that can see this project.
|
|
68
|
+
# A private project then 404s as "Repository not found", which reads as a typo rather than
|
|
69
|
+
# as the wrong account, and `gh auth login` again changes nothing.
|
|
70
|
+
#
|
|
71
|
+
# So when the GitHub CLI is here, name the account that is actually active and the one
|
|
72
|
+
# command that changes it. With no gh, the old advice is still the right advice.
|
|
73
|
+
github_remedy() {
|
|
74
|
+
if have gh; then
|
|
75
|
+
active="$(gh auth status --active 2>/dev/null | sed -n 's/.*account \([A-Za-z0-9-]*\).*/\1/p' | head -n 1)"
|
|
76
|
+
if [ -n "${active:-}" ]; then
|
|
77
|
+
echo "git is using your '$active' GitHub account, which may not be able to see this project. Switch with gh auth switch (it lists the accounts signed in here), then paste the setup command again. If the right account is not listed, add it with gh auth login first."
|
|
78
|
+
return
|
|
79
|
+
fi
|
|
80
|
+
fi
|
|
81
|
+
echo "Sign in to GitHub on this device with an account that can see the project (the GitHub CLI's gh auth login is the simplest way), then paste the setup command again."
|
|
82
|
+
}
|
|
83
|
+
|
|
62
84
|
# The setup command is piped into bash, so the keyboard is /dev/tty, not stdin.
|
|
63
85
|
# OPENED, not just tested: with no terminal at all (CI, a remote command) the
|
|
64
86
|
# device node still exists and reads as readable, and opening it is what fails.
|
|
@@ -133,12 +155,12 @@ mkdir -p "$RUNNER_HOME"
|
|
|
133
155
|
if [ -d "$REPO_DIR/.git" ]; then
|
|
134
156
|
echo "The runner's copy is already here; bringing it up to date..."
|
|
135
157
|
if [ "$HAVE_TTY" = 1 ]; then git -C "$REPO_DIR" fetch --quiet origin main </dev/tty; else git -C "$REPO_DIR" fetch --quiet origin main; fi \
|
|
136
|
-
|| stop_setup "this device could not fetch the project's code from GitHub." "
|
|
158
|
+
|| stop_setup "this device could not fetch the project's code from GitHub." "$(github_remedy)"
|
|
137
159
|
git -C "$REPO_DIR" reset --quiet --hard origin/main
|
|
138
160
|
else
|
|
139
161
|
echo "Making the runner's own copy of the project (GitHub may ask you to sign in)..."
|
|
140
162
|
if [ "$HAVE_TTY" = 1 ]; then git clone --quiet "$REPO" "$REPO_DIR" </dev/tty; else git clone --quiet "$REPO" "$REPO_DIR"; fi \
|
|
141
|
-
|| stop_setup "this device could not copy the project from GitHub." "
|
|
163
|
+
|| stop_setup "this device could not copy the project from GitHub." "$(github_remedy)"
|
|
142
164
|
fi
|
|
143
165
|
|
|
144
166
|
HOST_JS="$REPO_DIR/scripts/bongos/autobongos-host.js"
|
|
@@ -125,6 +125,28 @@ class Stop extends Error {
|
|
|
125
125
|
constructor(what, fix) { super(what); this.name = 'Stop'; this.fix = fix || null; }
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
// githubRemedy — what to DO when git cannot see the project (task 1005096). The JS twin of
|
|
129
|
+
// autobongos-bootstrap.sh's github_remedy; the two say the same thing because a person can
|
|
130
|
+
// meet either one, and the bootstrap's own header carries the full reasoning.
|
|
131
|
+
//
|
|
132
|
+
// IN SHORT: "sign in to GitHub" is the wrong advice for the commonest failure. git asks its
|
|
133
|
+
// FIRST credential helper — on a Mac, the login keychain — which answers with whichever
|
|
134
|
+
// GitHub account is ACTIVE, not with one that can see this project. A private project then
|
|
135
|
+
// 404s as "Repository not found", which reads as a typo rather than as the wrong account,
|
|
136
|
+
// and signing in again changes nothing. So name the active account when the GitHub CLI can
|
|
137
|
+
// tell us, and give the one command that changes it.
|
|
138
|
+
const GITHUB_REMEDY_GENERIC = 'Sign in to GitHub on this device with an account that can see the project (the GitHub CLI\'s gh auth login is the simplest way), then run the setup command again.';
|
|
139
|
+
function githubRemedy(run = spawnSync) {
|
|
140
|
+
try {
|
|
141
|
+
const r = run('gh', ['auth', 'status', '--active'], { encoding: 'utf8', windowsHide: true, timeout: 10000 });
|
|
142
|
+
const m = /account ([A-Za-z0-9-]+)/.exec(String((r && r.stdout) || '') + String((r && r.stderr) || ''));
|
|
143
|
+
if (m) {
|
|
144
|
+
return `git is using your '${m[1]}' GitHub account, which may not be able to see this project. Switch with gh auth switch (it lists the accounts signed in here), then run the setup command again. If the right account is not listed, add it with gh auth login first.`;
|
|
145
|
+
}
|
|
146
|
+
} catch (_) { /* no gh, or it could not answer — the generic line is still right */ }
|
|
147
|
+
return GITHUB_REMEDY_GENERIC;
|
|
148
|
+
}
|
|
149
|
+
|
|
128
150
|
// ── the runner's own copy ──────────────────────────────────────────────────
|
|
129
151
|
|
|
130
152
|
function runnerCopyMarkPath(repoRoot = REPO_ROOT) { return path.join(repoRoot, '.git', RUNNER_COPY_MARK); }
|
|
@@ -289,7 +311,14 @@ function git(args, opts = {}) {
|
|
|
289
311
|
function refreshCheckout(log = () => {}) {
|
|
290
312
|
assertRunnerCopy();
|
|
291
313
|
const f = git(['fetch', '--quiet', 'origin', 'main']);
|
|
292
|
-
if (f.status !== 0) {
|
|
314
|
+
if (f.status !== 0) {
|
|
315
|
+
// The log is the ONLY place this is said (the card reads check-ins, not fetches), so it
|
|
316
|
+
// carries the remedy too — a fetch that fails for a credential reason fails every loop,
|
|
317
|
+
// and the runner then works forever on the code it happens to have (task 1005096).
|
|
318
|
+
log(`git fetch failed — running the code already here (${String(f.stderr || '').trim().slice(0, 200)})`);
|
|
319
|
+
log(` if that keeps happening, it is not transient: ${githubRemedy()}`);
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
293
322
|
const r = git(['reset', '--quiet', '--hard', 'origin/main']);
|
|
294
323
|
if (r.status !== 0) { log(`git reset failed (${String(r.stderr || '').trim().slice(0, 200)})`); return false; }
|
|
295
324
|
return true;
|
|
@@ -726,7 +755,7 @@ async function setup(argv, { paths = hostPaths(), say = console.log } = {}) {
|
|
|
726
755
|
|
|
727
756
|
say('4/5 Checking this device can fetch the project’s code…');
|
|
728
757
|
if (git(['ls-remote', 'origin', 'refs/heads/main'], { timeout: 60000 }).status !== 0) {
|
|
729
|
-
throw new Stop('This device could not fetch the project’s code from GitHub.',
|
|
758
|
+
throw new Stop('This device could not fetch the project’s code from GitHub.', githubRemedy());
|
|
730
759
|
}
|
|
731
760
|
|
|
732
761
|
say('5/5 Starting the runner…');
|
|
@@ -895,4 +924,5 @@ module.exports = {
|
|
|
895
924
|
windowsRunArgv, windowsRunCommand, windowsStartSpawn, legacyBootTaskInstalled, launchdPlist,
|
|
896
925
|
stopRunner, stopReport, exchangeCode, claudeState, waitForCheckIn,
|
|
897
926
|
signIn, cleanBase, install, removeStartEntry, installedState, status, digestArgv,
|
|
927
|
+
githubRemedy, GITHUB_REMEDY_GENERIC,
|
|
898
928
|
};
|
|
@@ -90,12 +90,19 @@ function planRemedy(inst, reason, o = {}) {
|
|
|
90
90
|
if (!SHELL_SAFE_RE.test(backupDir)) return { refused: 'the backup folder is not a path this runner will put in a command' };
|
|
91
91
|
// EXACTLY db-backup-nightly.sh's name, so this never touches a sibling project's dumps
|
|
92
92
|
// (`test` must not prune `test-nk`'s — task 1003948). The timestamp sorts by name, so
|
|
93
|
-
//
|
|
93
|
+
// dropping all but the last K leaves the newest K. Nothing is deleted when K or fewer exist.
|
|
94
|
+
//
|
|
95
|
+
// ALL-BUT-THE-LAST-K IN POSIX awk, NOT `head -n -K` (task 1005096). `head -n -K` is a GNU
|
|
96
|
+
// extension: BSD/macOS head answers `illegal line count`, exits 1, and the pipeline then
|
|
97
|
+
// deletes nothing — and `xargs -r` is GNU-only too. Same family as the `stat -c` sweep
|
|
98
|
+
// (task 1004170): the command runs on a Linux box, but the test that runs it for real runs
|
|
99
|
+
// wherever a builder builds, so on a Mac it could never pass. POSIX `xargs` with no input
|
|
100
|
+
// may still run `rm -f --`, which succeeds with no operands, so `-r` is not needed.
|
|
94
101
|
const D = '[0-9]';
|
|
95
102
|
const glob = `${db}-${D}${D}${D}${D}-${D}${D}-${D}${D}T${D}${D}${D}${D}${D}${D}Z.sql.gz`;
|
|
96
103
|
return {
|
|
97
104
|
reason, cwd: null, env: null,
|
|
98
|
-
cmd: `find ${backupDir} -maxdepth 1 -type f -name '${glob}' | sort |
|
|
105
|
+
cmd: `find ${backupDir} -maxdepth 1 -type f -name '${glob}' | sort | awk -v k=${DUMP_KEEP_FLOOR} '{a[NR]=$0} END{for(i=1;i<=NR-k;i++) print a[i]}' | xargs rm -f --`,
|
|
99
106
|
describe: `pruned ${db}'s old database backups in ${backupDir}, keeping the newest ${DUMP_KEEP_FLOOR}`,
|
|
100
107
|
};
|
|
101
108
|
}
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.24.
|
|
84
|
+
const CORE_VERSION = '1.24.86'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -663,3 +663,36 @@ test('the digest is read from the RUNNER’s run log: bare, the digest CLI looks
|
|
|
663
663
|
assert.equal(bare.status, 0, 'the digest never fails a morning');
|
|
664
664
|
assert.equal(bare.stdout.includes('424242'), false, 'which is why the guide sends people through the device program');
|
|
665
665
|
});
|
|
666
|
+
|
|
667
|
+
// --- "sign in to GitHub" is the wrong advice when you already are (task 1005096) -------
|
|
668
|
+
//
|
|
669
|
+
// Found by running the real setup on a real Mac. The device had TWO GitHub accounts signed
|
|
670
|
+
// in through the GitHub CLI; git asks its first credential helper — the login keychain —
|
|
671
|
+
// which answers with whichever is ACTIVE, and that one could not see the private project.
|
|
672
|
+
// git then says "Repository not found", which reads as a typo, and the setup answered
|
|
673
|
+
// "Sign in to GitHub … gh auth login is the simplest way" — advice that changes nothing,
|
|
674
|
+
// because the person is signed in. The remedy has to NAME the account git is using.
|
|
675
|
+
test('githubRemedy names the active GitHub account, and the command that changes it', () => {
|
|
676
|
+
const said = host.githubRemedy(() => ({ stdout: 'github.com\n ✓ Logged in to github.com account mcip-labs (keyring)\n', stderr: '' }));
|
|
677
|
+
assert.match(said, /'mcip-labs'/, 'the account git is actually using must be named, or the advice is a circle');
|
|
678
|
+
assert.match(said, /gh auth switch/, 'and the one command that changes it');
|
|
679
|
+
assert.notEqual(said, host.GITHUB_REMEDY_GENERIC);
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
test('githubRemedy falls back to the plain advice when gh cannot answer', () => {
|
|
683
|
+
// No GitHub CLI on the device, it failed, or it is signed in to nothing: there is no
|
|
684
|
+
// account to name, and "sign in" is then exactly the right thing to say.
|
|
685
|
+
assert.equal(host.githubRemedy(() => ({ stdout: '', stderr: 'command not found' })), host.GITHUB_REMEDY_GENERIC);
|
|
686
|
+
assert.equal(host.githubRemedy(() => { throw new Error('ENOENT'); }), host.GITHUB_REMEDY_GENERIC);
|
|
687
|
+
assert.equal(host.githubRemedy(() => ({ stdout: 'You are not logged into any GitHub hosts.\n', stderr: '' })), host.GITHUB_REMEDY_GENERIC);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test('the shell bootstrap gives the same remedy, so either half says the same thing', () => {
|
|
691
|
+
const sh = readFileSync(fileURLToPath(new URL('../scripts/bongos/autobongos-bootstrap.sh', import.meta.url)), 'utf8');
|
|
692
|
+
assert.match(sh, /github_remedy\(\)/, 'the bootstrap has the twin');
|
|
693
|
+
assert.match(sh, /gh auth switch/);
|
|
694
|
+
// Both clone/fetch stops route through it rather than re-typing the old line.
|
|
695
|
+
const stops = sh.split('\n').filter((l) => /stop_setup "this device could not (copy|fetch)/.test(l));
|
|
696
|
+
assert.equal(stops.length, 2, 'the two GitHub stops');
|
|
697
|
+
for (const l of stops) assert.match(l, /\$\(github_remedy\)/, l.trim());
|
|
698
|
+
});
|
|
@@ -138,7 +138,7 @@ test('the Settings page IS the menu: one search over the groups, loaded before s
|
|
|
138
138
|
|
|
139
139
|
// ---- shell-game.js: the embedded page and game mode's Escape --------------------
|
|
140
140
|
|
|
141
|
-
function bootGame({ search = '', framed = true, game = false, path: p = '/my-agents' } = {}) {
|
|
141
|
+
function bootGame({ search = '', framed = true, game = false, path: p = '/my-agents', dom = false } = {}) {
|
|
142
142
|
const docListeners = {};
|
|
143
143
|
const winListeners = {};
|
|
144
144
|
const timers = [];
|
|
@@ -156,13 +156,40 @@ function bootGame({ search = '', framed = true, game = false, path: p = '/my-age
|
|
|
156
156
|
win.parent = framed ? parent : win;
|
|
157
157
|
const document = {
|
|
158
158
|
documentElement: root,
|
|
159
|
-
body: { setAttribute(k, v) { bodyAttrs[k] = v; } },
|
|
159
|
+
body: { setAttribute(k, v) { bodyAttrs[k] = v; }, getAttribute: (k) => (k in bodyAttrs ? bodyAttrs[k] : null) },
|
|
160
160
|
addEventListener(t, fn) { (docListeners[t] ||= []).push(fn); },
|
|
161
161
|
};
|
|
162
|
+
// dom: enough of a page for the game's options window to be drawn (createElement,
|
|
163
|
+
// a sibling the window makes inert, a frame that keeps its src), with OTBDialog.bind
|
|
164
|
+
// standing in for hall-dialog.js (its trap, Escape and focus return are tested there).
|
|
165
|
+
const windows = [];
|
|
166
|
+
const bound = [];
|
|
167
|
+
const sibling = { attrs: {}, setAttribute(k, v) { this.attrs[k] = v; }, hasAttribute(k) { return k in this.attrs; }, removeAttribute(k) { delete this.attrs[k]; } };
|
|
168
|
+
if (dom) {
|
|
169
|
+
document.body.children = [sibling];
|
|
170
|
+
document.body.appendChild = (el) => { windows.push(el); document.body.children.push(el); return el; };
|
|
171
|
+
document.createElement = () => {
|
|
172
|
+
const frameAttrs = {};
|
|
173
|
+
const frame = { getAttribute: (k) => (k in frameAttrs ? frameAttrs[k] : null), setAttribute: (k, v) => { frameAttrs[k] = v; }, focus() {} };
|
|
174
|
+
const box = { box: true };
|
|
175
|
+
return { hidden: false, innerHTML: '', querySelector: (sel) => (sel === '.opt-window__box' ? box : frame), frame, box };
|
|
176
|
+
};
|
|
177
|
+
win.OTBDialog = {
|
|
178
|
+
bind(root, opts) {
|
|
179
|
+
let open = false;
|
|
180
|
+
bound.push({ root, opts });
|
|
181
|
+
return {
|
|
182
|
+
open() { if (open) return; open = true; root.hidden = false; root.classes = [opts.lockClass]; opts.onOpen(); },
|
|
183
|
+
close() { if (!open) return; open = false; root.hidden = true; root.classes = []; opts.onClose(); },
|
|
184
|
+
isOpen: () => open,
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
162
189
|
const ctx = vm.createContext({ window: win, document, URLSearchParams, URL });
|
|
163
190
|
vm.runInContext(read('shell-game.js'), ctx);
|
|
164
191
|
return {
|
|
165
|
-
root, bodyAttrs, parent, assigned, game: win.OTBShellGame,
|
|
192
|
+
root, bodyAttrs, parent, assigned, windows, bound, sibling, win, game: win.OTBShellGame,
|
|
166
193
|
press(key, target = { tagName: 'DIV' }) {
|
|
167
194
|
const ev = { key, target, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
|
|
168
195
|
for (const fn of winListeners.keydown || []) fn(ev);
|
|
@@ -173,7 +200,7 @@ function bootGame({ search = '', framed = true, game = false, path: p = '/my-age
|
|
|
173
200
|
const attrs = { href };
|
|
174
201
|
if (target) attrs.target = target;
|
|
175
202
|
const a = { getAttribute: (k) => (k in attrs ? attrs[k] : null), setAttribute: (k, v) => { attrs[k] = v; } };
|
|
176
|
-
for (const fn of docListeners.click || []) fn({ target: { closest: () => a } });
|
|
203
|
+
for (const fn of docListeners.click || []) fn({ target: { closest: () => a }, preventDefault() { this.defaultPrevented = true; } });
|
|
177
204
|
return attrs.target || null;
|
|
178
205
|
},
|
|
179
206
|
};
|
|
@@ -214,6 +241,38 @@ test('GAME MODE: on the menu itself the last Escape closes it; outside game mode
|
|
|
214
241
|
assert.deepEqual(off.assigned, [], 'outside game mode the press stays the shell\'s');
|
|
215
242
|
});
|
|
216
243
|
|
|
244
|
+
test('GAME MODE: away from the menu, Escape opens the options as a window over the place, and Escape again shuts it', () => {
|
|
245
|
+
const city = bootGame({ game: true, framed: false, path: '/city', dom: true });
|
|
246
|
+
city.press('Escape');
|
|
247
|
+
assert.deepEqual(city.assigned, [], 'the place stays: no page change');
|
|
248
|
+
assert.equal(city.windows.length, 1, 'one window drawn over it');
|
|
249
|
+
assert.equal(city.windows[0].frame.getAttribute('src'), '/settings?embed=1', 'the menu, framed and chromeless');
|
|
250
|
+
assert.equal(city.bound.length, 1, 'the window is the shared dialog: OTBDialog.bind owns Escape, the scrim, the focus trap and return');
|
|
251
|
+
assert.equal(city.bound[0].opts.panel, city.windows[0].box, 'the glass box is the dialog panel');
|
|
252
|
+
assert.equal(city.bound[0].opts.lockClass, 'opt-window-open', 'the page behind stops scrolling');
|
|
253
|
+
assert.equal(city.bound[0].opts.initialFocus(), city.windows[0].frame, 'focus goes into the menu');
|
|
254
|
+
assert.equal(city.sibling.hasAttribute('inert'), true, 'the page behind is inert, so Tab out of the frame cannot reach it');
|
|
255
|
+
assert.equal(city.win.OTBOptionsOverlay.isOpen(), true);
|
|
256
|
+
city.press('Escape');
|
|
257
|
+
assert.equal(city.win.OTBOptionsOverlay.isOpen(), false, 'the second Escape shuts it');
|
|
258
|
+
assert.equal(city.sibling.hasAttribute('inert'), false, 'and the page behind is live again');
|
|
259
|
+
city.press('Escape');
|
|
260
|
+
assert.equal(city.windows.length, 1, 'reopening reuses the one window');
|
|
261
|
+
assert.equal(city.bound.length, 1, 'bound once');
|
|
262
|
+
// a link to the menu (the visor's Options row) opens the window at that screen
|
|
263
|
+
city.win.OTBOptionsOverlay.close();
|
|
264
|
+
city.click('/settings?from=%2Fcity#access');
|
|
265
|
+
assert.equal(city.windows[0].frame.getAttribute('src'), '/settings?embed=1#access', 'the screen it named, without ?from=');
|
|
266
|
+
assert.equal(city.win.OTBOptionsOverlay.isOpen(), true);
|
|
267
|
+
// outside game mode the link is a link
|
|
268
|
+
const off = bootGame({ game: false, framed: false, path: '/city', dom: true });
|
|
269
|
+
off.click('/settings');
|
|
270
|
+
assert.equal(off.windows.length, 0);
|
|
271
|
+
// and the menu, closing inside the window, shuts the window rather than leaving the page
|
|
272
|
+
const om = read('options-menu.js');
|
|
273
|
+
assert.ok(om.includes('window.parent.OTBOptionsOverlay') && om.includes('holder.close(); return;'), 'close() shuts the window first');
|
|
274
|
+
});
|
|
275
|
+
|
|
217
276
|
test('options-menu.js gives the menu its Escape: close in game mode, nothing outside it', () => {
|
|
218
277
|
const src = read('options-menu.js');
|
|
219
278
|
assert.match(src, /function escape\(\) \{\s*var game = window\.OTBShellGame;\s*if \(game && game\.on && game\.on\(\)\) close\(\);/);
|
|
@@ -41,7 +41,7 @@ function node(tag, id, cls) {
|
|
|
41
41
|
}
|
|
42
42
|
function all(root) { return root.children.flatMap((c) => [c, ...all(c)]); }
|
|
43
43
|
|
|
44
|
-
function boot({ session = {}, search = '' } = {}) {
|
|
44
|
+
function boot({ session = {}, search = '', embedded = false } = {}) {
|
|
45
45
|
const sidebar = node('NAV', 'app-sidebar');
|
|
46
46
|
const topbar = node('DIV', 'app-topbar');
|
|
47
47
|
const side = topbar.appendChild(node('DIV', '', 'topbar-side'));
|
|
@@ -61,6 +61,7 @@ function boot({ session = {}, search = '' } = {}) {
|
|
|
61
61
|
const mk = (obj) => ({ getItem: (k) => (k in obj ? obj[k] : null), setItem: (k, v) => { obj[k] = String(v); }, removeItem: (k) => { delete obj[k]; } });
|
|
62
62
|
const opened = [];
|
|
63
63
|
const window = { OTB: realOTB(), localStorage: mk({}), sessionStorage: mk(session), matchMedia: () => ({ matches: false }), location: { search } };
|
|
64
|
+
if (embedded) window.OTBShellGame = { embedded: () => true };
|
|
64
65
|
// "Loading" page-notes.js: record the tag, then define the API it would define.
|
|
65
66
|
head.onAppend = (tag) => {
|
|
66
67
|
loaded.push(tag.src);
|
|
@@ -94,6 +95,13 @@ test('THE PANEL: docked at the edge in Artist alone, with no top-bar button, and
|
|
|
94
95
|
assert.deepEqual(none.loaded, [], 'and nothing loads');
|
|
95
96
|
});
|
|
96
97
|
|
|
98
|
+
test('a screen in the options menu frame docks no second panel: the menu around it has one', async () => {
|
|
99
|
+
const p = boot({ session: { 'hall-craft': 'artist' }, embedded: true });
|
|
100
|
+
await flush();
|
|
101
|
+
assert.deepEqual(p.opened, [], 'no dock inside the frame');
|
|
102
|
+
assert.deepEqual(p.loaded, [], 'and the panel script never loads there');
|
|
103
|
+
});
|
|
104
|
+
|
|
97
105
|
test('the panel script loads once, at the same version as craft-mode.js', async () => {
|
|
98
106
|
const p = boot({ session: { 'hall-craft': 'artist' } });
|
|
99
107
|
await flush();
|
|
@@ -47,3 +47,37 @@ test('the rule catches a lone GNU or BSD stat, and passes the paired form and pr
|
|
|
47
47
|
assert.equal(offending('U=$(stat -c %U "$d" 2>/dev/null || stat -f %Su "$d" 2>/dev/null)').length, 0);
|
|
48
48
|
assert.equal(offending(" # was stat -c '%s', which BSD refuses").length, 0, 'a comment is not a call');
|
|
49
49
|
});
|
|
50
|
+
|
|
51
|
+
// --- `head -n -K` — the same bug, a different GNU extension (task 1005096) ------------
|
|
52
|
+
//
|
|
53
|
+
// `head -n -K` means "all but the last K" to GNU and is an ERROR to BSD/macOS head
|
|
54
|
+
// ("illegal line count", exit 1). wedge-remedy.js's disk-full prune used it, so on a Mac
|
|
55
|
+
// the pipeline deleted nothing and the test that runs that command for real could never
|
|
56
|
+
// pass. The command itself runs on a Linux box — but a builder builds wherever they are,
|
|
57
|
+
// so a one-dialect idiom still reds their suite. POSIX awk answers it everywhere:
|
|
58
|
+
// … | sort | awk -v k=K '{a[NR]=$0} END{for(i=1;i<=NR-k;i++) print a[i]}'
|
|
59
|
+
//
|
|
60
|
+
// Scanned in the .js files too, not only *.sh: this one lived inside a shell string a
|
|
61
|
+
// Node script builds, which the stat scan above would never have seen.
|
|
62
|
+
const GNU_HEAD = /(^|[^\w-])head\s+(-n\s*-\d|--lines[\s=]-\d|-\s*-\d)/;
|
|
63
|
+
|
|
64
|
+
test('no tracked script asks for `head -n -K`, which BSD/macOS head refuses', () => {
|
|
65
|
+
const files = execFileSync('git', ['ls-files', '*.sh', 'scripts/*.js', 'scripts/**/*.js'], { cwd: ROOT, encoding: 'utf8' })
|
|
66
|
+
.split('\n').filter(Boolean);
|
|
67
|
+
assert.ok(files.length > 20, `the scan must see the scripts, got ${files.length}`);
|
|
68
|
+
const hits = files.flatMap((f) => fs.readFileSync(path.join(ROOT, f), 'utf8')
|
|
69
|
+
.split('\n').map((l, i) => ({ n: i + 1, l }))
|
|
70
|
+
.filter(({ l }) => !/^\s*(#|\/\/)/.test(l) && GNU_HEAD.test(l))
|
|
71
|
+
.map(({ n, l }) => `${f}:${n}: ${l.trim()}`));
|
|
72
|
+
assert.deepEqual(hits, [], "all but the last K: sort | awk -v k=K '{a[NR]=$0} END{for(i=1;i<=NR-k;i++) print a[i]}'");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('the head rule catches the GNU forms and leaves an ordinary head alone', () => {
|
|
76
|
+
const hit = (s) => GNU_HEAD.test(s);
|
|
77
|
+
assert.equal(hit("… | head -n -7 | xargs rm -f --"), true);
|
|
78
|
+
assert.equal(hit('… | head -n-7'), true);
|
|
79
|
+
assert.equal(hit('… | head --lines=-7'), true);
|
|
80
|
+
assert.equal(hit('… | head -n 7'), false, 'the ordinary form is portable');
|
|
81
|
+
assert.equal(hit('… | head -7'), false);
|
|
82
|
+
assert.equal(hit('git log | head -n 20'), false);
|
|
83
|
+
});
|
|
@@ -76,7 +76,11 @@ function writeFile(p, body) {
|
|
|
76
76
|
|
|
77
77
|
// Build one fixture. `mainAt`: 'primary' | 'linked' | 'none'.
|
|
78
78
|
function buildFixture(mainAt) {
|
|
79
|
-
|
|
79
|
+
// realpath'd (task 1005096): on a Mac os.tmpdir() is /var/folders/…, a symlink to
|
|
80
|
+
// /private/var/folders/…, and resolveRoots answers with git's own answer — the /private
|
|
81
|
+
// form. Comparing the two spellings failed on every Mac and passed on CI's Linux, which
|
|
82
|
+
// has no such link. The fixture names the path the code under test will name.
|
|
83
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), `bongos-vendored-${mainAt}-`)));
|
|
80
84
|
const origin = path.join(root, 'origin.git');
|
|
81
85
|
const inst = path.join(root, 'instance');
|
|
82
86
|
git(root, 'init', '--bare', '-b', 'main', origin);
|