@bongos/core 1.19.670 → 1.19.672
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 +37 -22
- package/clients/bongos-client/index.d.ts +2 -2
- package/docs/api/openapi.json +9 -3
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +146 -140
- package/docs/copy-registry.json +254 -198
- package/docs/module-api-changelog.md +4 -0
- package/modules/provisioning/provisioning.js +13 -1
- package/modules/provisioning/routes/body-validators.js +94 -0
- package/modules/provisioning/routes/provisioning.js +31 -55
- package/modules/public-landing/public/assets/cosmos.css +6 -0
- package/modules/public-landing/public/projects.html +228 -38
- package/modules/public-landing/public/projects.states.json +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/project_type_editable.mjs +427 -0
- package/tests/projects_hub_pre_uat.mjs +10 -3
- package/tests/wizard_intent_resume.mjs +216 -0
|
@@ -1799,5 +1799,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1799
1799
|
landed since 1.19.668 with no explicit bump. run 34619821252. (task 1002620)
|
|
1800
1800
|
1.19.670 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1801
1801
|
landed since 1.19.669 with no explicit bump. run 34622305231. (task 1002620)
|
|
1802
|
+
1.19.671 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1803
|
+
landed since 1.19.670 with no explicit bump. run 34627255469. (task 1002620)
|
|
1804
|
+
1.19.672 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1805
|
+
landed since 1.19.671 with no explicit bump. run 34629971417. (task 1002620)
|
|
1802
1806
|
---------------------------------------------------------------------------
|
|
1803
1807
|
```
|
|
@@ -694,7 +694,7 @@ async function updateInstanceSettings(db, id, patch) {
|
|
|
694
694
|
// impossible — the very half of "add modules later" an owner needs most. Only an
|
|
695
695
|
// array ever reaches here, so the column's NULL ("never asked", ADR 0240 §2)
|
|
696
696
|
// stays reachable only by never answering; no write can un-ask the question.
|
|
697
|
-
async function updateInstanceDetail(db, id, { description, detail, modules } = {}) {
|
|
697
|
+
async function updateInstanceDetail(db, id, { description, detail, modules, type } = {}) {
|
|
698
698
|
const sets = ['updated_at = now()'];
|
|
699
699
|
const params = [id];
|
|
700
700
|
if (description !== undefined) {
|
|
@@ -713,6 +713,18 @@ async function updateInstanceDetail(db, id, { description, detail, modules } = {
|
|
|
713
713
|
params.push(picked === null ? null : JSON.stringify(picked));
|
|
714
714
|
sets.push(`modules = $${params.length}::jsonb`);
|
|
715
715
|
}
|
|
716
|
+
// The declared type (task 1003504 — the way back into the wizard's Type step).
|
|
717
|
+
// ASSIGNED, never COALESCEd: this is the owner re-declaring, so the new word has
|
|
718
|
+
// to win even over a curated one. The route is the only caller and it refuses
|
|
719
|
+
// anything outside PROJECT_TYPES, null included — 'not-sure' is the vocabulary's
|
|
720
|
+
// own way to say "I would rather not", so a null here would be a second spelling
|
|
721
|
+
// of an answer that already exists, and the catalog projection could not carry it
|
|
722
|
+
// (it reads the type as COALESCE(new, old), so a null would leave the map showing
|
|
723
|
+
// the declaration the owner just took back).
|
|
724
|
+
if (type !== undefined) {
|
|
725
|
+
params.push(type);
|
|
726
|
+
sets.push(`type = $${params.length}`);
|
|
727
|
+
}
|
|
716
728
|
const { rows } = await db.query(
|
|
717
729
|
`UPDATE provisioning_instances SET ${sets.join(', ')} WHERE id = $1 RETURNING *`,
|
|
718
730
|
params
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// modules/provisioning/routes/body-validators.js — the LOUD body validators the
|
|
2
|
+
// provisioning routes share (task 1003504).
|
|
3
|
+
//
|
|
4
|
+
// Lifted out of routes/provisioning.js when that file hit the 1500-line size ratchet,
|
|
5
|
+
// the same way ./callback-page.js was. These three are a natural group and the cheapest
|
|
6
|
+
// thing in the file to move: PURE functions with no express, no pool and no auth in
|
|
7
|
+
// them, each answering one question — "is this body field a mistake, and what do I tell
|
|
8
|
+
// the caller?" — and each returning null when clean or a { code, message } to fail with.
|
|
9
|
+
//
|
|
10
|
+
// They exist because the kernel's strict body validator cannot do their jobs: it reaches
|
|
11
|
+
// top-level fields only, so a typo INSIDE `detail` or `modules` would be dropped in
|
|
12
|
+
// silence on the way to storage. Silence is the wrong answer at the boundary.
|
|
13
|
+
//
|
|
14
|
+
// Both the create route and PATCH …/detail run them, so the two can never drift.
|
|
15
|
+
|
|
16
|
+
const provisioning = require('../provisioning');
|
|
17
|
+
const { ALWAYS_ON_CORE, OPTIONAL_MODULES } = require('../starter-bundles');
|
|
18
|
+
|
|
19
|
+
// The project-detail answers, validated LOUDLY (task 1002334). `detail` arrives as
|
|
20
|
+
// a nested object, and the strict body validator only reaches top-level fields — so
|
|
21
|
+
// without this a typo'd answer key would be dropped by normalizeProjectDetail and
|
|
22
|
+
// the caller would be told nothing, which is exactly the silent-drop failure the
|
|
23
|
+
// strict validator exists to prevent (and that a re-request's type_ignored receipt
|
|
24
|
+
// was written to avoid). Returns null when clean, or a { code, message } to fail
|
|
25
|
+
// with. Both the create route and PATCH …/detail run it, so they cannot drift.
|
|
26
|
+
function badProjectDetail(detail) {
|
|
27
|
+
if (detail === undefined || detail === null) return null;
|
|
28
|
+
if (typeof detail !== 'object' || Array.isArray(detail)) {
|
|
29
|
+
return { code: 'bad_detail', message: 'detail must be an object of answers.' };
|
|
30
|
+
}
|
|
31
|
+
const vocab = provisioning.PROJECT_DETAIL_VOCAB;
|
|
32
|
+
for (const [key, value] of Object.entries(detail)) {
|
|
33
|
+
if (!Object.prototype.hasOwnProperty.call(vocab, key)) {
|
|
34
|
+
return { code: 'bad_detail', message: `Unknown detail answer "${key}" — the interview asks: ${Object.keys(vocab).join(', ')}.` };
|
|
35
|
+
}
|
|
36
|
+
// An explicit null is the UN-ANSWER, not an off-vocabulary value (task 1003577):
|
|
37
|
+
// the merge-patch way back to "never answered". Only null — an empty string or any
|
|
38
|
+
// other sentinel stays a typo, so a caller trying to SET one is never silently
|
|
39
|
+
// read as having erased their answer.
|
|
40
|
+
if (value === null) continue;
|
|
41
|
+
if (!vocab[key].includes(value)) {
|
|
42
|
+
return { code: 'bad_detail', message: `"${key}" must be one of: ${vocab[key].join(', ')} — or null to un-answer it.` };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The creation picker's module selection, validated LOUDLY (BV1.R20, task
|
|
49
|
+
// 1002339) — badProjectDetail's rule applied to a list. The strict body validator
|
|
50
|
+
// checks that `modules` is an array of strings; it cannot check MEMBERSHIP, and
|
|
51
|
+
// normalizeModuleSelection drops an unknown key silently on the way to storage.
|
|
52
|
+
// Silence is the wrong answer at the boundary: an API caller who names a module
|
|
53
|
+
// that does not exist, or one of the always-on / hub-only ones, has made a
|
|
54
|
+
// mistake and would otherwise be told nothing and handed a project without it.
|
|
55
|
+
// Returns null when clean, or a { code, message } to fail with.
|
|
56
|
+
function badModuleSelection(modules) {
|
|
57
|
+
if (modules === undefined || modules === null) return null;
|
|
58
|
+
if (!Array.isArray(modules)) {
|
|
59
|
+
return { code: 'bad_modules', message: 'modules must be an array of module keys.' };
|
|
60
|
+
}
|
|
61
|
+
const offered = OPTIONAL_MODULES.map((m) => m.key);
|
|
62
|
+
for (const key of modules) {
|
|
63
|
+
if (offered.includes(key)) continue;
|
|
64
|
+
if (ALWAYS_ON_CORE.includes(key)) {
|
|
65
|
+
return { code: 'bad_modules', message: `"${key}" is always on for every project — it is not one of the modules to choose.` };
|
|
66
|
+
}
|
|
67
|
+
return { code: 'bad_modules', message: `Unknown module "${key}" — the choices are: ${offered.join(', ')}.` };
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The declared project type, validated LOUDLY (task 1003504) — badProjectDetail's rule
|
|
73
|
+
// applied to a single closed-vocabulary value. Returns null when clean, or a
|
|
74
|
+
// { code, message } to fail with.
|
|
75
|
+
//
|
|
76
|
+
// NULL IS REFUSED, and this is the one place the two "un-answer" conventions on
|
|
77
|
+
// PATCH …/detail part company. `detail: { key: null }` un-answers a question because
|
|
78
|
+
// that vocabulary has no word for "never said". The TYPE vocabulary has one —
|
|
79
|
+
// 'not-sure' — so null would only be a second spelling of an answer that already
|
|
80
|
+
// exists. It would also be a lie on the map: the catalog projection carries the type
|
|
81
|
+
// as COALESCE(EXCLUDED.type, existing.type), so a null would leave the row showing the
|
|
82
|
+
// declaration the owner had just taken back, with no second write to correct it.
|
|
83
|
+
function badProjectType(type) {
|
|
84
|
+
if (type === undefined) return null;
|
|
85
|
+
if (type === null) {
|
|
86
|
+
return { code: 'bad_type', message: 'A project always has a kind — send "not-sure" to take the declaration back, not null.' };
|
|
87
|
+
}
|
|
88
|
+
if (!provisioning.PROJECT_TYPES.includes(type)) {
|
|
89
|
+
return { code: 'bad_type', message: `Unknown project type "${type}" — the choices are: ${provisioning.PROJECT_TYPES.join(', ')}.` };
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { badProjectDetail, badModuleSelection, badProjectType };
|
|
@@ -55,6 +55,8 @@ const { requireAuthorityForMeteredShape } = require('../paid-shape-gate');
|
|
|
55
55
|
const { adoptCredentialFault } = require('../credential-preflight');
|
|
56
56
|
const catalogBridge = require('../catalog-bridge');
|
|
57
57
|
const { callbackPage } = require('./callback-page');
|
|
58
|
+
// the LOUD body validators (task 1003504) — lifted out when this file hit the size ratchet
|
|
59
|
+
const { badProjectDetail, badModuleSelection, badProjectType } = require('./body-validators');
|
|
58
60
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
59
61
|
const log = api.logger('provisioning');
|
|
60
62
|
|
|
@@ -107,59 +109,6 @@ function ownerFromTargetRef(ref) {
|
|
|
107
109
|
return m ? m[1] : null;
|
|
108
110
|
}
|
|
109
111
|
|
|
110
|
-
// The project-detail answers, validated LOUDLY (task 1002334). `detail` arrives as
|
|
111
|
-
// a nested object, and the strict body validator only reaches top-level fields — so
|
|
112
|
-
// without this a typo'd answer key would be dropped by normalizeProjectDetail and
|
|
113
|
-
// the caller would be told nothing, which is exactly the silent-drop failure the
|
|
114
|
-
// strict validator exists to prevent (and that a re-request's type_ignored receipt
|
|
115
|
-
// was written to avoid). Returns null when clean, or a { code, message } to fail
|
|
116
|
-
// with. Both the create route and PATCH …/detail run it, so they cannot drift.
|
|
117
|
-
function badProjectDetail(detail) {
|
|
118
|
-
if (detail === undefined || detail === null) return null;
|
|
119
|
-
if (typeof detail !== 'object' || Array.isArray(detail)) {
|
|
120
|
-
return { code: 'bad_detail', message: 'detail must be an object of answers.' };
|
|
121
|
-
}
|
|
122
|
-
const vocab = provisioning.PROJECT_DETAIL_VOCAB;
|
|
123
|
-
for (const [key, value] of Object.entries(detail)) {
|
|
124
|
-
if (!Object.prototype.hasOwnProperty.call(vocab, key)) {
|
|
125
|
-
return { code: 'bad_detail', message: `Unknown detail answer "${key}" — the interview asks: ${Object.keys(vocab).join(', ')}.` };
|
|
126
|
-
}
|
|
127
|
-
// An explicit null is the UN-ANSWER, not an off-vocabulary value (task 1003577):
|
|
128
|
-
// the merge-patch way back to "never answered". Only null — an empty string or any
|
|
129
|
-
// other sentinel stays a typo, so a caller trying to SET one is never silently
|
|
130
|
-
// read as having erased their answer.
|
|
131
|
-
if (value === null) continue;
|
|
132
|
-
if (!vocab[key].includes(value)) {
|
|
133
|
-
return { code: 'bad_detail', message: `"${key}" must be one of: ${vocab[key].join(', ')} — or null to un-answer it.` };
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// The creation picker's module selection, validated LOUDLY (BV1.R20, task
|
|
140
|
-
// 1002339) — badProjectDetail's rule applied to a list. The strict body validator
|
|
141
|
-
// checks that `modules` is an array of strings; it cannot check MEMBERSHIP, and
|
|
142
|
-
// normalizeModuleSelection drops an unknown key silently on the way to storage.
|
|
143
|
-
// Silence is the wrong answer at the boundary: an API caller who names a module
|
|
144
|
-
// that does not exist, or one of the always-on / hub-only ones, has made a
|
|
145
|
-
// mistake and would otherwise be told nothing and handed a project without it.
|
|
146
|
-
// Returns null when clean, or a { code, message } to fail with.
|
|
147
|
-
function badModuleSelection(modules) {
|
|
148
|
-
if (modules === undefined || modules === null) return null;
|
|
149
|
-
if (!Array.isArray(modules)) {
|
|
150
|
-
return { code: 'bad_modules', message: 'modules must be an array of module keys.' };
|
|
151
|
-
}
|
|
152
|
-
const offered = OPTIONAL_MODULES.map((m) => m.key);
|
|
153
|
-
for (const key of modules) {
|
|
154
|
-
if (offered.includes(key)) continue;
|
|
155
|
-
if (ALWAYS_ON_CORE.includes(key)) {
|
|
156
|
-
return { code: 'bad_modules', message: `"${key}" is always on for every project — it is not one of the modules to choose.` };
|
|
157
|
-
}
|
|
158
|
-
return { code: 'bad_modules', message: `Unknown module "${key}" — the choices are: ${offered.join(', ')}.` };
|
|
159
|
-
}
|
|
160
|
-
return null;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
112
|
// Register this module's kernel seam (ADR 0083 / the registerBoxSeams precedent).
|
|
164
113
|
// Idempotent — the loader calls this factory once at boot, but tests may
|
|
165
114
|
// instantiate the router repeatedly; gating on hasProvider keeps the port
|
|
@@ -716,6 +665,21 @@ module.exports = function provisioningRoutes() {
|
|
|
716
665
|
// a later edit. The always-on core stays unremovable by construction:
|
|
717
666
|
// badModuleSelection refuses its keys and `effectiveModules` re-adds it on read.
|
|
718
667
|
//
|
|
668
|
+
// `type` RIDES IT TOO (task 1003504), and for the third time the same argument: the
|
|
669
|
+
// wizard's Type step declared something at creation and nothing owner-facing could
|
|
670
|
+
// change it afterwards, so task 1002772 had to narrow the wizard's own promise and
|
|
671
|
+
// stop saying "you can change it later". This is the route that earns the sentence
|
|
672
|
+
// back. It belongs here rather than on the settings channel for the mModules reason —
|
|
673
|
+
// a type is a DECLARATION on the platform row, not a policy the running instance
|
|
674
|
+
// obeys, so nothing is pushed to the project and nothing restarts.
|
|
675
|
+
//
|
|
676
|
+
// One consequence is worth naming, because the owner has to be told it: for a project
|
|
677
|
+
// whose picker was SKIPPED, `modules` is NULL and effectiveModules resolves the type's
|
|
678
|
+
// starter bundle at read time (ADR 0240 §2) — so changing the type moves the extras
|
|
679
|
+
// too. A set the owner picked for themselves is stored and never moves. The reply
|
|
680
|
+
// carries `modules` on every patch precisely so the caller learns which happened
|
|
681
|
+
// without a follow-up GET.
|
|
682
|
+
//
|
|
719
683
|
// AN ANSWER CAN BE TAKEN BACK (task 1003577). `detail` merges, so before this the
|
|
720
684
|
// first `team_shape` an owner sent was permanent — and "never answered" is a state
|
|
721
685
|
// `effectiveModules` reads (ADR 0240), not merely the absence of one. Sending
|
|
@@ -731,16 +695,22 @@ module.exports = function provisioningRoutes() {
|
|
|
731
695
|
description: { type: 'string', maxLength: provisioning.DESCRIPTION_MAX },
|
|
732
696
|
detail: { type: 'object' },
|
|
733
697
|
modules: { type: 'array', itemsType: 'string', maxItems: 50 },
|
|
698
|
+
type: { type: 'string', maxLength: 40 },
|
|
734
699
|
})) return;
|
|
735
700
|
const body = req.body || {};
|
|
736
701
|
const detailFault = badProjectDetail(body.detail);
|
|
737
702
|
if (detailFault) return res.fail(detailFault.code, { status: 400, message: detailFault.message });
|
|
738
703
|
const modulesFault = badModuleSelection(body.modules);
|
|
739
704
|
if (modulesFault) return res.fail(modulesFault.code, { status: 400, message: modulesFault.message });
|
|
705
|
+
// `type: null` reaches here because the kernel validator reads null as ABSENT —
|
|
706
|
+
// which is exactly why the refusal has to be its own check and not an enum rule.
|
|
707
|
+
const typeFault = badProjectType(Object.prototype.hasOwnProperty.call(body, 'type') ? body.type : undefined);
|
|
708
|
+
if (typeFault) return res.fail(typeFault.code, { status: 400, message: typeFault.message });
|
|
740
709
|
const wantsModules = Array.isArray(body.modules);
|
|
741
|
-
|
|
710
|
+
const wantsType = typeof body.type === 'string';
|
|
711
|
+
if (body.description === undefined && body.detail === undefined && !wantsModules && !wantsType) {
|
|
742
712
|
return res.fail('bad_detail', { status: 400,
|
|
743
|
-
message: 'The body carries nothing to change — send description, detail, modules, or any mix.' });
|
|
713
|
+
message: 'The body carries nothing to change — send description, detail, modules, type, or any mix.' });
|
|
744
714
|
}
|
|
745
715
|
try {
|
|
746
716
|
const inst = await provisioning.getInstanceById(pool, id);
|
|
@@ -753,6 +723,7 @@ module.exports = function provisioningRoutes() {
|
|
|
753
723
|
if (body.description !== undefined) patch.description = provisioning.normalizeDescription(body.description);
|
|
754
724
|
if (body.detail !== undefined) patch.detail = body.detail;
|
|
755
725
|
if (wantsModules) patch.modules = body.modules;
|
|
726
|
+
if (wantsType) patch.type = body.type;
|
|
756
727
|
const updated = await provisioning.updateInstanceDetail(pool, id, patch);
|
|
757
728
|
await provisioning.recordEvent(pool, {
|
|
758
729
|
instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'detail',
|
|
@@ -775,7 +746,12 @@ module.exports = function provisioningRoutes() {
|
|
|
775
746
|
// Re-deriving it from a follow-up GET would let the two answers disagree.
|
|
776
747
|
publish: publishVerdict(updated),
|
|
777
748
|
// `team_shape` MOVES a bundle-sourced project's modules — effect with cause (ADR 0243 §5).
|
|
749
|
+
// So does `type`, for a project whose picker was skipped — same reason, said once here.
|
|
778
750
|
modules: effectiveModules(updated),
|
|
751
|
+
// The declaration as it now stands (task 1003504) — the caller repaints from the
|
|
752
|
+
// platform's answer, never from the word it sent, so a refused or clamped write
|
|
753
|
+
// can never leave the picker showing a choice the row does not hold.
|
|
754
|
+
type: updated.type || null,
|
|
779
755
|
});
|
|
780
756
|
} catch (err) {
|
|
781
757
|
log.error('[provisioning] PATCH /provisioning/instances/:id/detail', err);
|
|
@@ -1585,6 +1585,12 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
1585
1585
|
#view-new .panel .lede strong,#view-new .panel .lede em{color:var(--ink);font-weight:500;font-style:normal;}
|
|
1586
1586
|
/* the done clause's link to Your projects clears the 24px hit floor as a box (an inline's rect is its content box) */
|
|
1587
1587
|
#view-new .panel .lede a{display:inline-block;padding:3px 0;min-height:24px;line-height:18px;color:var(--ink);font-weight:400;text-decoration:underline;text-underline-offset:3px;}
|
|
1588
|
+
/* the "still watching …" line above step 1 (task 1003503): a quiet offer, not a
|
|
1589
|
+
banner — the fresh wizard is what the click asked for. Its link clears the 24px
|
|
1590
|
+
hit floor as a box, the same way the lede's does. */
|
|
1591
|
+
#view-new .resumeOffer{font-weight:400;font-size:13.5px;line-height:1.5;color:var(--ink-soft);margin:0 0 22px;max-width:62ch;}
|
|
1592
|
+
#view-new .resumeOffer strong{color:var(--ink);font-weight:500;}
|
|
1593
|
+
#view-new .resumeOffer a{display:inline-block;padding:3px 0;min-height:24px;line-height:18px;color:var(--ink);font-weight:400;text-decoration:underline;text-underline-offset:3px;}
|
|
1588
1594
|
#view-new .finePrint{font-weight:400;font-size:13.5px;line-height:1.5;color:var(--ink-soft);margin-top:18px;max-width:62ch;}
|
|
1589
1595
|
#view-new .finePrint a{color:var(--ink);font-weight:400;text-decoration:underline;text-underline-offset:3px;}
|
|
1590
1596
|
#view-new .finePrint em{font-style:normal;color:var(--ink);}
|
|
@@ -829,6 +829,28 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
829
829
|
</details>
|
|
830
830
|
</div>
|
|
831
831
|
|
|
832
|
+
<!-- live since task 1003504: the way back into the creation wizard's Type
|
|
833
|
+
step. Task 1002772 shipped the declaration with a deliberately NARROWED
|
|
834
|
+
promise — the wizard stopped saying "you can change it later", because
|
|
835
|
+
nothing owner-facing could. This is the surface that earns the sentence
|
|
836
|
+
back, and it sits beside "Its extras" for that section's own reason and
|
|
837
|
+
on the same channel: a type is a DECLARATION on the platform row, not a
|
|
838
|
+
policy the running instance obeys, so nothing is pushed to the project
|
|
839
|
+
and nothing restarts.
|
|
840
|
+
The one way it differs from its neighbour: this is a choice of ONE, not
|
|
841
|
+
a set of toggles, so picking the kind already chosen is a no-op rather
|
|
842
|
+
than an un-declaration. "Not Sure" is the vocabulary's own way back to
|
|
843
|
+
saying nothing, which is why the route refuses null. The fold is a
|
|
844
|
+
SIBLING of the volatile slot so no re-render can drop it. -->
|
|
845
|
+
<div class="card mSec" id="mKind">
|
|
846
|
+
<h2>Its kind</h2>
|
|
847
|
+
<div id="mKindPick"><p class="finePrint">Reading the project…</p></div>
|
|
848
|
+
<details class="finePrint" id="mKindWhat">
|
|
849
|
+
<summary>What the choice changes</summary>
|
|
850
|
+
<p>How this project is listed — the kind shown beside its name on the map, in the table and on its card. It is a declaration about the project, not a setting the project obeys: nothing is pushed to it and nothing restarts. One thing does follow the kind: if you never picked your extras yourself, they are the set this kind suggests, so changing the kind changes them too — you will be told when that happens. Extras you picked for yourself are stored and never move. Not Sure is a real answer, and it is the way back if you would rather not say.</p>
|
|
851
|
+
</details>
|
|
852
|
+
</div>
|
|
853
|
+
|
|
832
854
|
<!-- live since task 1002342 (BV1.R23): the way back into the creation
|
|
833
855
|
wizard's module picker. Step 4 of the wizard is skippable, and an
|
|
834
856
|
optional step is a default rather than a dead end only if there is a
|
|
@@ -978,6 +1000,11 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
978
1000
|
<h1 id="name-h">Start a project</h1>
|
|
979
1001
|
<p class="lede">Your own project: its own repository, its own home on the web, and a hall of AI builders
|
|
980
1002
|
who do the building. You set the direction; agents lay down the code.</p>
|
|
1003
|
+
<!-- The standup already being watched, kept one click away (task 1003503).
|
|
1004
|
+
A deliberate "New project" click opens THIS fresh step 1; the previous
|
|
1005
|
+
project's done panel is reachable here instead of taking the panel over.
|
|
1006
|
+
Baked markup, hidden until enter() has a record to name. -->
|
|
1007
|
+
<p class="resumeOffer" id="wizResume" hidden>still watching <strong id="wizResumeName">your project</strong> — <a href="#" id="wizResumeGo">resume it</a></p>
|
|
981
1008
|
<form id="newForm" data-next="2">
|
|
982
1009
|
<label class="wizField"><span>Project name</span>
|
|
983
1010
|
<input type="text" id="newName" placeholder="What should it be called?" autocomplete="off" maxlength="80" aria-label="Project name">
|
|
@@ -1000,7 +1027,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1000
1027
|
forced into a wrong answer, and it can change later. -->
|
|
1001
1028
|
<section class="panel" id="panel-type" aria-labelledby="type-h">
|
|
1002
1029
|
<h1 id="type-h">What kind of project is it?</h1>
|
|
1003
|
-
<p class="lede">Pick the shape that fits best — it’s how the project is listed on the map, and it
|
|
1030
|
+
<p class="lede">Pick the shape that fits best — it’s how the project is listed on the map, and you can change it later from the project’s own page. Not sure is a fine answer.</p>
|
|
1004
1031
|
<form data-next="3">
|
|
1005
1032
|
<div class="typeCards" id="typeCards" role="group" aria-label="Project type">
|
|
1006
1033
|
<button type="button" class="tpill" data-type="game" aria-pressed="false">Game</button>
|
|
@@ -4613,6 +4640,36 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
4613
4640
|
tick();
|
|
4614
4641
|
}
|
|
4615
4642
|
|
|
4643
|
+
/* Everything the wizard must FORGET to start a second project: the watch, the
|
|
4644
|
+
manifest leg, the answers, the fields. Deliberately not the created record —
|
|
4645
|
+
the caller owns that. "Start another project" drops it (the founder said they
|
|
4646
|
+
were done); the intent-driven resume below keeps it, so the standup stays one
|
|
4647
|
+
click away while a fresh step 1 is filled in. */
|
|
4648
|
+
function resetForNewProject() {
|
|
4649
|
+
stopPolling();
|
|
4650
|
+
stopManifestPoll();
|
|
4651
|
+
instanceId = null;
|
|
4652
|
+
watchPending = false; manifestPending = false; manifestState = null;
|
|
4653
|
+
manifestStartedAt = 0; manifestStale = false;
|
|
4654
|
+
live = { instStatus: null, manifest: null, manifestMsg: '', manifestCode: '', appSlug: null };
|
|
4655
|
+
lastStepsHtml = '';
|
|
4656
|
+
lastProgHtml = '';
|
|
4657
|
+
try { sessionStorage.removeItem(DRAFT_KEY); } catch (e) {}
|
|
4658
|
+
/* Every key the initial state declares has to be re-declared here. `modules`
|
|
4659
|
+
is three-valued and null is its "nothing said" sentinel; leaving the key out
|
|
4660
|
+
left it UNDEFINED, which is not null — so pickedModules() reached .slice()
|
|
4661
|
+
on it and the second project's Modules step threw. */
|
|
4662
|
+
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 };
|
|
4663
|
+
$('newName').value = ''; $('f-owner').value = ''; $('f-repo').value = ''; $('f-slug').value = ''; $('f-domain').value = '';
|
|
4664
|
+
$('f-noaddr').checked = false; $('f-desc').value = '';
|
|
4665
|
+
/* Re-seed the owner from the signed-in account for the next project. */
|
|
4666
|
+
if (me && me.github_login) { state.owner = me.github_login; $('f-owner').value = state.owner; }
|
|
4667
|
+
$('h-name').innerHTML = ' ';
|
|
4668
|
+
paintType(null);
|
|
4669
|
+
paintTeam(null);
|
|
4670
|
+
setMode('greenfield');
|
|
4671
|
+
}
|
|
4672
|
+
|
|
4616
4673
|
/* start another project: clear the stored instance + draft, back to step 1 */
|
|
4617
4674
|
function wireAgain() {
|
|
4618
4675
|
var again = document.createElement('button');
|
|
@@ -4620,24 +4677,11 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
4620
4677
|
again.addEventListener('click', function () {
|
|
4621
4678
|
/* a standup still being watched is not abandoned by accident */
|
|
4622
4679
|
if (instanceId && pollTimer && !confirm((state.name || state.slug) + ' keeps building — you’ll find it on your projects page. Start a new one?')) return;
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
live = { instStatus: null, manifest: null, manifestMsg: '', manifestCode: '', appSlug: null };
|
|
4629
|
-
lastStepsHtml = '';
|
|
4630
|
-
lastProgHtml = '';
|
|
4631
|
-
try { sessionStorage.removeItem(CREATED_KEY); sessionStorage.removeItem(DRAFT_KEY); } catch (e) {}
|
|
4632
|
-
state = { step: 1, name: '', projType: null, description: '', teamShape: null, mode: 'greenfield', owner: '', ownerTouched: false, repo: '', repoTouched: false, slug: '', slugTouched: false, domain: '', noAddress: false };
|
|
4633
|
-
$('newName').value = ''; $('f-owner').value = ''; $('f-repo').value = ''; $('f-slug').value = ''; $('f-domain').value = '';
|
|
4634
|
-
$('f-noaddr').checked = false; $('f-desc').value = '';
|
|
4635
|
-
/* Re-seed the owner from the signed-in account for the next project. */
|
|
4636
|
-
if (me && me.github_login) { state.owner = me.github_login; $('f-owner').value = state.owner; }
|
|
4637
|
-
$('h-name').innerHTML = ' ';
|
|
4638
|
-
paintType(null);
|
|
4639
|
-
paintTeam(null);
|
|
4640
|
-
setMode('greenfield');
|
|
4680
|
+
resetForNewProject();
|
|
4681
|
+
/* the deliberate goodbye: the record goes too, so nothing offers to resume
|
|
4682
|
+
the project the founder has just said they are finished watching */
|
|
4683
|
+
try { sessionStorage.removeItem(CREATED_KEY); } catch (e) {}
|
|
4684
|
+
paintResumeOffer(null);
|
|
4641
4685
|
show(1, true);
|
|
4642
4686
|
});
|
|
4643
4687
|
$('panel-done').appendChild(again);
|
|
@@ -4648,6 +4692,81 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
4648
4692
|
function createdRecord() {
|
|
4649
4693
|
try { return JSON.parse(sessionStorage.getItem(CREATED_KEY) || 'null'); } catch (e) { return null; }
|
|
4650
4694
|
}
|
|
4695
|
+
/* The "still watching …" offer above step 1 (task 1003503). Its markup is baked
|
|
4696
|
+
into the panel, so this only names the project and shows or hides the line —
|
|
4697
|
+
this surface builds no live DOM from strings. null takes it down. */
|
|
4698
|
+
function paintResumeOffer(created) {
|
|
4699
|
+
var el = $('wizResume');
|
|
4700
|
+
if (!el) return;
|
|
4701
|
+
if (created && created.id) {
|
|
4702
|
+
$('wizResumeName').textContent = created.name || created.slug || 'your project';
|
|
4703
|
+
el.hidden = false;
|
|
4704
|
+
} else {
|
|
4705
|
+
el.hidden = true;
|
|
4706
|
+
}
|
|
4707
|
+
}
|
|
4708
|
+
/* Raised by the resume link so the enter() it triggers reads as "yes, the done
|
|
4709
|
+
panel" instead of one more ask for a fresh wizard. One-shot: enter() lowers it. */
|
|
4710
|
+
var resumeIntent = false;
|
|
4711
|
+
$('wizResumeGo').addEventListener('click', function (ev) {
|
|
4712
|
+
ev.preventDefault();
|
|
4713
|
+
resumeIntent = true;
|
|
4714
|
+
enter(true);
|
|
4715
|
+
});
|
|
4716
|
+
|
|
4717
|
+
/* enterCreated(created, focus) — a stored record means a standup is (or was)
|
|
4718
|
+
being watched, and WHO IS ARRIVING decides what that means (task 1003503,
|
|
4719
|
+
owner call 2026-09-11):
|
|
4720
|
+
|
|
4721
|
+
• a reload, Back, or the identity settle (focus false) — the same visit
|
|
4722
|
+
continuing, so the done panel and its human-steps checklist come back
|
|
4723
|
+
untouched. Losing the checklist there would be the bug;
|
|
4724
|
+
• a deliberate "New project" click (focus true) — an ask for ANOTHER
|
|
4725
|
+
project, so it opens a fresh step 1 and leaves the standup one click
|
|
4726
|
+
away on the resume link. That nav used to land on the previous
|
|
4727
|
+
project's checklist for the rest of the tab session, with "Start
|
|
4728
|
+
another project" the only way out.
|
|
4729
|
+
|
|
4730
|
+
The reset fires only when there is a SECOND DRAFT to lose, and the draft's own
|
|
4731
|
+
step is what says so. A landed create clears DRAFT_KEY — but show(8) then
|
|
4732
|
+
persists the done panel's own position straight back into it, so "is there a
|
|
4733
|
+
draft?" is always yes and would have skipped the reset forever, leaving the
|
|
4734
|
+
finished project's name sitting in the fresh form. Only steps 1–7 are wizard
|
|
4735
|
+
steps: anything else is the done panel's bookkeeping, not a project someone
|
|
4736
|
+
is part-way through typing. */
|
|
4737
|
+
function enterCreated(created, focus) {
|
|
4738
|
+
if (focus && !resumeIntent) {
|
|
4739
|
+
var draft = null;
|
|
4740
|
+
try { draft = JSON.parse(sessionStorage.getItem(DRAFT_KEY) || 'null'); } catch (e) {}
|
|
4741
|
+
var at = draft && draft.step >= 1 && draft.step <= 7 ? draft.step : 0;
|
|
4742
|
+
if (!at) { resetForNewProject(); at = 1; }
|
|
4743
|
+
paintResumeOffer(created);
|
|
4744
|
+
/* step 1's own focus target is the name field, not the heading — the same
|
|
4745
|
+
deliberate-nav rule enter() applies below */
|
|
4746
|
+
show(at, at !== 1);
|
|
4747
|
+
if (at === 1) $('newName').focus();
|
|
4748
|
+
return;
|
|
4749
|
+
}
|
|
4750
|
+
resumeIntent = false;
|
|
4751
|
+
paintResumeOffer(null);
|
|
4752
|
+
/* returning after a create — resume watching that instance instead of a
|
|
4753
|
+
blank wizard */
|
|
4754
|
+
state.name = created.name || created.slug; state.slug = created.slug || '';
|
|
4755
|
+
state.domain = created.domain || ''; state.mode = created.mode || 'greenfield';
|
|
4756
|
+
/* restore the opt-out too — without it resolvedDomain() re-derives the
|
|
4757
|
+
assigned address and the done panel promises one that doesn't exist */
|
|
4758
|
+
state.noAddress = !!created.noaddr;
|
|
4759
|
+
/* the manifest leg's last known truth, kept across a reload */
|
|
4760
|
+
if (created.manifest) { live.manifest = created.manifest; live.appSlug = created.app_slug || null; }
|
|
4761
|
+
if (created.manifest_state && created.manifest !== 'done' && created.manifest !== 'error' && created.manifest !== 'expired') {
|
|
4762
|
+
manifestState = created.manifest_state; manifestPending = true;
|
|
4763
|
+
}
|
|
4764
|
+
renderDone();
|
|
4765
|
+
show(8, !!focus);
|
|
4766
|
+
startProgress(created.id);
|
|
4767
|
+
resumePolls();
|
|
4768
|
+
}
|
|
4769
|
+
|
|
4651
4770
|
/* enter(focus) — paint the right step for whoever just arrived. Before the
|
|
4652
4771
|
identity settles it shows the draft step optimistically (the page stays
|
|
4653
4772
|
usable while /me is in flight — the boot's own rule); onAuth() re-enters
|
|
@@ -4655,25 +4774,8 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
4655
4774
|
function enter(focus) {
|
|
4656
4775
|
if (authKnown && !me) { show(0, !!focus); return; }
|
|
4657
4776
|
var created = authKnown ? createdRecord() : null;
|
|
4658
|
-
if (created && created.id) {
|
|
4659
|
-
|
|
4660
|
-
blank wizard */
|
|
4661
|
-
state.name = created.name || created.slug; state.slug = created.slug || '';
|
|
4662
|
-
state.domain = created.domain || ''; state.mode = created.mode || 'greenfield';
|
|
4663
|
-
/* restore the opt-out too — without it resolvedDomain() re-derives the
|
|
4664
|
-
assigned address and the done panel promises one that doesn't exist */
|
|
4665
|
-
state.noAddress = !!created.noaddr;
|
|
4666
|
-
/* the manifest leg's last known truth, kept across a reload */
|
|
4667
|
-
if (created.manifest) { live.manifest = created.manifest; live.appSlug = created.app_slug || null; }
|
|
4668
|
-
if (created.manifest_state && created.manifest !== 'done' && created.manifest !== 'error' && created.manifest !== 'expired') {
|
|
4669
|
-
manifestState = created.manifest_state; manifestPending = true;
|
|
4670
|
-
}
|
|
4671
|
-
renderDone();
|
|
4672
|
-
show(8, !!focus);
|
|
4673
|
-
startProgress(created.id);
|
|
4674
|
-
resumePolls();
|
|
4675
|
-
return;
|
|
4676
|
-
}
|
|
4777
|
+
if (created && created.id) { enterCreated(created, !!focus); return; }
|
|
4778
|
+
paintResumeOffer(null);
|
|
4677
4779
|
/* before identity settles, never render past the name step: deeper steps
|
|
4678
4780
|
carry authenticated side effects (the Home-step repo probe), and onAuth()
|
|
4679
4781
|
re-enters to the real draft step the moment /me answers */
|
|
@@ -5720,6 +5822,9 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
5720
5822
|
/* the extras band (task 1002342) rides the same guard: its own toggles
|
|
5721
5823
|
are the only thing that should ever repaint it, never the tick */
|
|
5722
5824
|
if (!keepRepo) renderMods(inst);
|
|
5825
|
+
/* the kind picker (task 1003504) rides it for the same reason — and it can
|
|
5826
|
+
move the band above, so the two must never be repainted out of step */
|
|
5827
|
+
if (!keepRepo) renderKind(inst);
|
|
5723
5828
|
})
|
|
5724
5829
|
.catch(function (err) {
|
|
5725
5830
|
var m = String(err && err.message);
|
|
@@ -6591,6 +6696,85 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
6591
6696
|
repaints from the server's own answer — never from the list we sent — so it
|
|
6592
6697
|
can never show a set the platform did not accept. ── */
|
|
6593
6698
|
|
|
6699
|
+
/* The declared kind (task 1003504). Painted from TYPE_META — the page's single
|
|
6700
|
+
copy of the vocabulary, shared with the table cell and the card — so a sixth
|
|
6701
|
+
kind, or a re-worded one, can never mean two things on one page. Reuses the
|
|
6702
|
+
extras band's own classes: the control is the same control (a labelled choice
|
|
6703
|
+
with its line of explanation), and a second set of rules for it would drift
|
|
6704
|
+
from the first. aria-pressed, not aria-checked, mirrors the wizard's own Type
|
|
6705
|
+
pills — this is that picker, met again. */
|
|
6706
|
+
function kindSlot(html) { $('mKindPick').innerHTML = html; }
|
|
6707
|
+
var kindBusy = false;
|
|
6708
|
+
|
|
6709
|
+
function renderKind(inst) {
|
|
6710
|
+
var now = (inst && inst.type) || null;
|
|
6711
|
+
kindSlot('<div class="modBand" id="mKindBand" role="group" aria-label="Project kind">' +
|
|
6712
|
+
Object.keys(TYPE_META).map(function (k) {
|
|
6713
|
+
return '<button type="button" class="modOpt" data-kind="' + esc(k) + '" aria-pressed="' +
|
|
6714
|
+
(now === k ? 'true' : 'false') + '"' + (kindBusy ? ' disabled' : '') +
|
|
6715
|
+
'><b>' + esc(TYPE_META[k].label) + '</b><small>' + esc(TYPE_META[k].desc) + '</small></button>';
|
|
6716
|
+
}).join('') + '</div>' +
|
|
6717
|
+
(now ? '' : '<p class="finePrint">This project has never said what kind it is. Saying so puts the word beside its name wherever it is listed; it changes nothing else.</p>') +
|
|
6718
|
+
'<div class="projMsg" id="mKindMsg" role="status" aria-live="polite"></div>');
|
|
6719
|
+
}
|
|
6720
|
+
|
|
6721
|
+
/* One pick, sent as the whole declaration. Re-picking the kind already chosen is
|
|
6722
|
+
a NO-OP, not an un-declaration: the vocabulary's way back is "Not Sure", and a
|
|
6723
|
+
toggle-off here would send a null the route refuses anyway. */
|
|
6724
|
+
function doKindPick(key) {
|
|
6725
|
+
if (!manageInst || kindBusy) return;
|
|
6726
|
+
if ((manageInst.type || null) === key) return;
|
|
6727
|
+
kindBusy = true;
|
|
6728
|
+
renderKind(manageInst);
|
|
6729
|
+
var box = $('mKindMsg');
|
|
6730
|
+
box.className = 'projMsg'; box.textContent = 'Saving…';
|
|
6731
|
+
var asked = manageId; /* a stale answer must never repaint a newer project */
|
|
6732
|
+
var hadPicked = modsSource(manageInst) === 'picked';
|
|
6733
|
+
fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/detail', {
|
|
6734
|
+
method: 'PATCH', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
|
6735
|
+
body: JSON.stringify({ type: key })
|
|
6736
|
+
}).then(function (res) {
|
|
6737
|
+
return jsonOrEmpty(res).then(function (data) {
|
|
6738
|
+
kindBusy = false;
|
|
6739
|
+
if (asked !== manageId) return;
|
|
6740
|
+
if (res.ok) {
|
|
6741
|
+
/* the platform's own answer, never the word we sent */
|
|
6742
|
+
manageInst.type = data.type || null;
|
|
6743
|
+
manageInst.modules = data.modules || manageInst.modules;
|
|
6744
|
+
renderKind(manageInst);
|
|
6745
|
+
/* the extras band reads the same row: a bundle-sourced set has just moved
|
|
6746
|
+
with the kind, and leaving it painted from the old one would be a lie */
|
|
6747
|
+
renderMods(manageInst);
|
|
6748
|
+
var ok = $('mKindMsg');
|
|
6749
|
+
ok.className = 'projMsg ok';
|
|
6750
|
+
ok.textContent = hadPicked || modsSource(manageInst) !== 'bundle'
|
|
6751
|
+
? 'Saved. Nothing on the project itself changes — there is nothing to restart.'
|
|
6752
|
+
: 'Saved. Your extras follow the kind, because you never picked them yourself — they are now the set this kind suggests. Nothing on the project itself changes.';
|
|
6753
|
+
return;
|
|
6754
|
+
}
|
|
6755
|
+
renderKind(manageInst); /* the band goes back to what the row still says */
|
|
6756
|
+
var e = apiError(data, res.status);
|
|
6757
|
+
var m = $('mKindMsg');
|
|
6758
|
+
m.className = 'projMsg err';
|
|
6759
|
+
if (res.status === 401) { m.innerHTML = signInAgain(); return; }
|
|
6760
|
+
m.textContent = e;
|
|
6761
|
+
});
|
|
6762
|
+
}).catch(function () {
|
|
6763
|
+
kindBusy = false;
|
|
6764
|
+
if (asked !== manageId) return;
|
|
6765
|
+
renderKind(manageInst);
|
|
6766
|
+
var m = $('mKindMsg');
|
|
6767
|
+
m.className = 'projMsg err';
|
|
6768
|
+
m.textContent = 'Couldn’t reach the platform just now. Nothing about your project has changed — try again in a moment.';
|
|
6769
|
+
});
|
|
6770
|
+
}
|
|
6771
|
+
|
|
6772
|
+
document.addEventListener('click', function (ev) {
|
|
6773
|
+
var b = ev.target.closest && ev.target.closest('#mKindBand [data-kind]');
|
|
6774
|
+
if (!b) return;
|
|
6775
|
+
doKindPick(b.getAttribute('data-kind'));
|
|
6776
|
+
});
|
|
6777
|
+
|
|
6594
6778
|
var modsCatalog = null; /* { core, optional } — the public catalog, read once */
|
|
6595
6779
|
var modsPromise = null;
|
|
6596
6780
|
var modsFailed = false; /* asked and did not get it; distinct from not yet asked */
|
|
@@ -6617,6 +6801,12 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
6617
6801
|
function modsSelected(inst) {
|
|
6618
6802
|
return ((inst && inst.modules && inst.modules.selected) || []).slice();
|
|
6619
6803
|
}
|
|
6804
|
+
/* 'bundle' = the kind's suggested set, resolved at read time because the picker
|
|
6805
|
+
was skipped; 'picked' = an answer the owner gave and the column stores. The
|
|
6806
|
+
kind picker beside this reads it to say whether a change moved the extras. */
|
|
6807
|
+
function modsSource(inst) {
|
|
6808
|
+
return (inst && inst.modules && inst.modules.source) || null;
|
|
6809
|
+
}
|
|
6620
6810
|
|
|
6621
6811
|
function renderMods(inst) {
|
|
6622
6812
|
ensureModsCatalog();
|