@bongos/core 1.19.120 → 1.19.122
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 +60 -30
- package/.claude/skills/builder-end/SKILL.md +3 -3
- package/clients/bongos-client/index.d.ts +1 -1
- package/config/branding.neutral.json +1 -0
- package/docs/api/openapi.json +3 -0
- package/docs/api-reference.md +1 -1
- package/docs/branding-contract.md +1 -0
- package/docs/file-map.md +2 -0
- package/docs/recipes/ops-gotchas.md +2 -0
- package/docs/recipes/windows-builders.md +103 -0
- package/modules/provisioning/provisioning.js +37 -0
- package/modules/provisioning/routes/provisioning.js +93 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +38 -2
- package/scripts/gds/api.js +12 -2
- package/scripts/gds/cli-lib.js +29 -0
- package/scripts/gds/init.js +8 -2
- package/scripts/gds/onboard.js +1 -1
- package/scripts/gds/ship.js +52 -7
- package/scripts/gds/worktree.js +252 -0
- package/src/bongos/serve-internal.js +11 -2
- package/src/module-api.js +5 -1
- package/tests/api_msys_path_repair.mjs +104 -0
- package/tests/provisioning_default_domain.mjs +331 -0
- package/tests/provisioning_domain_attach.mjs +1 -0
- package/tests/provisioning_teardown_intent.mjs +1 -0
- package/tests/ship_post_land_verify.mjs +114 -0
- package/tests/worktree_helper.mjs +145 -0
|
@@ -55,6 +55,16 @@ function mothershipOrigin() {
|
|
|
55
55
|
catch { return ''; }
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// The apex under which new projects get their auto-assigned <slug>.<apex> address
|
|
59
|
+
// (task 1002704). A dedicated branding key — NOT publicOrigin, which on a control
|
|
60
|
+
// plane that co-hosts a game is the GAME's domain (see mothershipOrigin above), so
|
|
61
|
+
// deriving from it would assign customers under the wrong zone. Blank/absent →
|
|
62
|
+
// no auto-assign: self-host and unconfigured instances keep creating domainless.
|
|
63
|
+
function instanceApex() {
|
|
64
|
+
try { return (api.branding().domains || {}).instanceBase || ''; }
|
|
65
|
+
catch { return ''; }
|
|
66
|
+
}
|
|
67
|
+
|
|
58
68
|
// The provisioning mothership's product brand (e.g. "Cloud Bongos" on the control plane),
|
|
59
69
|
// used to brand-qualify the auto GitHub App name so a common project slug ("demo") does
|
|
60
70
|
// not collide with an existing GitHub App (task 2093). Portable — reads THIS instance's
|
|
@@ -311,6 +321,7 @@ module.exports = function provisioningRoutes() {
|
|
|
311
321
|
target_ref: { type: 'string', maxLength: 200 },
|
|
312
322
|
hosting_shape: { type: 'string', maxLength: 20 },
|
|
313
323
|
domain: { type: 'string', maxLength: 253 },
|
|
324
|
+
no_address: { type: 'boolean' },
|
|
314
325
|
tier: { type: 'string', maxLength: 40 },
|
|
315
326
|
onboard_mode: { type: 'string', maxLength: 20 },
|
|
316
327
|
// the declared project Type (task 1002772) — optional, closed vocabulary
|
|
@@ -337,10 +348,57 @@ module.exports = function provisioningRoutes() {
|
|
|
337
348
|
});
|
|
338
349
|
}
|
|
339
350
|
try {
|
|
351
|
+
// A re-request must not re-litigate the address: createInstance never updates
|
|
352
|
+
// an existing row's domain, so for one the guards below could only misfire —
|
|
353
|
+
// a "Try again" on a pre-existing reserved-label row, or domain_taken fired
|
|
354
|
+
// against the row's own address. Birth-time rules run at birth only.
|
|
355
|
+
const existing = await provisioning.getInstanceBySlug(pool, slug);
|
|
356
|
+
// Resolve the address (task 1002704 — auto-assign, opt-out). An explicit
|
|
357
|
+
// domain (bring-your-own) wins; `no_address: true` is the deliberate opt-out;
|
|
358
|
+
// otherwise default to <slug>.<instanceBase> so an API-first caller inherits
|
|
359
|
+
// the same day-one address the wizard promises. An omitted domain is NOT an
|
|
360
|
+
// opt-out. Explicit domains are normalized + refused when malformed — the
|
|
361
|
+
// same bar the attach route has always applied (create inserted them raw).
|
|
362
|
+
let domain;
|
|
363
|
+
const assignedDefault = provisioning.defaultDomainForSlug(slug, instanceApex());
|
|
364
|
+
if (!existing) {
|
|
365
|
+
if (String(body.domain || '').trim()) {
|
|
366
|
+
domain = provisioning.normalizeHostname(body.domain);
|
|
367
|
+
if (!domain) {
|
|
368
|
+
return res.fail('bad_domain', 400, { reason: 'not a plausible hostname (letters/digits/dashes, at least two labels)' });
|
|
369
|
+
}
|
|
370
|
+
} else if (!body.no_address) {
|
|
371
|
+
domain = assignedDefault || undefined;
|
|
372
|
+
}
|
|
373
|
+
if (domain && provisioning.isReservedHostname(domain, instanceApex())) {
|
|
374
|
+
return res.fail('domain_reserved', {
|
|
375
|
+
status: 400,
|
|
376
|
+
message: `${domain} is a platform-reserved address — pick a different handle or bring your own domain.`,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
// Inside the platform apex, the only address a project can hold is its own
|
|
380
|
+
// <slug>.<apex> — any other label under the apex is a squat on another
|
|
381
|
+
// handle's address space. Domains outside the apex are the real BYO path.
|
|
382
|
+
const apexZone = provisioning.normalizeHostname(instanceApex());
|
|
383
|
+
if (domain && apexZone && domain.endsWith(`.${apexZone}`) && domain !== assignedDefault) {
|
|
384
|
+
return res.fail('domain_reserved', {
|
|
385
|
+
status: 400,
|
|
386
|
+
message: `Inside ${apexZone}, a project's address is its own <handle>.${apexZone} — bring a domain you own instead.`,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
// The same uniqueness bar as the attach route: one owner's address can
|
|
390
|
+
// never silently shadow another's.
|
|
391
|
+
if (domain) {
|
|
392
|
+
const holder = await provisioning.getInstanceByDomain(pool, domain);
|
|
393
|
+
if (holder && holder.slug !== slug) {
|
|
394
|
+
return res.fail('domain_taken', { status: 409, message: 'That address is already held by another instance.' });
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
340
398
|
const { instance, created } = await provisioning.createInstance(pool, {
|
|
341
399
|
ownerBuilderId: req.builder.id, slug,
|
|
342
400
|
targetRef: body.target_ref, hostingShape: shape,
|
|
343
|
-
domain
|
|
401
|
+
domain, tier: body.tier, onboardMode,
|
|
344
402
|
type: body.type,
|
|
345
403
|
});
|
|
346
404
|
// Slug already taken by ANOTHER builder — refuse (never reassign ownership).
|
|
@@ -360,7 +418,7 @@ module.exports = function provisioningRoutes() {
|
|
|
360
418
|
if (created) {
|
|
361
419
|
await provisioning.recordEvent(pool, {
|
|
362
420
|
instanceId: instance.id, ownerBuilderId: req.builder.id, event: 'request',
|
|
363
|
-
detail: `provision requested (shape=${shape}, mode=${onboardMode}${
|
|
421
|
+
detail: `provision requested (shape=${shape}, mode=${onboardMode}${domain ? `, domain=${domain} (${domain === assignedDefault ? 'assigned' : 'byo'})` : ', no address'})`,
|
|
364
422
|
actor: 'api:self',
|
|
365
423
|
}).catch(() => {});
|
|
366
424
|
}
|
|
@@ -376,6 +434,15 @@ module.exports = function provisioningRoutes() {
|
|
|
376
434
|
};
|
|
377
435
|
}
|
|
378
436
|
}
|
|
437
|
+
// …and a re-request cannot REMOVE one either (task 1002704's opt-out only
|
|
438
|
+
// applies at birth) — same honesty, opposite direction.
|
|
439
|
+
let noAddressIgnored;
|
|
440
|
+
if (!created && body.no_address && instance.domain) {
|
|
441
|
+
noAddressIgnored = {
|
|
442
|
+
current: instance.domain,
|
|
443
|
+
hint: "a re-request never removes an existing instance's address",
|
|
444
|
+
};
|
|
445
|
+
}
|
|
379
446
|
// Same honesty for a changed Type (task 1002772): a re-request keeps the
|
|
380
447
|
// original declaration, and unlike domain there is no mutation route yet —
|
|
381
448
|
// say so rather than silently dropping the new answer.
|
|
@@ -392,6 +459,7 @@ module.exports = function provisioningRoutes() {
|
|
|
392
459
|
queued: intentCreated,
|
|
393
460
|
created,
|
|
394
461
|
...(domainIgnored ? { domain_ignored: domainIgnored } : {}),
|
|
462
|
+
...(noAddressIgnored ? { no_address_ignored: noAddressIgnored } : {}),
|
|
395
463
|
...(typeIgnored ? { type_ignored: typeIgnored } : {}),
|
|
396
464
|
});
|
|
397
465
|
} catch (err) {
|
|
@@ -497,16 +565,38 @@ module.exports = function provisioningRoutes() {
|
|
|
497
565
|
if (!domain) {
|
|
498
566
|
return res.fail('bad_domain', 400, { reason: 'a real multi-label hostname (e.g. myproject.example.com) — no scheme, path, or bare TLD' });
|
|
499
567
|
}
|
|
568
|
+
// The runner's DNS write is an UPSERT, so an attach may not claim a
|
|
569
|
+
// platform-reserved name any more than a create may (task 1002704).
|
|
570
|
+
if (provisioning.isReservedHostname(domain, instanceApex())) {
|
|
571
|
+
return res.fail('domain_reserved', {
|
|
572
|
+
status: 400,
|
|
573
|
+
message: `${domain} is a platform-reserved address — bring a domain of your own instead.`,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
500
576
|
const inst = await provisioning.getInstanceById(pool, id);
|
|
501
577
|
if (!inst || String(inst.owner_builder_id) !== String(req.builder.id)) {
|
|
502
578
|
return res.fail('instance_not_found', 404);
|
|
503
579
|
}
|
|
580
|
+
// Same rule as create: inside the platform apex the only address an instance
|
|
581
|
+
// can hold is its own <slug>.<apex> — anything else is a squat (task 1002704).
|
|
582
|
+
const apexZone = provisioning.normalizeHostname(instanceApex());
|
|
583
|
+
const assignedDefault = provisioning.defaultDomainForSlug(inst.slug, instanceApex());
|
|
584
|
+
if (apexZone && domain.endsWith(`.${apexZone}`) && domain !== assignedDefault) {
|
|
585
|
+
return res.fail('domain_reserved', {
|
|
586
|
+
status: 400,
|
|
587
|
+
message: `Inside ${apexZone}, an instance's address is its own <handle>.${apexZone} — bring a domain you own instead.`,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
504
590
|
const current = provisioning.normalizeHostname(inst.domain);
|
|
505
591
|
if (current && current === domain) {
|
|
506
592
|
return res.json({ ok: true, changed: false, queued: false, domain: inst.domain,
|
|
507
593
|
message: 'This instance already has that address.' });
|
|
508
594
|
}
|
|
509
|
-
|
|
595
|
+
// A platform-ASSIGNED address may be upgraded away to a bring-your-own
|
|
596
|
+
// domain — that upgrade path is half the task-1002704 decision (the old
|
|
597
|
+
// record lingers in DNS, the idea-1000710 class). A genuine BYO address
|
|
598
|
+
// stays un-re-pointable, as before.
|
|
599
|
+
if (current && current !== assignedDefault) {
|
|
510
600
|
return res.fail('domain_already_set', { status: 409,
|
|
511
601
|
message: `This instance is already addressed as '${inst.domain}' — re-pointing an existing address is not supported here.`,
|
|
512
602
|
details: { current: inst.domain } });
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.122",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.122",
|
|
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.19.
|
|
3
|
+
"version": "1.19.122",
|
|
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",
|
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>{{productName}} — projects</title>
|
|
7
7
|
<meta name="description" content="{{tagline}} Browse the map of what is being built, manage your own projects, and start a new one.">
|
|
8
|
+
<!-- the apex new projects are addressed under (server-stamped, task 1002704) —
|
|
9
|
+
the wizard's address preview reads this so it matches what the server will
|
|
10
|
+
actually assign; blank/unstamped → fall back to guessing from the host -->
|
|
11
|
+
<meta name="instance-base" content="{{instanceBase}}">
|
|
8
12
|
<!-- Fonts: the production instance also injects these from the branding pack
|
|
9
13
|
(config/branding.json theme.ui.fonts.links); kept here so the page reads
|
|
10
14
|
correctly when viewed standalone. -->
|
|
@@ -2054,6 +2058,12 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
2054
2058
|
dotless/local host (localhost, an IP) there is nothing sane to assign, so
|
|
2055
2059
|
the step degrades to the old optional-domain form. ── */
|
|
2056
2060
|
function apexBase() {
|
|
2061
|
+
/* the server-stamped apex wins — it is what POST /provisioning/instances
|
|
2062
|
+
will actually assign; the host guess below can disagree on a subdomain
|
|
2063
|
+
hub (e.g. get.<apex>) and would then preview an address that 400s */
|
|
2064
|
+
var meta = document.querySelector('meta[name="instance-base"]');
|
|
2065
|
+
var stamped = meta ? String(meta.content || '').toLowerCase() : '';
|
|
2066
|
+
if (stamped && stamped.indexOf('{{') < 0 && stamped.indexOf('.') > 0) return stamped;
|
|
2057
2067
|
var h = String(location.hostname || '').toLowerCase().replace(/^www\./, '');
|
|
2058
2068
|
if (!h || h.indexOf('.') < 0) return null;
|
|
2059
2069
|
if (/^(localhost|127\.|\d+\.\d+\.\d+\.\d+$|\[)/.test(h)) return null;
|
|
@@ -2479,6 +2489,9 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
2479
2489
|
var code = err && err.data && err.data.error && (err.data.error.code || '');
|
|
2480
2490
|
if (code === 'slug_taken') return 'That handle is already taken by another project. Pick a different one.';
|
|
2481
2491
|
if (code === 'bad_slug') return 'The handle didn’t pass the server’s checks — 2–40 lowercase letters, numbers, or dashes.';
|
|
2492
|
+
if (code === 'domain_reserved') return 'That address is reserved for the platform itself. Pick a different handle, or bring a domain you own.';
|
|
2493
|
+
if (code === 'domain_taken') return 'That address is already held by another project. Pick a different handle or domain.';
|
|
2494
|
+
if (code === 'bad_domain') return 'The domain didn’t parse as a real hostname — use something like myproject.example.com.';
|
|
2482
2495
|
return 'Couldn’t file the request: ' + (err && err.message ? err.message : 'something went sideways. Try again.');
|
|
2483
2496
|
}
|
|
2484
2497
|
|
|
@@ -2532,6 +2545,10 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
2532
2545
|
type: state.projType || undefined,
|
|
2533
2546
|
hosting_shape: 'standalone',
|
|
2534
2547
|
domain: resolvedDomain() || undefined,
|
|
2548
|
+
/* The server now defaults an omitted domain to the assigned address
|
|
2549
|
+
(task 1002704), so the opt-out must travel explicitly — an absent
|
|
2550
|
+
domain no longer means "none". */
|
|
2551
|
+
no_address: state.noAddress ? true : undefined,
|
|
2535
2552
|
target_ref: state.owner + '/' + state.repo,
|
|
2536
2553
|
/* LOAD-BEARING for the control-plane runner, not just for the checklist we
|
|
2537
2554
|
render (task 1002562): 'adopt' makes it clone + layer onto the repo's
|
|
@@ -2554,6 +2571,7 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
2554
2571
|
try {
|
|
2555
2572
|
sessionStorage.setItem(CREATED_KEY, JSON.stringify({
|
|
2556
2573
|
id: data.instance && data.instance.id, slug: state.slug, domain: resolvedDomain(),
|
|
2574
|
+
noaddr: !!state.noAddress,
|
|
2557
2575
|
mode: state.mode, name: state.name, at: Date.now()
|
|
2558
2576
|
}));
|
|
2559
2577
|
sessionStorage.removeItem(DRAFT_KEY);
|
|
@@ -2973,6 +2991,9 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
2973
2991
|
blank wizard */
|
|
2974
2992
|
state.name = created.name || created.slug; state.slug = created.slug || '';
|
|
2975
2993
|
state.domain = created.domain || ''; state.mode = created.mode || 'greenfield';
|
|
2994
|
+
/* restore the opt-out too — without it resolvedDomain() re-derives the
|
|
2995
|
+
assigned address and the done panel promises one that doesn't exist */
|
|
2996
|
+
state.noAddress = !!created.noaddr;
|
|
2976
2997
|
renderDone();
|
|
2977
2998
|
show(6, !!focus);
|
|
2978
2999
|
startProgress(created.id);
|
|
@@ -3155,7 +3176,21 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
3155
3176
|
return inst.status === 'provisioning' || inst.status === 'active';
|
|
3156
3177
|
}
|
|
3157
3178
|
/* client mirror of provisioning.DOMAIN_ATTACH_FROM */
|
|
3158
|
-
|
|
3179
|
+
/* the platform-assigned <slug>.<apex> for this instance, from the server-stamped
|
|
3180
|
+
apex only — no host guessing here: the upgrade door below must never show for
|
|
3181
|
+
an address the server would refuse to re-point (task 1002704) */
|
|
3182
|
+
function assignedDefaultFor(inst) {
|
|
3183
|
+
var meta = document.querySelector('meta[name="instance-base"]');
|
|
3184
|
+
var base = meta ? String(meta.content || '').toLowerCase() : '';
|
|
3185
|
+
if (!base || base.indexOf('{{') >= 0 || base.indexOf('.') <= 0) return null;
|
|
3186
|
+
return inst.slug + '.' + base;
|
|
3187
|
+
}
|
|
3188
|
+
/* domainless instances get their first address; an assigned default may be
|
|
3189
|
+
upgraded to a bring-your-own domain (the server allows exactly that re-point) */
|
|
3190
|
+
function canAttachDomain(inst) {
|
|
3191
|
+
return (!inst.domain || inst.domain === assignedDefaultFor(inst)) &&
|
|
3192
|
+
(inst.status === 'requested' || inst.status === 'error' || inst.status === 'active');
|
|
3193
|
+
}
|
|
3159
3194
|
/* re-enqueue doors: Try again on snagged, Bring it back on torn down —
|
|
3160
3195
|
both re-POST the slug (the server's PROVISION_ENQUEUE_FROM gate) */
|
|
3161
3196
|
function canRetry(inst) { return inst.status === 'error' && !inst.open_intent; }
|
|
@@ -3292,7 +3327,8 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
3292
3327
|
if (canAttachDomain(inst)) {
|
|
3293
3328
|
body += '<div class="attach">' +
|
|
3294
3329
|
'<input type="text" id="dom-' + iid + '" placeholder="myproject.example.com" autocomplete="off" spellcheck="false" maxlength="253">' +
|
|
3295
|
-
'<button type="button" class="btn small" data-attach="' + iid + '">
|
|
3330
|
+
'<button type="button" class="btn small" data-attach="' + iid + '">' +
|
|
3331
|
+
(inst.domain ? 'Use your own domain' : 'Give it an address') + '</button></div>';
|
|
3296
3332
|
}
|
|
3297
3333
|
if (inst.status === 'error' && !inst.open_intent) {
|
|
3298
3334
|
/* once a retry is queued the strip tells the story — the stale error
|
package/scripts/gds/api.js
CHANGED
|
@@ -18,12 +18,15 @@
|
|
|
18
18
|
// on a 2xx, 1 on any non-2xx or transport error, 2 on bad invocation.
|
|
19
19
|
|
|
20
20
|
const fs = require('node:fs');
|
|
21
|
-
const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanceAndExit, cliExit } = require('./cli-lib');
|
|
21
|
+
const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanceAndExit, cliExit, repairMsysMangledPath } = require('./cli-lib');
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
(async () => {
|
|
25
25
|
const method = (process.argv[2] || '').toUpperCase();
|
|
26
|
-
|
|
26
|
+
// task 1002809: Git Bash converts the leading-slash path arg into a Windows
|
|
27
|
+
// path under the Git install root before node sees it; undo that so builders
|
|
28
|
+
// don't need the MSYS_NO_PATHCONV=1 prefix.
|
|
29
|
+
const urlPath = repairMsysMangledPath(process.argv[3]);
|
|
27
30
|
if (!method || !urlPath || !urlPath.startsWith('/')) {
|
|
28
31
|
console.error("usage: node scripts/gds/api.js <METHOD> <path> [--body '<json>' | --body-file <file|->] [--quiet]");
|
|
29
32
|
process.exit(2);
|
|
@@ -46,6 +49,13 @@ const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanc
|
|
|
46
49
|
if (raw != null && raw.trim()) body = JSON.parse(raw);
|
|
47
50
|
} catch (e) {
|
|
48
51
|
console.error(`api: could not read/parse JSON body: ${e.message}`);
|
|
52
|
+
// task 1002809: inline --body is the usual culprit — shells (especially on
|
|
53
|
+
// Windows) strip or re-split the quotes before node sees them. Teach the
|
|
54
|
+
// durable pattern instead of leaving a bare parse error.
|
|
55
|
+
if (bodyFile == null && bodyInline != null) {
|
|
56
|
+
console.error(' Inline --body often loses its quoting through the shell. Durable pattern:');
|
|
57
|
+
console.error(' write the JSON to a file and pass --body-file <path>, or pipe it via --body-file -');
|
|
58
|
+
}
|
|
49
59
|
process.exit(2);
|
|
50
60
|
}
|
|
51
61
|
|
package/scripts/gds/cli-lib.js
CHANGED
|
@@ -771,6 +771,34 @@ function parseSessionRequest(text) {
|
|
|
771
771
|
return { timeBudgetMin, mode, parallel };
|
|
772
772
|
}
|
|
773
773
|
|
|
774
|
+
// task 1002809: Git Bash (MSYS) rewrites a leading-slash argument into a Windows
|
|
775
|
+
// path under the Git install root BEFORE node sees it — `/tasks/123` arrives as
|
|
776
|
+
// `C:/Program Files/Git/tasks/123` — unless the builder remembers
|
|
777
|
+
// MSYS_NO_PATHCONV=1. Undo it here so no one needs the env prefix. EXEPATH (set
|
|
778
|
+
// by Git Bash) points INSIDE the install (`…\Git\bin`), while the mangled prefix
|
|
779
|
+
// is the install root (`…\Git`), so we strip the LONGEST ancestor of EXEPATH
|
|
780
|
+
// that prefixes the arg — never the bare drive root (that would "repair"
|
|
781
|
+
// arbitrary Windows paths). Case-insensitive, both slash styles. Pure + exported
|
|
782
|
+
// for tests/api_msys_path_repair.mjs; no-op off win32 or without EXEPATH.
|
|
783
|
+
function repairMsysMangledPath(raw, { exepath = process.env.EXEPATH, platform = process.platform } = {}) {
|
|
784
|
+
if (!raw || raw.startsWith('/')) return raw;
|
|
785
|
+
if (platform !== 'win32' || !exepath) return raw;
|
|
786
|
+
const rawFwd = String(raw).replace(/\\/g, '/');
|
|
787
|
+
const rawLower = rawFwd.toLowerCase();
|
|
788
|
+
let ancestor = String(exepath).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
789
|
+
let best = null;
|
|
790
|
+
while (ancestor.includes('/')) {
|
|
791
|
+
// Stop before the bare drive root ("C:" after the last slice).
|
|
792
|
+
if (/^[a-z]:$/i.test(ancestor)) break;
|
|
793
|
+
if (rawLower.startsWith(ancestor.toLowerCase() + '/') && (best === null || ancestor.length > best.length)) {
|
|
794
|
+
best = ancestor;
|
|
795
|
+
}
|
|
796
|
+
ancestor = ancestor.slice(0, ancestor.lastIndexOf('/'));
|
|
797
|
+
}
|
|
798
|
+
if (best === null) return raw;
|
|
799
|
+
return rawFwd.slice(best.length);
|
|
800
|
+
}
|
|
801
|
+
|
|
774
802
|
module.exports = {
|
|
775
803
|
API_BASE,
|
|
776
804
|
hallBase,
|
|
@@ -784,6 +812,7 @@ module.exports = {
|
|
|
784
812
|
arg,
|
|
785
813
|
hasFlag,
|
|
786
814
|
stripBom,
|
|
815
|
+
repairMsysMangledPath,
|
|
787
816
|
resolveQuiet,
|
|
788
817
|
readSessionToken,
|
|
789
818
|
apiCall,
|
package/scripts/gds/init.js
CHANGED
|
@@ -521,7 +521,7 @@ async function interview({ adopt = false } = {}) {
|
|
|
521
521
|
const wantProv = (await ask(rl, ' Provision production hosting now? (y/N)', 'N')).toLowerCase();
|
|
522
522
|
if (wantProv === 'y' || wantProv === 'yes') {
|
|
523
523
|
const shape = (await ask(rl, ' Hosting shape? (co-tenant = share an existing box, $0 / dedicated = its own droplet / standalone = its own repo + pinned @bongos/core, ADR 0108)', 'co-tenant')).toLowerCase();
|
|
524
|
-
const domain = await ask(rl, ' Domain you own — bring-your-own (blank =
|
|
524
|
+
const domain = await ask(rl, ' Domain you own — bring-your-own (blank = the platform assigns <slug>.<its apex> when configured)?');
|
|
525
525
|
const targetRef = await ask(rl, ' Target credential ref? (which control-plane creds the runner should use; optional)');
|
|
526
526
|
let tier = '';
|
|
527
527
|
if (shape === 'dedicated') tier = await ask(rl, ' Droplet tier? (e.g. s-1vcpu-2gb; optional)');
|
|
@@ -780,7 +780,13 @@ async function seedViaApi(spec, { dryRun, log = console.log, api = apiCall } = {
|
|
|
780
780
|
provisioned = true;
|
|
781
781
|
const inst = (pres.data && pres.data.instance) || {};
|
|
782
782
|
const cost = Number(inst.cost_estimate_usd || 0);
|
|
783
|
-
|
|
783
|
+
// Report the ROW's domain, not the request's: the server may have
|
|
784
|
+
// auto-assigned <slug>.<instanceBase> to a domainless request (task
|
|
785
|
+
// 1002704) — the operator must hear the address they actually got.
|
|
786
|
+
const addr = inst.domain
|
|
787
|
+
? `, ${inst.domain}${pr.domain ? '' : ' (auto-assigned — attach your own domain any time to replace it)'}`
|
|
788
|
+
: ', no address yet';
|
|
789
|
+
log(` ✓ requested provisioning of instance "${pr.slug}" (${pr.hosting_shape}${addr})` +
|
|
784
790
|
`${cost ? ` — est. $${cost}/mo` : ''} — a provision intent is queued for the control-plane runner`);
|
|
785
791
|
} else {
|
|
786
792
|
const why = pres.status === 404
|
package/scripts/gds/onboard.js
CHANGED
|
@@ -88,7 +88,7 @@ async function run() {
|
|
|
88
88
|
a.repoName = await ask(rl, 'GitHub repo name?');
|
|
89
89
|
a.firstAdmin = await ask(rl, 'First admin (GitHub login — the founding Archon)?', a.repoOwner);
|
|
90
90
|
a.slug = await ask(rl, 'Instance slug? (DNS/db-safe, e.g. mercury)');
|
|
91
|
-
a.domain = await ask(rl, 'Domain you own (bring-your-own; blank =
|
|
91
|
+
a.domain = await ask(rl, 'Domain you own (bring-your-own; blank = the platform assigns <slug>.<its apex> when configured)?');
|
|
92
92
|
|
|
93
93
|
// Step 2 — human: the GitHub repo (create empty for greenfield; use existing for adopt)
|
|
94
94
|
if (adopt) {
|
package/scripts/gds/ship.js
CHANGED
|
@@ -3122,6 +3122,40 @@ function playShipBell() {
|
|
|
3122
3122
|
playSound(shipWav);
|
|
3123
3123
|
}
|
|
3124
3124
|
|
|
3125
|
+
// task 1002809: normalize an API error body across BOTH wire shapes — the live
|
|
3126
|
+
// ADR 0116 (R01) envelope `{ error: { code, message } }` and the legacy flat
|
|
3127
|
+
// `{ error: 'code' }`. Same normalization as claim.js:formatClaimFailure and
|
|
3128
|
+
// cli-lib:isBoxScope; pure + exported for tests/ship_post_land_verify.mjs.
|
|
3129
|
+
function normalizeApiError(data) {
|
|
3130
|
+
const errRaw = data ? data.error : undefined;
|
|
3131
|
+
const isEnvelope = errRaw !== null && typeof errRaw === 'object';
|
|
3132
|
+
return {
|
|
3133
|
+
code: isEnvelope ? errRaw.code : errRaw,
|
|
3134
|
+
message: isEnvelope ? errRaw.message : undefined,
|
|
3135
|
+
};
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
// task 1002809: classify a FAILED post-land POST /tasks/<id>/ship. The merge
|
|
3139
|
+
// already succeeded, so the server may simply have landed the task first
|
|
3140
|
+
// (reconcile-on-read — task 1049 — or the ~5-min reconciler racing this call).
|
|
3141
|
+
// The old check compared `data.error === 'task_not_confirmed'` — dead against
|
|
3142
|
+
// the envelope shape, so the already-landed SUCCESS rendered as a failure with
|
|
3143
|
+
// an "[object Object]" reason (the HANDOFF-apex-ui "400 after a successful
|
|
3144
|
+
// land" gotcha). liveStatus is the task's status from a verify GET (null when
|
|
3145
|
+
// that GET itself failed): 'shipped' is authoritative success whatever the
|
|
3146
|
+
// error said; task_not_confirmed with no live status keeps the task-1049
|
|
3147
|
+
// benefit of the doubt; any KNOWN non-shipped status stays a loud failure.
|
|
3148
|
+
// Pure + exported for tests/ship_post_land_verify.mjs.
|
|
3149
|
+
function postLandShipOutcome({ code, liveStatus }) {
|
|
3150
|
+
if (liveStatus === 'shipped') {
|
|
3151
|
+
return { alreadyShipped: true, why: 'the server landed it first — the failed ship call lost that race, nothing to fix' };
|
|
3152
|
+
}
|
|
3153
|
+
if (liveStatus == null && code === 'task_not_confirmed') {
|
|
3154
|
+
return { alreadyShipped: true, why: 'the server reconciled it from the merged PR' };
|
|
3155
|
+
}
|
|
3156
|
+
return { alreadyShipped: false };
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3125
3159
|
// Step 3 of the lifecycle: confirmed → shipped. Merge the branch into main,
|
|
3126
3160
|
// deploy, flip the task to shipped, and fire the Discord broadcast (only when
|
|
3127
3161
|
// the change actually deploys — #94 / ADR 0032; best-effort). Side-effecting;
|
|
@@ -3146,17 +3180,26 @@ async function mergeAndDeploy({ taskId, summary, advanceToMerge, skipMerge, dbOn
|
|
|
3146
3180
|
|
|
3147
3181
|
const ship = await (await cliClient()).tasks.postTasksIdShip({ id: taskId, body: {} });
|
|
3148
3182
|
if (!ship.ok) {
|
|
3149
|
-
// task 1049: the server
|
|
3150
|
-
//
|
|
3151
|
-
//
|
|
3152
|
-
|
|
3153
|
-
|
|
3183
|
+
// task 1049 / 1002809: the server may have already flipped this task to
|
|
3184
|
+
// shipped (reconcile-on-read, or the reconciler racing this call). Verify
|
|
3185
|
+
// the LIVE status before treating the refusal as a failure — the old
|
|
3186
|
+
// flat-shape string check never matched the R01 envelope, so this success
|
|
3187
|
+
// path was dead and every reconciled land reported as a failed ship.
|
|
3188
|
+
const { code, message } = normalizeApiError(ship.data);
|
|
3189
|
+
let liveStatus = null;
|
|
3190
|
+
try {
|
|
3191
|
+
const t = await (await cliClient()).tasks.getTasksId({ id: taskId });
|
|
3192
|
+
if (t.ok && t.data && t.data.task) liveStatus = t.data.task.status;
|
|
3193
|
+
} catch (_) { /* verify is best-effort; fall back to the code-based call */ }
|
|
3194
|
+
const outcome = postLandShipOutcome({ code, liveStatus });
|
|
3195
|
+
if (outcome.alreadyShipped) {
|
|
3196
|
+
console.log(`Task #${taskId} is now: shipped (${outcome.why}).`);
|
|
3154
3197
|
} else {
|
|
3155
|
-
|
|
3198
|
+
const e = message || code || `HTTP ${ship.status}`;
|
|
3199
|
+
console.error(` /tasks/${taskId}/ship failed: ${e}${liveStatus ? ` (task is at '${liveStatus}')` : ''}`);
|
|
3156
3200
|
// The merge itself succeeded — only the status flip failed, so a re-run
|
|
3157
3201
|
// resumes cleanly (task 941). Report it rather than letting the card claim
|
|
3158
3202
|
// the land is in progress.
|
|
3159
|
-
const e = (ship.data && (ship.data.message || ship.data.error)) || `HTTP ${ship.status}`;
|
|
3160
3203
|
return landBailed(`merged, but the ship call failed — ${e}`, `node scripts/gds/ship.js ${taskId}`);
|
|
3161
3204
|
}
|
|
3162
3205
|
} else {
|
|
@@ -4418,6 +4461,8 @@ module.exports = {
|
|
|
4418
4461
|
// task 1049 (B): pure CI-land outcome classifier — a timed-out-but-armed PR is
|
|
4419
4462
|
// 'pending' (success), not failure. Exported so the semantics are unit-tested.
|
|
4420
4463
|
classifyLandOutcome,
|
|
4464
|
+
normalizeApiError,
|
|
4465
|
+
postLandShipOutcome,
|
|
4421
4466
|
// task 1718: pure poll-path decision — enable-failure polls the SERVER (not a
|
|
4422
4467
|
// dead-end strand at confirmed), enable-success watches GitHub. Exported so the
|
|
4423
4468
|
// regression test locks in that a worktree enable-failure still waits for the land.
|