@nanocollective/roster 0.1.0-alpha.13 → 0.1.0-alpha.14

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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanocollective/roster",
3
- "version": "0.1.0-alpha.13",
3
+ "version": "0.1.0-alpha.14",
4
4
  "description": "An agent-run org, powered by GitHub. Scaffold AI staff members whose brain is a repo.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -121,3 +121,25 @@
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
+ .altway{margin-top:14px}
145
+ .altway summary{cursor:pointer;color:var(--ink-dim);font-size:12.5px}
@@ -6,24 +6,30 @@
6
6
  import { getCredential, storeCredential } from "../api.js";
7
7
  import { el } from "../dom.js";
8
8
 
9
- export function credentialPanel() {
10
- const box = el("div", { className: "manual" });
11
- box.append(el("b", { textContent: "Agent credential" }));
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) => draw(body, 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 }, ["Model it on: ", model]);
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 the whole reply here, including anything your AI said around the file. " +
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
- "Paste this into Claude, ChatGPT or anything else. It carries every file it refers to, " +
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 whole reply back here:" }),
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
- box.append(
18
- el("b", { textContent: "Which repos the staff work in" }),
19
- el("p", {
20
- textContent:
21
- "Add the repos your staff should work on. New hires get access to them automatically.",
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
- "What is still to do before " + (App.data?.name ?? "this org") + " runs well. Each step " +
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
- }
81
-
82
- main.append(afterCreate(App.data?.staff?.length ? 1 : 2));
64
+ sub.textContent = "Work through these in order.";
65
+ main.append(afterCreate());
83
66
 
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) main.append(afterCreate(3));
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(n) {
379
+ function afterCreate() {
393
380
  fileSteps.clear();
394
- const card = step(n, "What is left", false);
395
- card.append(accessStep());
396
- card.append(credentialPanel());
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
- card.append(repoPicker({ org: S.tenant.org, known: S.tenant.repos ?? [] }));
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 = fileStep(
403
- "business",
404
- "Describe the business",
405
- "Answer the questions in org/business.md. Every staff member reads it before they start work.",
406
- );
407
- // The copy-a-prompt loop, which is the whole answer to "how do I write this file".
408
- business.append(paste({ kind: "discover", title: "Write it with your own AI", onSaved: recheck }));
409
- card.append(business);
410
-
411
- const priorities = fileStep(
412
- "priorities",
413
- "Set this month's priorities",
414
- "Up to three things, in order, plus what's out of scope. Saved as org/priorities.md.",
415
- );
416
- priorities.append(prioritiesEditor());
417
- card.append(priorities);
418
- return card;
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,58 +482,143 @@ 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
- function prioritiesEditor() {
449
- const box = el("div", { style: "margin-top:9px" });
450
- const path = opsName() + "/org/priorities.md";
451
- const ta = el("textarea", { className: "pastebox" });
452
- ta.rows = 12;
453
- const save = el("button", { className: "btn primary", textContent: "Save and commit" });
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 box = 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
- box.append(ta, el("div", { className: "row", style: "margin-top:9px" }, [save]), out);
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
+ box.append(...fields.map((f) => f.wrap), el("div", { className: "row" }, [save]), out);
470
508
 
471
509
  save.onclick = async () => {
472
- if (ta.value.trim() === before.trim()) {
473
- out.replaceChildren(el("p", { className: "sub", textContent: "Nothing changed yet." }));
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
- save.disabled = true;
477
- save.textContent = "Saving…";
478
- try {
479
- const r = await saveFile(path, ta.value, "portal: write org/priorities.md");
480
- before = ta.value;
481
- out.replaceChildren(
482
- el("p", {
483
- className: r.pushed ? "sub" : "err",
484
- textContent: r.pushed
485
- ? "Saved and pushed."
486
- : "Committed, but the push failed: " + (r.note ?? ""),
487
- }),
488
- );
489
- recheck();
490
- } catch (err) {
491
- out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
492
- } finally {
493
- save.disabled = false;
494
- save.textContent = "Save and commit";
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
+ // For somebody who would rather be interviewed than fill in a form.
528
+ box.append(
529
+ el("details", { className: "altway" }, [
530
+ el("summary", { textContent: "Or let an AI interview you instead" }),
531
+ paste({ kind: "discover", title: "", onSaved: () => (onSaved(), recheck()) }),
532
+ ]),
533
+ );
534
+ box.append(changeRaw(path, onSaved));
535
+ return box;
536
+ }
537
+
538
+ /** This month's priorities, as three lines and an out-of-scope list. */
539
+ function prioritiesForm(onSaved) {
540
+ const box = el("div");
541
+ const path = opsName() + "/org/priorities.md";
542
+ const ranks = [1, 2, 3].map((i) => {
543
+ const input = el("input", { type: "text", placeholder: i === 1 ? "e.g. Tasks can be ticked off and removed" : "" });
544
+ return { input, wrap: el("label", { className: "qfield" }, [el("span", { textContent: "Priority " + i + (i > 1 ? " (optional)" : "") }), input]) };
545
+ });
546
+ const scope = el("textarea", { className: "pastebox", rows: 3, placeholder: "One per line" });
547
+ const save = el("button", { className: "btn primary", textContent: "Save" });
548
+ const out = el("div");
549
+ box.append(
550
+ ...ranks.map((r) => r.wrap),
551
+ el("label", { className: "qfield" }, [el("span", { textContent: "Out of scope this month (optional)" }), scope]),
552
+ el("div", { className: "row" }, [save]),
553
+ out,
554
+ );
555
+ save.onclick = async () => {
556
+ const items = ranks.map((r) => String(r.input.value ?? "").trim()).filter(Boolean);
557
+ if (!items.length) {
558
+ ranks[0].input.focus();
559
+ out.replaceChildren(el("p", { className: "err", textContent: "Add at least one priority." }));
560
+ return;
495
561
  }
562
+ const outs = String(scope.value ?? "").split("\n").map((l) => l.replace(/^[-*]\s*/, "").trim()).filter(Boolean);
563
+ const text =
564
+ "## What matters this month\n\n### Priorities, in order\n\n" +
565
+ items.map((t, i) => `${i + 1}. ${t}`).join("\n") +
566
+ "\n" +
567
+ (outs.length ? "\n### Out of scope this month\n\n" + outs.map((t) => `- ${t}`).join("\n") + "\n" : "");
568
+ await saveAndSay(save, out, path, text, "portal: write org/priorities.md", onSaved);
496
569
  };
570
+ box.append(changeRaw(path, onSaved));
497
571
  return box;
498
572
  }
499
573
 
574
+ async function saveAndSay(btn, out, path, text, message, onSaved) {
575
+ btn.disabled = true;
576
+ const label = btn.textContent;
577
+ btn.textContent = "Saving…";
578
+ try {
579
+ const r = await saveFile(path, text, message);
580
+ out.replaceChildren(
581
+ el("p", {
582
+ className: r.pushed ? "sub" : "err",
583
+ textContent: r.pushed ? "Saved." : "Saved locally, but the push failed: " + (r.note ?? ""),
584
+ }),
585
+ );
586
+ onSaved();
587
+ recheck();
588
+ } catch (err) {
589
+ out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
590
+ } finally {
591
+ btn.disabled = false;
592
+ btn.textContent = label;
593
+ }
594
+ }
595
+
596
+ /** The file itself, for editing what is already there rather than starting again. */
597
+ function changeRaw(path, onSaved) {
598
+ const details = el("details", { className: "altway" });
599
+ details.append(el("summary", { textContent: "Or edit the file directly" }));
600
+ let loaded = false;
601
+ details.addEventListener("toggle", () => {
602
+ if (!details.open || loaded) return;
603
+ loaded = true;
604
+ const ta = el("textarea", { className: "pastebox" });
605
+ ta.rows = 12;
606
+ const save = el("button", { className: "btn", textContent: "Save" });
607
+ const out = el("div");
608
+ details.append(ta, el("div", { className: "row" }, [save]), out);
609
+ // Not getFile: an absent file answers 404 with a message, and that is not what to edit.
610
+ fetch(fileUrl(path), { cache: "no-store" })
611
+ .then((r) => (r.ok ? r.text() : ""))
612
+ .then((text) => {
613
+ ta.value = text;
614
+ grow(ta);
615
+ });
616
+ ta.addEventListener("input", () => grow(ta));
617
+ save.onclick = () => saveAndSay(save, out, path, ta.value, "portal: edit " + path.split("/").slice(1).join("/"), onSaved);
618
+ });
619
+ return details;
620
+ }
621
+
500
622
  /** The ops repo's directory name, which is what every workspace-relative path starts with. */
501
623
  function opsName() {
502
624
  return App.data?.opsName ?? String(S.tenant.opsDir ?? "roster-ops").split("/").pop();
@@ -509,9 +631,8 @@ function opsName() {
509
631
  *
510
632
  * Skipped, every workflow fails with "workflow not found", which reads like a typo in a path.
511
633
  */
512
- function accessStep() {
513
- const box = el("div", { className: "manual" });
514
- box.append(el("b", { textContent: "Let staff repos run the shared workflow" }));
634
+ function accessStep(opts = {}) {
635
+ const box = el("div");
515
636
  const out = el("div");
516
637
  box.append(out);
517
638
  const say = (text, cls) => el("p", { className: cls ?? "sub", textContent: text });
@@ -521,7 +642,7 @@ function accessStep() {
521
642
  out.replaceChildren(
522
643
  say(
523
644
  (why ? why + " " : "") +
524
- "Needed so staff repos can run the shared workflow.",
645
+ "Roster tried to set this when it created the org, and GitHub didn't allow it.",
525
646
  ),
526
647
  el("div", { className: "row" }, [go]),
527
648
  );
@@ -531,6 +652,7 @@ function accessStep() {
531
652
  const r = await setAccess();
532
653
  if (r.ok) {
533
654
  out.replaceChildren(say("Done."));
655
+ opts.onStatus?.(true);
534
656
  return;
535
657
  }
536
658
  out.replaceChildren(
@@ -548,6 +670,7 @@ function accessStep() {
548
670
  out.replaceChildren(say("Checking…"));
549
671
  getAccess()
550
672
  .then((r) => {
673
+ opts.onStatus?.(Boolean(r.ok));
551
674
  if (r.ok) out.replaceChildren(say("Done."));
552
675
  else offer(r.level ? "It is set to “" + r.level + "”." : "");
553
676
  })