@nanocollective/roster 0.1.0-alpha.13 → 0.1.0-alpha.15
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/dist/cli.js +18 -0
- package/package.json +1 -1
- package/templates/portal/css/setup.css +27 -0
- package/templates/portal/js/views/credential.js +13 -6
- package/templates/portal/js/views/paste.js +5 -8
- package/templates/portal/js/views/repos.js +10 -8
- package/templates/portal/js/views/setup.js +240 -101
package/dist/cli.js
CHANGED
|
@@ -1319,6 +1319,7 @@ async function setupStatus(startedIn) {
|
|
|
1319
1319
|
opsDir: found.opsDir,
|
|
1320
1320
|
org: readOrgName(found.opsDir),
|
|
1321
1321
|
repos: readBlockNames(found.opsDir, "repos", "name"),
|
|
1322
|
+
products: readProducts(found.opsDir),
|
|
1322
1323
|
left: unfinished(found.opsDir, readBlockNames(found.opsDir, "staff", "handle").length)
|
|
1323
1324
|
} : { found: false },
|
|
1324
1325
|
gh: ready.ok ? { ok: true, login: ready.data?.login } : { ok: false, error: ready.error ?? "gh is not ready" },
|
|
@@ -1333,6 +1334,23 @@ function readOrgName(opsDir) {
|
|
|
1333
1334
|
const m = /^org:\s*(\S+)\s*$/m.exec(readFileSync10(path, "utf8"));
|
|
1334
1335
|
return m?.[1];
|
|
1335
1336
|
}
|
|
1337
|
+
function readProducts(opsDir) {
|
|
1338
|
+
const path = join11(opsDir, "org.yaml");
|
|
1339
|
+
if (!existsSync11(path)) return [];
|
|
1340
|
+
const out = [];
|
|
1341
|
+
let inside = false;
|
|
1342
|
+
for (const line of readFileSync10(path, "utf8").split("\n")) {
|
|
1343
|
+
if (/^repos:\s*$/.test(line)) {
|
|
1344
|
+
inside = true;
|
|
1345
|
+
continue;
|
|
1346
|
+
}
|
|
1347
|
+
if (inside && /^\S/.test(line)) break;
|
|
1348
|
+
if (!inside || !/\brole:\s*product\b/.test(line)) continue;
|
|
1349
|
+
const m = /\bname:\s*([\w.-]+)/.exec(line);
|
|
1350
|
+
if (m) out.push(m[1]);
|
|
1351
|
+
}
|
|
1352
|
+
return out;
|
|
1353
|
+
}
|
|
1336
1354
|
function readBlockNames(opsDir, block, key) {
|
|
1337
1355
|
const path = join11(opsDir, "org.yaml");
|
|
1338
1356
|
if (!existsSync11(path)) return [];
|
package/package.json
CHANGED
|
@@ -121,3 +121,30 @@
|
|
|
121
121
|
.plansteps{margin:6px 0 12px;padding-left:20px;font-size:13.5px;line-height:1.7}
|
|
122
122
|
.planfiles{margin:0 0 12px}
|
|
123
123
|
.planfiles summary{cursor:pointer;color:var(--ink-dim);font-size:12.5px}
|
|
124
|
+
|
|
125
|
+
/* ---------- the numbered to-do list ---------- */
|
|
126
|
+
|
|
127
|
+
.todos{display:grid;gap:10px}
|
|
128
|
+
.todo{background:var(--panel);border:1px solid var(--line);border-radius:var(--r-lg);padding:16px 18px}
|
|
129
|
+
.todo.done{padding:11px 18px;border-color:color-mix(in srgb,var(--accent) 30%,var(--line))}
|
|
130
|
+
.todohead{display:flex;align-items:center;gap:11px}
|
|
131
|
+
.todohead h3{margin:0;font:600 14.5px var(--sans);flex:1}
|
|
132
|
+
.todon{flex:none;width:24px;height:24px;border-radius:50%;display:grid;place-items:center;
|
|
133
|
+
background:var(--surface-2);color:var(--ink-dim);font:600 11px var(--mono)}
|
|
134
|
+
.todo.done .todon{background:var(--accent-fill);color:var(--accent-ink)}
|
|
135
|
+
.todopill{font:600 11px var(--sans);padding:2px 8px;border-radius:99px;background:var(--surface-2);color:var(--ink-dim)}
|
|
136
|
+
.todo.done .todopill{background:var(--accent-soft);color:var(--accent)}
|
|
137
|
+
.todohint{margin:8px 0 0 35px}
|
|
138
|
+
.todobody{margin:12px 0 0 35px}
|
|
139
|
+
.todobody .row{margin-top:10px}
|
|
140
|
+
.qfield{display:grid;gap:5px;margin:0 0 12px}
|
|
141
|
+
.qfield span{font:600 12.5px var(--sans)}
|
|
142
|
+
.qfield input{background:var(--bg-elev);border:1px solid var(--border-strong);color:var(--ink);
|
|
143
|
+
border-radius:var(--r);padding:8px 10px;font:13px var(--sans)}
|
|
144
|
+
.modes{display:inline-flex;gap:2px;padding:3px;margin:0 0 14px;background:var(--surface-2);
|
|
145
|
+
border-radius:var(--r);flex-wrap:wrap}
|
|
146
|
+
.mode{border:0;background:none;color:var(--ink-dim);font:600 12.5px var(--sans);padding:6px 12px;
|
|
147
|
+
border-radius:calc(var(--r) - 2px);cursor:pointer}
|
|
148
|
+
.mode:hover{color:var(--ink)}
|
|
149
|
+
.mode.on{background:var(--bg-elev);color:var(--ink);box-shadow:var(--shadow-sm)}
|
|
150
|
+
.modes + div > .paste{border-top:0;margin-top:0;padding-top:0}
|
|
@@ -6,24 +6,30 @@
|
|
|
6
6
|
import { getCredential, storeCredential } from "../api.js";
|
|
7
7
|
import { el } from "../dom.js";
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
box
|
|
9
|
+
/** @param opts.bare drawn inside a step that already has a title. `onStatus(done)` hears whether one is stored. */
|
|
10
|
+
export function credentialPanel(opts = {}) {
|
|
11
|
+
const box = el("div", { className: opts.bare ? "" : "manual" });
|
|
12
|
+
if (!opts.bare) box.append(el("b", { textContent: "Agent credential" }));
|
|
12
13
|
const body = el("div");
|
|
13
14
|
box.append(body);
|
|
14
15
|
body.append(el("p", { className: "sub", textContent: "Looking at where it would go…" }));
|
|
15
16
|
|
|
16
17
|
getCredential()
|
|
17
|
-
.then((c) =>
|
|
18
|
+
.then((c) => {
|
|
19
|
+
opts.onStatus?.(Boolean(c.orgSecret));
|
|
20
|
+
draw(body, c, opts);
|
|
21
|
+
})
|
|
18
22
|
.catch((err) => body.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) })));
|
|
19
23
|
return box;
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
function draw(body, c) {
|
|
26
|
+
function draw(body, c, opts = {}) {
|
|
23
27
|
body.replaceChildren();
|
|
24
28
|
if (c.howTo) body.append(el("p", { textContent: "Where to get one: " + c.howTo }));
|
|
25
29
|
|
|
26
|
-
if (!c.brains.length) {
|
|
30
|
+
if (!(c.brains ?? []).length) {
|
|
31
|
+
// Inside a step, the step's own hint already says when this opens.
|
|
32
|
+
if (opts.bare) return;
|
|
27
33
|
body.append(
|
|
28
34
|
el("p", {
|
|
29
35
|
className: "sub",
|
|
@@ -73,6 +79,7 @@ function draw(body, c) {
|
|
|
73
79
|
const r = await storeCredential(value, false);
|
|
74
80
|
input.value = "";
|
|
75
81
|
if (!r.ok) throw new Error(r.error);
|
|
82
|
+
opts.onStatus?.(true);
|
|
76
83
|
out.replaceChildren(
|
|
77
84
|
...(r.fellBack ? [el("p", { className: "err", textContent: "The org secret was refused: " + r.fellBack })] : []),
|
|
78
85
|
el("p", {
|
|
@@ -15,7 +15,7 @@ import { unifiedDiff, diffStat } from "../textdiff.js";
|
|
|
15
15
|
*/
|
|
16
16
|
export function paste(opts) {
|
|
17
17
|
const box = el("div", { className: "paste" });
|
|
18
|
-
box.append(el("h3", { textContent: opts.title ?? "Write it with your own AI" }));
|
|
18
|
+
if (opts.title !== "") box.append(el("h3", { textContent: opts.title ?? "Write it with your own AI" }));
|
|
19
19
|
|
|
20
20
|
const status = el("p", { className: "sub", textContent: "Building the prompt…" });
|
|
21
21
|
box.append(status);
|
|
@@ -23,7 +23,7 @@ export function paste(opts) {
|
|
|
23
23
|
/* A charter brief carries a worked example to model the shape on, matched to the role. The
|
|
24
24
|
choice is on the page because the match is a guess, and "none" is a fair answer. */
|
|
25
25
|
const model = el("select", { hidden: true });
|
|
26
|
-
const modelRow = el("label", { className: "sub", hidden: true }, ["
|
|
26
|
+
const modelRow = el("label", { className: "sub", hidden: true }, ["Example to follow: ", model]);
|
|
27
27
|
box.append(modelRow);
|
|
28
28
|
|
|
29
29
|
const actions = el("div", { className: "row" });
|
|
@@ -38,8 +38,7 @@ export function paste(opts) {
|
|
|
38
38
|
const answer = el("textarea", {
|
|
39
39
|
className: "pastebox",
|
|
40
40
|
placeholder:
|
|
41
|
-
"Paste
|
|
42
|
-
"Only the part between the sentinels is used.",
|
|
41
|
+
"Paste its whole reply here.",
|
|
43
42
|
});
|
|
44
43
|
answer.rows = 8;
|
|
45
44
|
|
|
@@ -76,11 +75,9 @@ export function paste(opts) {
|
|
|
76
75
|
model.hidden = modelRow.hidden = false;
|
|
77
76
|
}
|
|
78
77
|
status.textContent =
|
|
79
|
-
"
|
|
80
|
-
"so there is nothing to attach and nothing for it to ask you for. It will interview you " +
|
|
81
|
-
"first, then hand back the finished file.";
|
|
78
|
+
"Copy the prompt into Claude or ChatGPT and answer its questions.";
|
|
82
79
|
box.append(
|
|
83
|
-
el("p", { className: "sub", textContent: "Then paste its
|
|
80
|
+
el("p", { className: "sub", textContent: "Then paste its reply here:" }),
|
|
84
81
|
answer,
|
|
85
82
|
el("div", { className: "row" }, [check]),
|
|
86
83
|
result,
|
|
@@ -13,14 +13,16 @@ import { el } from "../dom.js";
|
|
|
13
13
|
* org.yaml already lists is read off disk, which is how the Org screen uses it long after setup.
|
|
14
14
|
*/
|
|
15
15
|
export function repoPicker(opts) {
|
|
16
|
-
const box = el("div", { className: "manual" });
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
const box = el("div", { className: opts.bare ? "" : "manual" });
|
|
17
|
+
if (!opts.bare) {
|
|
18
|
+
box.append(
|
|
19
|
+
el("b", { textContent: "Which repos the staff work in" }),
|
|
20
|
+
el("p", {
|
|
21
|
+
textContent:
|
|
22
|
+
"Add the repos your staff should work on. New hires get access to them automatically.",
|
|
23
|
+
}),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
24
26
|
|
|
25
27
|
const out = el("div", { style: "margin-top:9px" });
|
|
26
28
|
box.append(out);
|
|
@@ -61,27 +61,10 @@ export async function viewGettingStarted(main) {
|
|
|
61
61
|
S = await getSetup();
|
|
62
62
|
// Somebody may have clicked elsewhere while GitHub was answering.
|
|
63
63
|
if (App.view !== "setup") return;
|
|
64
|
-
sub.textContent =
|
|
65
|
-
|
|
66
|
-
"reads the world rather than remembering a click, so it is safe to leave and come back.";
|
|
67
|
-
|
|
68
|
-
if (!App.data?.staff?.length) {
|
|
69
|
-
const next = step(1, "Hire your first staff member", false);
|
|
70
|
-
const hire = el("button", { className: "btn primary", textContent: "Hire someone" });
|
|
71
|
-
hire.onclick = () => go({ view: "staff" });
|
|
72
|
-
next.append(
|
|
73
|
-
el("p", {
|
|
74
|
-
textContent:
|
|
75
|
-
"Nothing runs until you hire someone. You'll set up their GitHub App and charter from their card.",
|
|
76
|
-
}),
|
|
77
|
-
el("div", { className: "row" }, [hire]),
|
|
78
|
-
);
|
|
79
|
-
main.append(next);
|
|
80
|
-
}
|
|
64
|
+
sub.textContent = "Work through these in order.";
|
|
65
|
+
main.append(afterCreate());
|
|
81
66
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const health = step(App.data?.staff?.length ? 2 : 3, "Other problems", false);
|
|
67
|
+
const health = step("!", "Other problems", false);
|
|
85
68
|
healthHost = el("div", { style: "margin-top:12px" });
|
|
86
69
|
health.append(healthHost);
|
|
87
70
|
main.append(health);
|
|
@@ -136,7 +119,11 @@ function render(main) {
|
|
|
136
119
|
|
|
137
120
|
/* Drawn whenever a tenant exists, not only in the seconds after creating one. Setup takes
|
|
138
121
|
days: the repo picker and the two manual steps have to still be here tomorrow. */
|
|
139
|
-
if (S.tenant.found)
|
|
122
|
+
if (S.tenant.found) {
|
|
123
|
+
const rest = step(3, "Finish setting up", false);
|
|
124
|
+
rest.append(afterCreate());
|
|
125
|
+
main.append(rest);
|
|
126
|
+
}
|
|
140
127
|
}
|
|
141
128
|
|
|
142
129
|
/* ---------------------------------- 1 · gh ---------------------------------- */
|
|
@@ -389,33 +376,95 @@ function stepOrg(main) {
|
|
|
389
376
|
|
|
390
377
|
/* ------------------------- 5 · the two GitHub insists on ------------------------- */
|
|
391
378
|
|
|
392
|
-
function afterCreate(
|
|
379
|
+
function afterCreate() {
|
|
393
380
|
fileSteps.clear();
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
381
|
+
const list = el("div", { className: "todos" });
|
|
382
|
+
const left = S.tenant.left ?? [];
|
|
383
|
+
const staffCount = App.data?.staff?.length ?? 0;
|
|
384
|
+
let n = 0;
|
|
397
385
|
|
|
398
386
|
if (S.tenant.org) {
|
|
399
|
-
|
|
387
|
+
const repos = todo(++n, "Choose the repos your staff work on", (S.tenant.products ?? []).length > 0);
|
|
388
|
+
repos.hint("Pick at least one. Staff open pull requests there, and you review them.");
|
|
389
|
+
repos.body.append(
|
|
390
|
+
repoPicker({ org: S.tenant.org, known: S.tenant.repos ?? [], bare: true, onAdded: () => repos.setDone(true) }),
|
|
391
|
+
);
|
|
392
|
+
list.append(repos.card);
|
|
400
393
|
}
|
|
401
394
|
|
|
402
|
-
const business =
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
);
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
);
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
395
|
+
const business = todo(++n, "Describe the business", !left.includes("business"));
|
|
396
|
+
business.hint("A few short answers. Every staff member reads them before they start work.");
|
|
397
|
+
business.body.append(businessForm(() => business.setDone(true)));
|
|
398
|
+
fileSteps.set("business", business.setDone);
|
|
399
|
+
list.append(business.card);
|
|
400
|
+
|
|
401
|
+
const priorities = todo(++n, "Set this month's priorities", !left.includes("priorities"));
|
|
402
|
+
priorities.hint("Up to three, in order. Staff pick work that serves them.");
|
|
403
|
+
priorities.body.append(prioritiesForm(() => priorities.setDone(true)));
|
|
404
|
+
fileSteps.set("priorities", priorities.setDone);
|
|
405
|
+
list.append(priorities.card);
|
|
406
|
+
|
|
407
|
+
const hire = todo(++n, "Hire your first staff member", staffCount > 0);
|
|
408
|
+
hire.hint("Give them a role, like cto. You'll create their GitHub App and write their charter from their card.");
|
|
409
|
+
const hireBtn = el("button", { className: "btn primary", textContent: "Hire someone" });
|
|
410
|
+
hireBtn.onclick = () => {
|
|
411
|
+
// Before the org has loaded, this screen is the whole app, so the Staff screen needs a reload.
|
|
412
|
+
if (App.data) go({ view: "staff" });
|
|
413
|
+
else location.assign("/#/-/staff") || location.reload();
|
|
414
|
+
};
|
|
415
|
+
hire.body.append(el("div", { className: "row" }, [hireBtn]));
|
|
416
|
+
list.append(hire.card);
|
|
417
|
+
|
|
418
|
+
const cred = todo(++n, "Add your agent credential", false);
|
|
419
|
+
cred.hint(staffCount ? "Staff use it to run. You only add it once." : "Do this after your first hire.");
|
|
420
|
+
cred.body.append(credentialPanel({ bare: true, onStatus: (ok) => cred.setDone(ok) }));
|
|
421
|
+
list.append(cred.card);
|
|
422
|
+
|
|
423
|
+
const access = todo(++n, "Let staff repos use the shared workflow", false);
|
|
424
|
+
access.body.append(accessStep({ onStatus: (ok) => access.setDone(ok) }));
|
|
425
|
+
list.append(access.card);
|
|
426
|
+
|
|
427
|
+
return list;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* One numbered thing to do. Done ones fold to a single ticked line, and Change opens them again,
|
|
432
|
+
* so the list shows what is left without hiding what was done.
|
|
433
|
+
*/
|
|
434
|
+
function todo(n, title, done) {
|
|
435
|
+
const card = el("section", { className: "todo" });
|
|
436
|
+
const num = el("span", { className: "todon" });
|
|
437
|
+
const pill = el("span", { className: "todopill" });
|
|
438
|
+
const change = el("button", { className: "ghbtn todochange", textContent: "Change" });
|
|
439
|
+
const head = el("div", { className: "todohead" }, [num, el("h3", { textContent: title }), pill, change]);
|
|
440
|
+
const hintEl = el("p", { className: "sub todohint" });
|
|
441
|
+
const body = el("div", { className: "todobody" });
|
|
442
|
+
card.append(head, hintEl, body);
|
|
443
|
+
let open = false;
|
|
444
|
+
const setDone = (d) => {
|
|
445
|
+
card.classList.toggle("done", d);
|
|
446
|
+
num.textContent = d ? "✓" : String(n);
|
|
447
|
+
pill.textContent = d ? "Done" : "To do";
|
|
448
|
+
change.hidden = !d;
|
|
449
|
+
const shown = !d || open;
|
|
450
|
+
body.hidden = !shown;
|
|
451
|
+
hintEl.hidden = !shown || !hintEl.textContent;
|
|
452
|
+
};
|
|
453
|
+
change.onclick = () => {
|
|
454
|
+
open = !open;
|
|
455
|
+
change.textContent = open ? "Close" : "Change";
|
|
456
|
+
setDone(card.classList.contains("done"));
|
|
457
|
+
};
|
|
458
|
+
setDone(done);
|
|
459
|
+
return {
|
|
460
|
+
card,
|
|
461
|
+
body,
|
|
462
|
+
setDone,
|
|
463
|
+
hint: (text) => {
|
|
464
|
+
hintEl.textContent = text;
|
|
465
|
+
setDone(card.classList.contains("done"));
|
|
466
|
+
},
|
|
467
|
+
};
|
|
419
468
|
}
|
|
420
469
|
|
|
421
470
|
/**
|
|
@@ -424,18 +473,6 @@ function afterCreate(n) {
|
|
|
424
473
|
* Read from the status rather than from the click that saved it, so a file written in an
|
|
425
474
|
* editor, or by a coding agent, shows as done here too.
|
|
426
475
|
*/
|
|
427
|
-
function fileStep(id, title, why) {
|
|
428
|
-
const box = el("div", { className: "manual" });
|
|
429
|
-
const tag = el("span", { className: "meta" });
|
|
430
|
-
const mark = (done) => {
|
|
431
|
-
tag.textContent = done ? "Written." : "Still the stub it shipped as.";
|
|
432
|
-
box.classList[done ? "add" : "remove"]("done");
|
|
433
|
-
};
|
|
434
|
-
mark(!(S.tenant.left ?? []).includes(id));
|
|
435
|
-
fileSteps.set(id, mark);
|
|
436
|
-
box.append(el("div", {}, [el("b", { textContent: title }), tag]), el("p", { textContent: why }));
|
|
437
|
-
return box;
|
|
438
|
-
}
|
|
439
476
|
|
|
440
477
|
/**
|
|
441
478
|
* org/priorities.md, edited in place.
|
|
@@ -445,55 +482,156 @@ function fileStep(id, title, why) {
|
|
|
445
482
|
* quicker to type than to explain to a model. So the file opens as it is on disk, stub and
|
|
446
483
|
* all, and saving it is the same commit-and-push as every other editor here.
|
|
447
484
|
*/
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
485
|
+
const BUSINESS_QUESTIONS = [
|
|
486
|
+
["line", "What does it do, in one line?", "What this business does, in one line", true],
|
|
487
|
+
["who", "Who is it for?", "Who the customers are, specifically", true],
|
|
488
|
+
["key", "What does everything depend on?", "The one fact everything else follows from", false],
|
|
489
|
+
["true", "What's already true? What's built, measured or tried?", "What is already true", false],
|
|
490
|
+
["not", "What should staff never decide on their own?", "What is not ours to decide", false],
|
|
491
|
+
];
|
|
492
|
+
|
|
493
|
+
/** The business, as five questions in plain fields, written out as org/business.md. */
|
|
494
|
+
function businessForm(onSaved) {
|
|
495
|
+
const form = el("div");
|
|
496
|
+
const path = opsName() + "/org/business.md";
|
|
497
|
+
const fields = BUSINESS_QUESTIONS.map(([id, label, , required]) => {
|
|
498
|
+
const input = id === "line" ? el("input", { type: "text" }) : el("textarea", { className: "pastebox", rows: 3 });
|
|
499
|
+
const wrap = el("label", { className: "qfield" }, [
|
|
500
|
+
el("span", { textContent: label + (required ? "" : " (optional)") }),
|
|
501
|
+
input,
|
|
502
|
+
]);
|
|
503
|
+
return { id, input, wrap };
|
|
504
|
+
});
|
|
505
|
+
const save = el("button", { className: "btn primary", textContent: "Save" });
|
|
454
506
|
const out = el("div");
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
let before = "";
|
|
458
|
-
save.disabled = true;
|
|
459
|
-
// Not getFile: an absent file answers 404 with a message, and that is not what to edit.
|
|
460
|
-
fetch(fileUrl(path), { cache: "no-store" })
|
|
461
|
-
.then((r) => (r.ok ? r.text() : ""))
|
|
462
|
-
.then((text) => {
|
|
463
|
-
before = text;
|
|
464
|
-
ta.value = text;
|
|
465
|
-
grow(ta);
|
|
466
|
-
save.disabled = false;
|
|
467
|
-
})
|
|
468
|
-
.catch((err) => out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) })));
|
|
469
|
-
ta.addEventListener("input", () => grow(ta));
|
|
507
|
+
form.append(...fields.map((f) => f.wrap), el("div", { className: "row" }, [save]), out);
|
|
470
508
|
|
|
471
509
|
save.onclick = async () => {
|
|
472
|
-
|
|
473
|
-
|
|
510
|
+
const missing = fields.find((f, i) => BUSINESS_QUESTIONS[i][3] && !String(f.input.value ?? "").trim());
|
|
511
|
+
if (missing) {
|
|
512
|
+
missing.input.focus();
|
|
513
|
+
out.replaceChildren(el("p", { className: "err", textContent: "Answer the first two at least." }));
|
|
474
514
|
return;
|
|
475
515
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
|
|
516
|
+
const name = App.data?.name ?? S.tenant.name ?? S.tenant.org;
|
|
517
|
+
const text =
|
|
518
|
+
`# What ${name} is\n\n` +
|
|
519
|
+
fields
|
|
520
|
+
.map((f, i) => [BUSINESS_QUESTIONS[i][2], String(f.input.value ?? "").trim()])
|
|
521
|
+
.filter(([, v]) => v)
|
|
522
|
+
.map(([h, v]) => `## ${h}\n\n${v}\n`)
|
|
523
|
+
.join("\n");
|
|
524
|
+
await saveAndSay(save, out, path, text, "portal: write org/business.md", onSaved);
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
return modes([
|
|
528
|
+
{ label: "Answer questions", node: form },
|
|
529
|
+
{
|
|
530
|
+
label: "Let an AI interview you",
|
|
531
|
+
node: paste({ kind: "discover", title: "", onSaved: () => (onSaved(), recheck()) }),
|
|
532
|
+
},
|
|
533
|
+
rawMode(path, onSaved),
|
|
534
|
+
]);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** This month's priorities, as three lines and an out-of-scope list. */
|
|
538
|
+
function prioritiesForm(onSaved) {
|
|
539
|
+
const form = el("div");
|
|
540
|
+
const path = opsName() + "/org/priorities.md";
|
|
541
|
+
const ranks = [1, 2, 3].map((i) => {
|
|
542
|
+
const input = el("input", { type: "text", placeholder: i === 1 ? "e.g. Tasks can be ticked off and removed" : "" });
|
|
543
|
+
return { input, wrap: el("label", { className: "qfield" }, [el("span", { textContent: "Priority " + i + (i > 1 ? " (optional)" : "") }), input]) };
|
|
544
|
+
});
|
|
545
|
+
const scope = el("textarea", { className: "pastebox", rows: 3, placeholder: "One per line" });
|
|
546
|
+
const save = el("button", { className: "btn primary", textContent: "Save" });
|
|
547
|
+
const out = el("div");
|
|
548
|
+
form.append(
|
|
549
|
+
...ranks.map((r) => r.wrap),
|
|
550
|
+
el("label", { className: "qfield" }, [el("span", { textContent: "Out of scope this month (optional)" }), scope]),
|
|
551
|
+
el("div", { className: "row" }, [save]),
|
|
552
|
+
out,
|
|
553
|
+
);
|
|
554
|
+
save.onclick = async () => {
|
|
555
|
+
const items = ranks.map((r) => String(r.input.value ?? "").trim()).filter(Boolean);
|
|
556
|
+
if (!items.length) {
|
|
557
|
+
ranks[0].input.focus();
|
|
558
|
+
out.replaceChildren(el("p", { className: "err", textContent: "Add at least one priority." }));
|
|
559
|
+
return;
|
|
495
560
|
}
|
|
561
|
+
const outs = String(scope.value ?? "").split("\n").map((l) => l.replace(/^[-*]\s*/, "").trim()).filter(Boolean);
|
|
562
|
+
const text =
|
|
563
|
+
"## What matters this month\n\n### Priorities, in order\n\n" +
|
|
564
|
+
items.map((t, i) => `${i + 1}. ${t}`).join("\n") +
|
|
565
|
+
"\n" +
|
|
566
|
+
(outs.length ? "\n### Out of scope this month\n\n" + outs.map((t) => `- ${t}`).join("\n") + "\n" : "");
|
|
567
|
+
await saveAndSay(save, out, path, text, "portal: write org/priorities.md", onSaved);
|
|
496
568
|
};
|
|
569
|
+
return modes([{ label: "Fill in", node: form }, rawMode(path, onSaved)]);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async function saveAndSay(btn, out, path, text, message, onSaved) {
|
|
573
|
+
btn.disabled = true;
|
|
574
|
+
const label = btn.textContent;
|
|
575
|
+
btn.textContent = "Saving…";
|
|
576
|
+
try {
|
|
577
|
+
const r = await saveFile(path, text, message);
|
|
578
|
+
out.replaceChildren(
|
|
579
|
+
el("p", {
|
|
580
|
+
className: r.pushed ? "sub" : "err",
|
|
581
|
+
textContent: r.pushed ? "Saved." : "Saved locally, but the push failed: " + (r.note ?? ""),
|
|
582
|
+
}),
|
|
583
|
+
);
|
|
584
|
+
onSaved();
|
|
585
|
+
recheck();
|
|
586
|
+
} catch (err) {
|
|
587
|
+
out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
|
|
588
|
+
} finally {
|
|
589
|
+
btn.disabled = false;
|
|
590
|
+
btn.textContent = label;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** The file itself, for editing what is already there rather than starting again. */
|
|
595
|
+
function rawMode(path, onSaved) {
|
|
596
|
+
const node = el("div");
|
|
597
|
+
const ta = el("textarea", { className: "pastebox" });
|
|
598
|
+
ta.rows = 12;
|
|
599
|
+
const save = el("button", { className: "btn primary", textContent: "Save" });
|
|
600
|
+
const out = el("div");
|
|
601
|
+
node.append(ta, el("div", { className: "row" }, [save]), out);
|
|
602
|
+
ta.addEventListener("input", () => grow(ta));
|
|
603
|
+
save.onclick = () =>
|
|
604
|
+
saveAndSay(save, out, path, ta.value, "portal: edit " + path.split("/").slice(1).join("/"), onSaved);
|
|
605
|
+
// Read when first shown, so it shows the file as it is then, not as it was at page load.
|
|
606
|
+
const load = () =>
|
|
607
|
+
// Not getFile: an absent file answers 404 with a message, and that is not what to edit.
|
|
608
|
+
fetch(fileUrl(path), { cache: "no-store" })
|
|
609
|
+
.then((r) => (r.ok ? r.text() : ""))
|
|
610
|
+
.then((text) => {
|
|
611
|
+
ta.value = text;
|
|
612
|
+
grow(ta);
|
|
613
|
+
});
|
|
614
|
+
return { label: "Edit the file", node, onShow: load };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Ways to do one step, as a switcher across the top: one shown at a time. */
|
|
618
|
+
function modes(list) {
|
|
619
|
+
const box = el("div");
|
|
620
|
+
const bar = el("div", { className: "modes" });
|
|
621
|
+
const panes = el("div");
|
|
622
|
+
const buttons = list.map((m, i) => {
|
|
623
|
+
const b = el("button", { className: "mode", textContent: m.label });
|
|
624
|
+
b.onclick = () => show(i);
|
|
625
|
+
return b;
|
|
626
|
+
});
|
|
627
|
+
bar.append(...buttons);
|
|
628
|
+
box.append(bar, panes);
|
|
629
|
+
function show(i) {
|
|
630
|
+
buttons.forEach((b, j) => b.classList.toggle("on", i === j));
|
|
631
|
+
panes.replaceChildren(list[i].node);
|
|
632
|
+
list[i].onShow?.();
|
|
633
|
+
}
|
|
634
|
+
show(0);
|
|
497
635
|
return box;
|
|
498
636
|
}
|
|
499
637
|
|
|
@@ -509,9 +647,8 @@ function opsName() {
|
|
|
509
647
|
*
|
|
510
648
|
* Skipped, every workflow fails with "workflow not found", which reads like a typo in a path.
|
|
511
649
|
*/
|
|
512
|
-
function accessStep() {
|
|
513
|
-
const box = el("div"
|
|
514
|
-
box.append(el("b", { textContent: "Let staff repos run the shared workflow" }));
|
|
650
|
+
function accessStep(opts = {}) {
|
|
651
|
+
const box = el("div");
|
|
515
652
|
const out = el("div");
|
|
516
653
|
box.append(out);
|
|
517
654
|
const say = (text, cls) => el("p", { className: cls ?? "sub", textContent: text });
|
|
@@ -521,7 +658,7 @@ function accessStep() {
|
|
|
521
658
|
out.replaceChildren(
|
|
522
659
|
say(
|
|
523
660
|
(why ? why + " " : "") +
|
|
524
|
-
"
|
|
661
|
+
"Roster tried to set this when it created the org, and GitHub didn't allow it.",
|
|
525
662
|
),
|
|
526
663
|
el("div", { className: "row" }, [go]),
|
|
527
664
|
);
|
|
@@ -531,6 +668,7 @@ function accessStep() {
|
|
|
531
668
|
const r = await setAccess();
|
|
532
669
|
if (r.ok) {
|
|
533
670
|
out.replaceChildren(say("Done."));
|
|
671
|
+
opts.onStatus?.(true);
|
|
534
672
|
return;
|
|
535
673
|
}
|
|
536
674
|
out.replaceChildren(
|
|
@@ -548,6 +686,7 @@ function accessStep() {
|
|
|
548
686
|
out.replaceChildren(say("Checking…"));
|
|
549
687
|
getAccess()
|
|
550
688
|
.then((r) => {
|
|
689
|
+
opts.onStatus?.(Boolean(r.ok));
|
|
551
690
|
if (r.ok) out.replaceChildren(say("Done."));
|
|
552
691
|
else offer(r.level ? "It is set to “" + r.level + "”." : "");
|
|
553
692
|
})
|