@bongos/core 1.21.83 → 1.21.84

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.
@@ -1950,6 +1950,31 @@ summary{min-height:24px;padding:3px 0;}
1950
1950
  </div>
1951
1951
  <span class="acts"><button type="button" class="rowChg" data-row-chg aria-expanded="false">Edit</button></span>
1952
1952
  </div>
1953
+
1954
+ <!-- how finished work ships (task 1004629, ADR 0369): Auto unless the owner
1955
+ says otherwise. It rides the create as ship_mode and becomes the project's
1956
+ own ship mode, which its settings page can change any time. -->
1957
+ <div class="setRow" data-row="ship">
1958
+ <span class="k">Shipping</span>
1959
+ <div class="v"><span class="rowV" data-row-v></span><small class="rowS" data-row-s></small>
1960
+ <div class="opts" hidden>
1961
+ <fieldset class="appOpts" id="shipOpts">
1962
+ <legend>When the AI finishes a task</legend>
1963
+ <label class="appOpt">
1964
+ <input type="radio" name="shipMode" value="auto" checked>
1965
+ <span><b>Auto (recommended)</b>
1966
+ <span>The AI ships finished work on its own. You don’t run any commands.</span></span>
1967
+ </label>
1968
+ <label class="appOpt">
1969
+ <input type="radio" name="shipMode" value="manual">
1970
+ <span><b>Manual</b>
1971
+ <span>The AI stops before each ship and waits for you to approve it.</span></span>
1972
+ </label>
1973
+ </fieldset>
1974
+ </div>
1975
+ </div>
1976
+ <span class="acts"><button type="button" class="rowChg" data-row-chg aria-expanded="false">Edit</button></span>
1977
+ </div>
1953
1978
  </div>
1954
1979
  <div class="hint" id="h-review">&nbsp;</div>
1955
1980
  <div class="nav">
@@ -4476,7 +4501,7 @@ summary{min-height:24px;padding:3px 0;}
4476
4501
  request omits the field), [] = the owner switched them all off, [...] =
4477
4502
  what they picked. Never seed it with the preselect — that would store an
4478
4503
  answer nobody gave. */
4479
- var state = { step: 0, name: '', projType: null, description: '', teamShape: null, modules: null, mode: 'greenfield', owner: '', ownerTouched: false, repo: '', repoTouched: false, slug: '', slugTouched: false, domain: '', noAddress: false, appPlatform: 'later', physics: null, demo: null, look: null, termsAck: false, confirmed: {} };
4504
+ var state = { step: 0, name: '', projType: null, description: '', teamShape: null, modules: null, mode: 'greenfield', owner: '', ownerTouched: false, repo: '', repoTouched: false, slug: '', slugTouched: false, domain: '', noAddress: false, appPlatform: 'later', shipMode: 'auto', physics: null, demo: null, look: null, termsAck: false, confirmed: {} };
4480
4505
  try { Object.assign(state, JSON.parse(sessionStorage.getItem(DRAFT_KEY) || '{}')); } catch (e) {}
4481
4506
  var authKnown = false; /* flips true when the boot's /me settles, either way */
4482
4507
 
@@ -5956,7 +5981,6 @@ summary{min-height:24px;padding:3px 0;}
5956
5981
  if (appOptsEl) appOptsEl.addEventListener('change', function (ev) {
5957
5982
  if (ev.target && ev.target.name === 'appPlatform' && !ev.target.disabled) { state.appPlatform = ev.target.value; saveDraft(); syncRenderSetup(); paintSetup(); }
5958
5983
  });
5959
-
5960
5984
  /* ── Render (task 1004352, ADR 0348) ──
5961
5985
  The flow itself is renderConnect(), shared with an existing project's page (task
5962
5986
  1004184). Here it reads and writes the draft (`state`), so every answer but the key
@@ -6073,7 +6097,14 @@ summary{min-height:24px;padding:3px 0;}
6073
6097
  return 'Workspace ' + ws + ' — a new app (' + runtimeLabel(state.renderRuntime || 'node') + ', ' + plan + ')' + db + '. Render bills you directly.';
6074
6098
  }
6075
6099
 
6076
- var ROWS = ['code', 'address', 'app', 'extras', 'team', 'rewards', 'run'];
6100
+ /* the ship row (task 1004629): anything but 'manual' — an older draft has none — is Auto */
6101
+ function shipModeChoice() { return state.shipMode === 'manual' ? 'manual' : 'auto'; }
6102
+ function syncShipStep() {
6103
+ var pick = shipModeChoice();
6104
+ root.querySelectorAll('#shipOpts input[name=shipMode]').forEach(function (i) { i.checked = i.value === pick; });
6105
+ }
6106
+
6107
+ var ROWS = ['code', 'address', 'app', 'extras', 'team', 'rewards', 'run', 'ship'];
6077
6108
  function rowEl(key) { return root.querySelector('#reviewRows .setRow[data-row="' + key + '"]'); }
6078
6109
  function confirmedRows() {
6079
6110
  if (!state.confirmed || typeof state.confirmed !== 'object') state.confirmed = {};
@@ -6118,6 +6149,7 @@ summary{min-height:24px;padding:3px 0;}
6118
6149
  if (key === 'extras') renderModules();
6119
6150
  if (key === 'code') syncHome();
6120
6151
  if (key === 'app') { syncAppStep(); syncRenderSetup(); }
6152
+ if (key === 'ship') syncShipStep();
6121
6153
  }
6122
6154
  function closeRow(key) {
6123
6155
  var row = rowEl(key);
@@ -6182,6 +6214,8 @@ summary{min-height:24px;padding:3px 0;}
6182
6214
  controls' own handlers run first (they are on the targets), then the row repaints */
6183
6215
  ['input', 'change'].forEach(function (type) {
6184
6216
  $('reviewRows').addEventListener(type, function (ev) {
6217
+ /* the ship row's radios have no handler of their own (task 1004629) */
6218
+ if (type === 'change' && ev.target.name === 'shipMode') { state.shipMode = ev.target.value; saveDraft(); }
6185
6219
  if (ev.target.closest && ev.target.closest('.setRow.open .opts')) {
6186
6220
  var row = ev.target.closest('.setRow'), t = rowText(row.getAttribute('data-row'));
6187
6221
  row.querySelector('[data-row-v]').textContent = t.v;
@@ -6247,6 +6281,11 @@ summary{min-height:24px;padding:3px 0;}
6247
6281
  if (a.framing === 'open-source') return { v: 'Free core only', s: 'The free core, on your own machines.' };
6248
6282
  return { v: 'Everything, for you', s: 'Hosting, payouts and the starter kit, managed.' };
6249
6283
  }
6284
+ if (key === 'ship') {
6285
+ return shipModeChoice() === 'manual'
6286
+ ? { v: 'Manual', s: 'The AI waits for you to approve each ship. You can change this later in your project’s settings.' }
6287
+ : { v: 'Auto (recommended)', s: 'The AI ships finished work on its own. You can change this later in your project’s settings.' };
6288
+ }
6250
6289
  return { v: '', s: '' };
6251
6290
  }
6252
6291
  /* paint every row from the draft, and the locked Continue under them */
@@ -6407,7 +6446,9 @@ summary{min-height:24px;padding:3px 0;}
6407
6446
  existing history instead of pushing a fresh tree, which a repo that already
6408
6447
  has commits rejects as unrelated history. state.mode already tracked
6409
6448
  greenfield|adopt for the UI — it has to reach the server too. */
6410
- onboard_mode: state.mode === 'adopt' ? 'adopt' : 'greenfield'
6449
+ onboard_mode: state.mode === 'adopt' ? 'adopt' : 'greenfield',
6450
+ /* the ship row (task 1004629): becomes the new project's own ship mode */
6451
+ ship_mode: shipModeChoice()
6411
6452
  })
6412
6453
  });
6413
6454
  }).then(function (res) {
@@ -7175,7 +7216,7 @@ summary{min-height:24px;padding:3px 0;}
7175
7216
  is three-valued and null is its "nothing said" sentinel; leaving the key out
7176
7217
  left it UNDEFINED, which is not null — so pickedModules() reached .slice()
7177
7218
  on it and the second project's Modules step threw. */
7178
- state = { step: 1, name: '', projType: null, description: '', teamShape: null, modules: null, mode: 'greenfield', owner: '', ownerTouched: false, repo: '', repoTouched: false, slug: '', slugTouched: false, domain: '', noAddress: false, appPlatform: 'later', physics: null, demo: null, look: null, termsAck: false, confirmed: {} };
7219
+ state = { step: 1, name: '', projType: null, description: '', teamShape: null, modules: null, mode: 'greenfield', owner: '', ownerTouched: false, repo: '', repoTouched: false, slug: '', slugTouched: false, domain: '', noAddress: false, appPlatform: 'later', shipMode: 'auto', physics: null, demo: null, look: null, termsAck: false, confirmed: {} };
7179
7220
  $('newName').value = ''; $('f-owner').value = ''; $('f-repo').value = ''; $('f-slug').value = ''; $('f-domain').value = '';
7180
7221
  $('f-noaddr').checked = false; $('f-desc').value = '';
7181
7222
  $('termsAck').checked = false; paintDoors(); demoShow('intro'); checksShow('main');
@@ -55,6 +55,7 @@
55
55
  "in-new-review-extras": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"extras\"] [data-row-chg]"],["wait",900]],"expect":{"visible":["#modQ","#modCards .tpill","#modCoreNote"]}},
56
56
  "in-new-review-team": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"team\"] [data-row-chg]"],["wait",300],["click","[data-phys-solo=\"many\"]"],["wait",600]],"expect":{"visible":["#physTeam","#physGuessLine","#teamCards","#reviewRows .setRow[data-row=\"rewards\"]"]}},
57
57
  "in-new-review-run": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"run\"] [data-row-chg]"],["wait",300],["click","[data-phys=\"framing\"][data-val=\"hybrid\"]"],["wait",600]],"expect":{"visible":["#physSuite"]}},
58
+ "in-new-review-ship": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"ship\"] [data-row-chg]"],["wait",300],["click","#shipOpts input[value=\"manual\"]"],["wait",300]],"expect":{"visible":["#shipOpts"]}},
58
59
  "in-new-review-confirmed": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200]],"expect":{"visible":["#reviewGo","#reviewRows .setRow.done"],"text":[["#reviewGo","Looks right, continue"]]}},
59
60
  "in-new-look": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["click","#reviewGo"],["wait",500]],"expect":{"visible":["#panel-look","#wizLook","#wizRecap","#doCreate"],"hidden":["#panel-review"],"attr":[["#wizRoom","data-screen","look"]]}},
60
61
  "in-new-done": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["click","#reviewGo"],["wait",500],["click","#doCreate"],["wait",1500]],"expect":{"visible":["#panel-done","#hop","#hopCanvas","#hopStatus","#hopJump"],"hidden":["#panel-look","#qWrap","#wizBack","#wizInvite","#liveProgress","#humanSteps","#wizInviteLater","#stepPager"]}},
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.83",
3
+ "version": "1.21.84",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.83",
9
+ "version": "1.21.84",
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.21.83",
3
+ "version": "1.21.84",
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",
@@ -9324,5 +9324,11 @@
9324
9324
  "id": "1004646",
9325
9325
  "text": "Project invites now reach people on cloudbongos.com. Before, an invite sent by GitHub username was silently lost if the person had never signed in to a project through cloudbongos.com, which is almost every new invitee, so the"
9326
9326
  }
9327
+ ],
9328
+ "1.21.84": [
9329
+ {
9330
+ "id": "1004629",
9331
+ "text": "New projects now ask how finished work should ship: Auto (recommended) or Manual. The answer becomes the project's ship mode, which the owner can change later on the settings page."
9332
+ }
9327
9333
  ]
9328
9334
  }
@@ -226,14 +226,26 @@ function parseShipMode(raw) {
226
226
  // resolveShipMode() — the project's ship mode and where it came from.
227
227
  // Returns { mode, source, chosen, invalid }:
228
228
  // mode — always 'auto' or 'manual'
229
- // source — 'project_settings' | 'default'
229
+ // source — 'project_settings' | 'wizard' | 'default'
230
230
  // chosen — true only when someone actually SET a usable value. "Never chosen" is
231
231
  // a real answer the first-ship prompt (task 1004630) asks about, so it
232
232
  // must not be confused with "chose the default".
233
233
  // invalid — null, or { where, raw, reason } for a stored value that is unusable.
234
234
  // A database that cannot be read answers the default rather than failing a ship —
235
235
  // the same reporting posture as resolveRotDays.
236
- async function resolveShipMode() {
236
+ //
237
+ // 'wizard' (task 1004629): the founder picked a mode in the project-creation wizard,
238
+ // which reaches this instance as the server-only branding value founding.shipMode
239
+ // (the hub cannot write this database). It counts as chosen, but only while no row
240
+ // exists: the settings page and the CLI write the row, so a later change always wins.
241
+ // `deps.branding` is the test seam; production reads the process's own pack.
242
+ function wizardShipMode(deps) {
243
+ let pack;
244
+ try { pack = deps.branding ? deps.branding() : require('../branding').branding(); } catch (_) { return null; }
245
+ return parseShipMode(pack && pack.founding && pack.founding.shipMode).mode || null;
246
+ }
247
+
248
+ async function resolveShipMode(deps = {}) {
237
249
  let row = null;
238
250
  try {
239
251
  row = await getSetting(SETTING_KEYS.SHIP_MODE);
@@ -251,6 +263,8 @@ async function resolveShipMode() {
251
263
  invalid: { where: `project_settings['${SETTING_KEYS.SHIP_MODE}']`, raw: String(row.value), reason: got.reason },
252
264
  };
253
265
  }
266
+ const fromWizard = wizardShipMode(deps);
267
+ if (fromWizard) return { mode: fromWizard, source: 'wizard', chosen: true, invalid: null };
254
268
  return { mode: DEFAULT_SHIP_MODE, source: 'default', chosen: false, invalid: null };
255
269
  }
256
270
 
package/src/branding.js CHANGED
@@ -103,6 +103,9 @@ const ENV_OVERRIDES = [
103
103
  ['FOUNDING_BIRTH', ['founding', 'birth']],
104
104
  ['DEMO_PEOPLE', ['founding', 'demoPeople']],
105
105
  ['DEMO_HOURS', ['founding', 'demoHours']],
106
+ // The ship mode the founder picked in the wizard (task 1004629), empty when none was.
107
+ // resolveShipMode reads it only while project_settings['ship.mode'] is unset.
108
+ ['SHIP_MODE', ['founding', 'shipMode']],
106
109
  ];
107
110
 
108
111
  const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.83'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.84'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -729,7 +729,7 @@ test('the About answers travel on the create request, and an empty one is OMITTE
729
729
  // wizard PREFILLED, and the sentence is never prefilled, so it has no row there — its
730
730
  // cost is stated on its own field (pinned above)
731
731
  assert.doesNotMatch(SCRIPT, /function renderReview\(|\$\('reviewList'\)/, 'no ledger review is left');
732
- assert.match(SCRIPT, /var ROWS = \['code', 'address', 'app', 'extras', 'team', 'rewards', 'run'\];/,
732
+ assert.match(SCRIPT, /var ROWS = \['code', 'address', 'app', 'extras', 'team', 'rewards', 'run', 'ship'\];/,
733
733
  'the review rows are the seven prefilled answers');
734
734
  });
735
735
 
@@ -168,7 +168,7 @@ test('the row is real markup: Render and Decide later, Decide later the default'
168
168
 
169
169
  test("the row sits in the review, between Address and Extras, and its checks are the old step's (task 1004504)", () => {
170
170
  assert.match(HUB, /var panels = \{ 0: 'panel-gate', 1: 'panel-fork', 2: 'panel-what', 3: 'panel-checks', 4: 'panel-review', 5: 'panel-look', 6: 'panel-done' \};/);
171
- assert.match(HUB, /var ROWS = \['code', 'address', 'app', 'extras', 'team', 'rewards', 'run'\];/);
171
+ assert.match(HUB, /var ROWS = \['code', 'address', 'app', 'extras', 'team', 'rewards', 'run', 'ship'\];/);
172
172
  assert.doesNotMatch(HUB, /<li[^>]*data-step="\d+"/, 'no rail beat names the step any more');
173
173
  assert.match(HUB, /if \(step === 4\) \{[^}]*syncAppStep\(\); syncRenderSetup\(\);\r?\n\s*paintSetup\(\);\r?\n\s*\}/, 'entering the review repaints the row, Render setup included');
174
174
  assert.match(HUB, /if \(key === 'app'\) \{ syncAppStep\(\); syncRenderSetup\(\); \}/, 'opening the row repaints it too');
@@ -78,6 +78,7 @@ ${NEUTRAL_PREFIX}_LOGO_URL=
78
78
  ${NEUTRAL_PREFIX}_FOUNDING_BIRTH=
79
79
  ${NEUTRAL_PREFIX}_DEMO_PEOPLE=
80
80
  ${NEUTRAL_PREFIX}_DEMO_HOURS=
81
+ ${NEUTRAL_PREFIX}_SHIP_MODE=
81
82
  ${NEUTRAL_PREFIX}_HUB_UPGRADE_URL=
82
83
  `;
83
84
 
@@ -148,6 +149,8 @@ t('settingsEnvVarsFor: the runner pushes the SAME pair webEnvBody writes at stan
148
149
  [`${NEUTRAL_PREFIX}_LOOK_ACCENT`]: '', [`${NEUTRAL_PREFIX}_LOGO_URL`]: '',
149
150
  // the birth marker and a demo's box (task 1004506), empty on an un-stamped row
150
151
  [`${NEUTRAL_PREFIX}_FOUNDING_BIRTH`]: '', [`${NEUTRAL_PREFIX}_DEMO_PEOPLE`]: '', [`${NEUTRAL_PREFIX}_DEMO_HOURS`]: '',
152
+ // the wizard's ship mode (task 1004629), empty when none was picked
153
+ [`${NEUTRAL_PREFIX}_SHIP_MODE`]: '',
151
154
  // where the hall's Update button goes (task 1004608), empty when no hub origin resolves
152
155
  [`${NEUTRAL_PREFIX}_HUB_UPGRADE_URL`]: '',
153
156
  });
@@ -250,7 +253,7 @@ await ta('dry-run PLANS the patch: reads nothing real, writes NOTHING (no synthe
250
253
  assert.equal(r.ok, true);
251
254
  assert.equal(r.healthy, null, 'nothing ran, nothing observed');
252
255
  assert.equal(writes.length, 0, 'a dry-run must not plan a two-line overwrite of web.env');
253
- assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY, .*_JOIN_GRANT, .*_ARTIST_GATE, .*_SPEND_PAYER, .*_LOOK, .*_NIGHT_TINT, .*_ARTIST_GATE_SINCE, .*_LOOK_ACCENT, .*_LOGO_URL, .*_FOUNDING_BIRTH, .*_DEMO_PEOPLE, .*_DEMO_HOURS, .*_HUB_UPGRADE_URL in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
256
+ assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY, .*_JOIN_GRANT, .*_ARTIST_GATE, .*_SPEND_PAYER, .*_LOOK, .*_NIGHT_TINT, .*_ARTIST_GATE_SINCE, .*_LOOK_ACCENT, .*_LOGO_URL, .*_FOUNDING_BIRTH, .*_DEMO_PEOPLE, .*_DEMO_HOURS, .*_SHIP_MODE, .*_HUB_UPGRADE_URL in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
254
257
  assert.ok(cmds.some((c) => /restart spike/.test(c)), 'the restart is still in the plan');
255
258
  assert.ok(logs.some((l) => /dry-run — nothing ran/.test(l)), 'the closing line does not claim a restart happened');
256
259
  });
@@ -127,6 +127,8 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
127
127
  // The birth marker and a demo's box (task 1004506): empty on a row the create
128
128
  // route never stamped, which is every row made before it shipped (spec D8).
129
129
  CLOUDBONGOS_FOUNDING_BIRTH: '', CLOUDBONGOS_DEMO_PEOPLE: '', CLOUDBONGOS_DEMO_HOURS: '',
130
+ // The wizard's ship mode (task 1004629): empty when none was picked.
131
+ CLOUDBONGOS_SHIP_MODE: '',
130
132
  });
131
133
  // varsOf splits on '=', so an empty value reads as '' on both sides — the point
132
134
  // of the comparison is that the standup lines carry exactly the same key set.
@@ -135,7 +137,7 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
135
137
  X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply', X_VISIBILITY: 'public', X_JOIN_GRANT: 'full',
136
138
  X_ARTIST_GATE: 'strict', X_ARTIST_GATE_SINCE: '', X_SPEND_PAYER: 'none',
137
139
  X_LOOK: 'own', X_NIGHT_TINT: 'on', X_LOOK_ACCENT: '', X_LOGO_URL: '',
138
- X_FOUNDING_BIRTH: '', X_DEMO_PEOPLE: '', X_DEMO_HOURS: '',
140
+ X_FOUNDING_BIRTH: '', X_DEMO_PEOPLE: '', X_DEMO_HOURS: '', X_SHIP_MODE: '',
139
141
  }, 'defaults, explicitly');
140
142
  });
141
143
 
@@ -377,13 +377,13 @@ test('rewards are hidden for a solo founder: the real table hides the row, and t
377
377
  assert.equal(w.run("rowApplies('rewards')"), false, 'the REAL softLimitsFor hides reward_intents for one-and-one');
378
378
  assert.equal(w.row('rewards').hidden, true, 'and the row goes with it');
379
379
  assert.ok(!w.run('unconfirmedRows()').includes('rewards'), 'nothing to confirm for a question never asked');
380
- assert.equal(w.run('unconfirmedRows()').length, 6, 'six rows apply');
380
+ assert.equal(w.run('unconfirmedRows()').length, 7, 'seven rows apply');
381
381
  assert.equal(w.run('createDetail()').reward_intents, undefined);
382
382
  w.change('team');
383
383
  w.q('[data-phys-solo="many"]')[0].click();
384
384
  await w.settle();
385
385
  assert.equal(w.row('rewards').hidden, false, 'a team brings the question back, once the table is asked again');
386
- assert.equal(w.run('unconfirmedRows()').length, 7);
386
+ assert.equal(w.run('unconfirmedRows()').length, 8);
387
387
  });
388
388
 
389
389
  test('rewards: a greyed option is disabled and leaves the list, and each reason is said once', async () => {
@@ -482,9 +482,9 @@ test('one "Looks right, continue" confirms every row that applies, each through
482
482
  assert.equal(w.go().disabled, false, 'Continue is never locked: it is the confirmation');
483
483
  assert.equal(w.go().textContent, 'Looks right, continue');
484
484
  assert.equal(w.row('code').querySelector('[data-row-ok]'), null, 'no row carries a Looks right of its own');
485
- assert.equal(w.run('unconfirmedRows()').length, 7, 'nothing is confirmed before the founder says so');
485
+ assert.equal(w.run('unconfirmedRows()').length, 8, 'nothing is confirmed before the founder says so');
486
486
  assert.equal(w.run('validateStep(4)'), true, 'one Continue confirms them all');
487
- for (const key of ['code', 'address', 'app', 'extras', 'team', 'rewards', 'run']) assert.equal(w.state.confirmed[key], true, key);
487
+ for (const key of ['code', 'address', 'app', 'extras', 'team', 'rewards', 'run', 'ship']) assert.equal(w.state.confirmed[key], true, key);
488
488
  assert.equal(w.state.modules && plain(w.state.modules).join(), 'discord', 'confirming the extras is the answer the create sends');
489
489
  w.change('team');
490
490
  assert.equal(w.state.confirmed.team, undefined, 'Edit takes a row back');
@@ -629,7 +629,7 @@ test('no path reaches Look without the founder confirming the address: a prefill
629
629
  assert.equal(w.state.confirmed.address, undefined, `${label}: the prefills leave the address unconfirmed`);
630
630
  assert.equal(w.run("rowFixed('address')"), false, `${label}: the address is never a fixed row`);
631
631
  assert.equal(w.row('address').querySelector('[data-row-chg]').hidden, false, `${label}: it can always be changed`);
632
- for (const key of ['code', 'app', 'extras', 'team', 'rewards', 'run']) if (w.run(`rowApplies('${key}')`)) w.ok(key);
632
+ for (const key of ['code', 'app', 'extras', 'team', 'rewards', 'run', 'ship']) if (w.run(`rowApplies('${key}')`)) w.ok(key);
633
633
  assert.deepEqual(plain(w.run('unconfirmedRows()')), ['address'], `${label}: every other row confirmed, the address still asked`);
634
634
  // the address is on the page the founder confirms (task 1004550): only their own
635
635
  // "Looks right, continue" — or Done on the row — confirms it, and its check still runs
@@ -0,0 +1,187 @@
1
+ // tests/wizard_ship_mode.mjs — the project-creation wizard asks how finished work ships,
2
+ // and the new project is born with that answer (task 1004629, ADR 0369).
3
+ //
4
+ // Pinned here, end to end:
5
+ // 1. the wizard's review has a Shipping row: Auto first and checked, one plain sentence
6
+ // each, "you can change it later", and the create request carries ship_mode;
7
+ // 2. the create route accepts only auto|manual and stamps it, write-once, on the row it
8
+ // just made (modules/provisioning/birth.js);
9
+ // 3. the stamp rides the new instance's web.env as <PREFIX>_SHIP_MODE, which
10
+ // src/branding.js maps onto the server-only founding.shipMode;
11
+ // 4. the instance's resolveShipMode reads it as a CHOSEN mode while no project_settings
12
+ // row exists, and a row (the settings page, the CLI) always wins.
13
+ //
14
+ // Run: node --test tests/wizard_ship_mode.mjs
15
+ import { strict as assert } from 'node:assert';
16
+ import { test, after } from 'node:test';
17
+ import { createRequire } from 'node:module';
18
+ import { execFileSync } from 'node:child_process';
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import vm from 'node:vm';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
27
+
28
+ // Destructured from literal relative paths, so the dead-code scan (knip) can trace them.
29
+ const { SHIP_MODE_KEY, stampShipMode, BIRTH_COMPANION_ENV } = require('../modules/provisioning/birth.js');
30
+ const { resolveShipMode } = require('../src/bongos/project-settings.js');
31
+ const provisioning = require('../modules/provisioning/provisioning.js');
32
+ const poolMod = require('../src/bongos/pool.js');
33
+
34
+ function slice(start, end, what) {
35
+ const a = HUB.indexOf(start), b = HUB.indexOf(end, a);
36
+ assert.ok(a > 0 && b > a, `slice for ${what} not found — a rename moved it`);
37
+ return HUB.slice(a, b);
38
+ }
39
+
40
+ // ── 1. the wizard ──────────────────────────────────────────────────────────
41
+
42
+ test('the review asks how finished work ships: Auto (recommended) first and checked, Manual second', () => {
43
+ const row = slice('<div class="setRow" data-row="ship">', '<div class="hint" id="h-review">', 'the ship row');
44
+ const auto = row.indexOf('value="auto"'), manual = row.indexOf('value="manual"');
45
+ assert.ok(auto > 0 && manual > auto, 'Auto comes first');
46
+ assert.match(row, /<input type="radio" name="shipMode" value="auto" checked>/, 'and is the pick until the founder says otherwise');
47
+ assert.match(row, /<b>Auto \(recommended\)<\/b>/);
48
+ assert.match(row, /<b>Manual<\/b>/);
49
+ assert.match(row, /ships finished work on its own/, 'one plain sentence for Auto');
50
+ assert.match(row, /waits for you to approve it/, 'and one for Manual');
51
+ });
52
+
53
+ test('the row says what was picked and that it can be changed later; an older draft reads Auto', () => {
54
+ const fns = slice(' /* the ship row (task 1004629)', ' var ROWS = [', 'the ship choice');
55
+ const ctx = { state: {} };
56
+ vm.runInNewContext(`${fns}; this.choice = shipModeChoice;`, ctx);
57
+ assert.equal(ctx.choice(), 'auto', 'a draft with no shipMode is Auto');
58
+ ctx.state.shipMode = 'manual';
59
+ assert.equal(ctx.choice(), 'manual');
60
+ ctx.state.shipMode = 'yolo';
61
+ assert.equal(ctx.choice(), 'auto', 'anything but manual is Auto');
62
+ const say = slice(" if (key === 'ship') {", " return { v: '', s: '' };", "the ship row's words");
63
+ assert.match(say, /v: 'Auto \(recommended\)'/);
64
+ assert.match(say, /v: 'Manual'/);
65
+ assert.equal((say.match(/You can change this later in your project’s settings\./g) || []).length, 2, 'both say it can be changed later');
66
+ });
67
+
68
+ test('the draft remembers the pick, and Create sends it as ship_mode', () => {
69
+ assert.match(HUB, /appPlatform: 'later', shipMode: 'auto', physics: null/, 'the initial state starts on Auto');
70
+ assert.equal((HUB.match(/shipMode: 'auto'/g) || []).length, 2, 'and so does the reset (the two literals stay equal)');
71
+ assert.match(HUB, /if \(type === 'change' && ev\.target\.name === 'shipMode'\) \{ state\.shipMode = ev\.target\.value; saveDraft\(\); \}/);
72
+ assert.match(HUB, /ship_mode: shipModeChoice\(\)\n {10}\}\)/, 'the create request carries it');
73
+ });
74
+
75
+ // ── 2. the stamp ───────────────────────────────────────────────────────────
76
+
77
+ test('stampShipMode writes a valid answer once, and writes nothing for none or junk', async () => {
78
+ const seen = [];
79
+ const db = { query: async (sql, params) => { seen.push({ sql, params }); return { rows: [] }; } };
80
+ assert.equal(await stampShipMode(db, 41, 'manual'), true);
81
+ assert.equal(seen.length, 1);
82
+ assert.match(seen[0].sql, /UPDATE provisioning_instances/);
83
+ assert.match(seen[0].sql, /WHERE id = \$1 AND NOT \(COALESCE\(settings, '\{\}'::jsonb\) \? \$2::text\)/, 'write-once');
84
+ assert.deepEqual(seen[0].params, [41, SHIP_MODE_KEY, 'manual']);
85
+ assert.equal(await stampShipMode(db, 41, undefined), false, 'no answer: the project asks at its first ship instead');
86
+ assert.equal(await stampShipMode(db, 41, 'yolo'), false);
87
+ assert.equal(seen.length, 1, 'nothing written for either');
88
+ });
89
+
90
+ test('the stamp is not a setting: no client patch can write it and it never projects as one', () => {
91
+ assert.ok(!(SHIP_MODE_KEY in provisioning.SETTINGS_VOCAB));
92
+ assert.ok(!(SHIP_MODE_KEY in provisioning.PLANET_VOCAB));
93
+ assert.ok(!(SHIP_MODE_KEY in provisioning.effectiveSettings({ settings: { [SHIP_MODE_KEY]: 'manual' } })));
94
+ });
95
+
96
+ // ── 3. the transport ───────────────────────────────────────────────────────
97
+
98
+ test('the stamped mode rides web.env as SHIP_MODE; an unstamped or mangled row sends empty', () => {
99
+ assert.equal(typeof BIRTH_COMPANION_ENV.SHIP_MODE, 'function');
100
+ assert.equal(provisioning.settingsEnvVars({ settings: { ship_mode: 'manual' } }, 'X').X_SHIP_MODE, 'manual');
101
+ assert.equal(provisioning.settingsEnvVars({ settings: { ship_mode: 'auto' } }, 'X').X_SHIP_MODE, 'auto');
102
+ assert.equal(provisioning.settingsEnvVars({ settings: {} }, 'X').X_SHIP_MODE, '');
103
+ assert.equal(provisioning.settingsEnvVars({ settings: { ship_mode: 'sometimes' } }, 'X').X_SHIP_MODE, '');
104
+ });
105
+
106
+ test('the instance reads it into founding.shipMode, and never publishes it to the client', () => {
107
+ const read = (env) => JSON.parse(execFileSync(process.execPath, ['-e',
108
+ "const b=require('./src/branding.js');const r=(b.branding||b)();console.log(JSON.stringify({m:(r.founding||{}).shipMode||null,c:'founding' in b.clientBranding(r)}))"],
109
+ { cwd: ROOT, encoding: 'utf8', env: { ...process.env, ...env } }));
110
+ assert.deepEqual(read({ CLOUDBONGOS_SHIP_MODE: 'manual' }), { m: 'manual', c: false });
111
+ assert.deepEqual(read({ CLOUDBONGOS_SHIP_MODE: '' }), { m: null, c: false }, 'the empty line every other instance carries is nothing');
112
+ });
113
+
114
+ // ── 4. the instance's own resolver ─────────────────────────────────────────
115
+
116
+ async function withRow(value, fn) {
117
+ const pool = poolMod.pool;
118
+ const saved = pool.query;
119
+ pool.query = async () => ({ rows: value == null ? [] : [{ key: 'ship.mode', value, updated_at: null, updated_by: 1 }] });
120
+ try { return await fn(); } finally { pool.query = saved; }
121
+ }
122
+ const packWith = (shipMode) => () => ({ founding: { shipMode } });
123
+
124
+ test('a wizard pick counts as chosen while no setting row exists', async () => {
125
+ const r = await withRow(null, () => resolveShipMode({ branding: packWith('manual') }));
126
+ assert.deepEqual(r, { mode: 'manual', source: 'wizard', chosen: true, invalid: null });
127
+ const auto = await withRow(null, () => resolveShipMode({ branding: packWith('auto') }));
128
+ assert.equal(auto.chosen, true, 'picking Auto in the wizard is a choice, so the first ship does not ask again');
129
+ });
130
+
131
+ test('the settings page (a row) always wins over the wizard; junk or no pick is the default, unchosen', async () => {
132
+ const r = await withRow('auto', () => resolveShipMode({ branding: packWith('manual') }));
133
+ assert.equal(r.mode, 'auto');
134
+ assert.equal(r.source, 'project_settings');
135
+ assert.deepEqual(await withRow(null, () => resolveShipMode({ branding: packWith('') })), { mode: 'auto', source: 'default', chosen: false, invalid: null });
136
+ assert.deepEqual(await withRow(null, () => resolveShipMode({ branding: packWith('maybe') })), { mode: 'auto', source: 'default', chosen: false, invalid: null });
137
+ assert.equal((await withRow(null, () => resolveShipMode({ branding: () => { throw new Error('no pack'); } }))).source, 'default', 'a pack that cannot be read is no pick');
138
+ });
139
+
140
+ // ── 2b. the create route ───────────────────────────────────────────────────
141
+
142
+ const api = require('../src/module-api.js');
143
+ const birth = require('../modules/provisioning/birth.js');
144
+ provisioning.getInstanceBySlug = async () => null;
145
+ provisioning.getInstanceByDomain = async () => null;
146
+ birth.stampBirth = async () => {};
147
+ provisioning.createInstance = async (_db, args) => ({
148
+ instance: { id: 41, owner_builder_id: 7, slug: args.slug, status: 'requested', hosting_shape: 'byo-host', detail: {} }, created: true,
149
+ });
150
+ provisioning.enqueueIntent = async (_db, _id, action) => ({ intent: { id: 9, action, state: 'pending' }, created: true });
151
+ provisioning.getOpenIntent = async () => null;
152
+ provisioning.recordEvent = async () => {};
153
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
154
+ const stamped = [];
155
+ api.pool.query = async (sql, params) => { if (/jsonb_build_object/.test(sql) && params[1] === SHIP_MODE_KEY) stamped.push(params[2]); return { rows: [] }; };
156
+
157
+ const express = require('express');
158
+ const app = express();
159
+ app.use(express.json());
160
+ app.use((_req, res, next) => {
161
+ res.fail = (code, opts) => {
162
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
163
+ res.status(status).json({ error: { code, message: opts && opts.message } });
164
+ };
165
+ next();
166
+ });
167
+ app.use('/api/bongos', require('../modules/provisioning/routes/provisioning.js')());
168
+ const server = app.listen(0);
169
+ after(() => server.close());
170
+ async function create(body) {
171
+ await new Promise((r) => (server.listening ? r() : server.once('listening', r)));
172
+ const res = await fetch(`http://127.0.0.1:${server.address().port}/api/bongos/provisioning/instances`, {
173
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
174
+ });
175
+ return { status: res.status, body: await res.json() };
176
+ }
177
+
178
+ test('the create route stamps the pick on the new row, and refuses anything but auto|manual', async () => {
179
+ stamped.length = 0;
180
+ const r = await create({ slug: 'ships-by-hand', hosting_shape: 'byo-host', ship_mode: 'manual' });
181
+ assert.equal(r.status, 201, JSON.stringify(r.body));
182
+ assert.deepEqual(stamped, ['manual']);
183
+ stamped.length = 0;
184
+ assert.equal((await create({ slug: 'no-pick', hosting_shape: 'byo-host' })).status, 201, 'the answer is optional (an API caller)');
185
+ assert.deepEqual(stamped, [], 'and none is stamped when none is given');
186
+ assert.equal((await create({ slug: 'bad-pick', hosting_shape: 'byo-host', ship_mode: 'sometimes' })).status, 400);
187
+ });
@@ -81,10 +81,10 @@ test('planet words survive only as the light touch: the fork and the alive momen
81
81
  assert.match(SCRIPT, /if \(inst\.last_verified_up\) \{\n\s*\/\*[^*]*\*\/\n\s*\$\('done-h'\)\.textContent =/, 'said only once the project has answered (Live is a verified claim)');
82
82
  });
83
83
 
84
- test('the review cannot be skipped: seven rows, one Edit each, and one Continue that confirms them all (task 1004550)', () => {
84
+ test('the review cannot be skipped: eight rows, one Edit each, and one Continue that confirms them all (task 1004550)', () => {
85
85
  const p = panel('panel-review');
86
86
  const rows = [...p.matchAll(/<div class="setRow" data-row="([a-z]+)">/g)].map((m) => m[1]);
87
- assert.deepEqual(rows, ['code', 'address', 'app', 'extras', 'team', 'rewards', 'run']);
87
+ assert.deepEqual(rows, ['code', 'address', 'app', 'extras', 'team', 'rewards', 'run', 'ship']);
88
88
  for (const key of rows) {
89
89
  const r = (p.match(new RegExp(`<div class="setRow" data-row="${key}">[\\s\\S]*?<span class="acts">[^\\n]*\\n`)) || [''])[0];
90
90
  assert.match(r, /<span class="acts"><button type="button" class="rowChg" data-row-chg aria-expanded="false">Edit<\/button><\/span>/, `${key}: one Edit, nothing else`);
@@ -110,6 +110,7 @@ test('nothing the old steps could do is lost: their controls are moved whole int
110
110
  has('extras', ['modQ', 'modCards', 'modWhy', 'modCoreNote']);
111
111
  has('team', ['physTeam', 'physStartN', 'physExpected', 'teamCards']);
112
112
  has('run', ['physSuite']);
113
+ has('ship', ['shipOpts']);
113
114
  for (const id of ['physCountry', 'physRegion', 'physAges', 'physIntent', 'physForms', 'physHaveEntity', 'checksStop', 'physStopMsg', 'physFromAccount']) {
114
115
  assert.match(panel('panel-checks'), new RegExp(`id="${id}"`), `the quick checks host #${id}`);
115
116
  }