@nanocollective/roster 0.1.0-alpha.20 → 0.1.0-alpha.22
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/README.md +5 -0
- package/dist/cli.js +50 -11
- package/docs/commands.md +5 -0
- package/docs/getting-started.md +3 -0
- package/docs/upgrading.md +3 -3
- package/package.json +1 -1
- package/templates/portal/css/setup.css +10 -0
- package/templates/portal/js/views/orgedit.js +6 -2
- package/templates/portal/js/views/paste.js +39 -12
- package/templates/portal/js/views/staff.js +2 -6
package/README.md
CHANGED
|
@@ -33,6 +33,11 @@ first finished run, most of it writing what the business is and the staff member
|
|
|
33
33
|
Everything the portal does is also a command, on the same files. Nothing changes anything
|
|
34
34
|
without `--apply`.
|
|
35
35
|
|
|
36
|
+
**`roster` here means either of these.** With nothing installed, run commands through npx:
|
|
37
|
+
`npx @nanocollective/roster@latest upgrade`. Or install it once with
|
|
38
|
+
`npm install -g @nanocollective/roster` and type `roster upgrade`. When you run through npx,
|
|
39
|
+
the commands roster suggests are printed the npx way, so they can be pasted as they are.
|
|
40
|
+
|
|
36
41
|
```bash
|
|
37
42
|
roster init --org acme # ops repo, org layer, merge base
|
|
38
43
|
roster hire cto # scaffold a staff member: repo, workflows, labels, peers
|
package/dist/cli.js
CHANGED
|
@@ -5401,6 +5401,30 @@ function short3(e) {
|
|
|
5401
5401
|
return line.length > 200 ? `${line.slice(0, 199)}\u2026` : line;
|
|
5402
5402
|
}
|
|
5403
5403
|
|
|
5404
|
+
// src/lib/bin.ts
|
|
5405
|
+
var NPX = "npx @nanocollective/roster@latest";
|
|
5406
|
+
function bin(env = process.env, argv1 = process.argv[1] ?? "") {
|
|
5407
|
+
if (env.ROSTER_BIN) return env.ROSTER_BIN;
|
|
5408
|
+
if (env.npm_command === "exec" || /[\\/]_npx[\\/]/.test(argv1)) return NPX;
|
|
5409
|
+
return "roster";
|
|
5410
|
+
}
|
|
5411
|
+
var COMMAND = /\broster(?= (?:init|hire|app|credential|run|doctor|fix|portal|prompt|upgrade|retire|lint|brief|export|help)\b)/g;
|
|
5412
|
+
function withBin(text, name = bin()) {
|
|
5413
|
+
return name === "roster" ? text : text.replace(COMMAND, name);
|
|
5414
|
+
}
|
|
5415
|
+
function rewriteOutput(name = bin()) {
|
|
5416
|
+
const raw = process.stdout.write.bind(process.stdout);
|
|
5417
|
+
if (name === "roster") return (text) => void raw(text);
|
|
5418
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
5419
|
+
const write = stream.write.bind(stream);
|
|
5420
|
+
stream.write = ((chunk, ...rest) => write(
|
|
5421
|
+
typeof chunk === "string" ? withBin(chunk, name) : chunk,
|
|
5422
|
+
...rest
|
|
5423
|
+
));
|
|
5424
|
+
}
|
|
5425
|
+
return (text) => void raw(text);
|
|
5426
|
+
}
|
|
5427
|
+
|
|
5404
5428
|
// src/lib/browser.ts
|
|
5405
5429
|
import { spawn } from "child_process";
|
|
5406
5430
|
function shouldOpen(env, isTTY, noOpen) {
|
|
@@ -6415,7 +6439,7 @@ async function portalCommand(argv) {
|
|
|
6415
6439
|
const handleSetup = async (url, req, res) => {
|
|
6416
6440
|
const route = url.pathname.slice("/api/setup/".length);
|
|
6417
6441
|
if (route === "status") {
|
|
6418
|
-
json(res, { ...await setupStatus(startedIn), startedIn });
|
|
6442
|
+
json(res, { ...await setupStatus(startedIn), startedIn }, true);
|
|
6419
6443
|
return;
|
|
6420
6444
|
}
|
|
6421
6445
|
if (route === "app") {
|
|
@@ -6796,7 +6820,7 @@ async function portalCommand(argv) {
|
|
|
6796
6820
|
return;
|
|
6797
6821
|
}
|
|
6798
6822
|
if (url.pathname === "/api/doctor") {
|
|
6799
|
-
collect({ ops: ws?.opsDir, offline: url.searchParams.get("offline") === "1" }).then((report3) => json(res, report3 ?? { findings: [], online: false, empty: true })).catch((err) => {
|
|
6823
|
+
collect({ ops: ws?.opsDir, offline: url.searchParams.get("offline") === "1" }).then((report3) => json(res, report3 ?? { findings: [], online: false, empty: true }, true)).catch((err) => {
|
|
6800
6824
|
res.writeHead(200, { "content-type": "application/json" });
|
|
6801
6825
|
res.end(JSON.stringify({ findings: [], error: String(err?.message ?? err) }));
|
|
6802
6826
|
});
|
|
@@ -6877,7 +6901,7 @@ async function portalCommand(argv) {
|
|
|
6877
6901
|
throw new Error(`"${handle}" is not in org.yaml`);
|
|
6878
6902
|
}
|
|
6879
6903
|
const plan3 = buildRetirePlan(w, org, handle, parseYaml);
|
|
6880
|
-
json(res, { action, plan: plan3 });
|
|
6904
|
+
json(res, { action, plan: plan3 }, true);
|
|
6881
6905
|
return;
|
|
6882
6906
|
}
|
|
6883
6907
|
if (!/^[a-z][a-z0-9-]{1,20}$/.test(handle)) {
|
|
@@ -6889,10 +6913,11 @@ async function portalCommand(argv) {
|
|
|
6889
6913
|
const flags = hireFlags(url.searchParams);
|
|
6890
6914
|
const plan2 = buildPlan(w, org, handle, flags, parseYaml);
|
|
6891
6915
|
void readOrgSecret(org.org, plan2.staff.agentSecret).then(
|
|
6892
|
-
(orgSecret) => json(
|
|
6893
|
-
|
|
6894
|
-
plan: { ...plan2, orgSecret, files: [...plan2.files.keys()].sort() }
|
|
6895
|
-
|
|
6916
|
+
(orgSecret) => json(
|
|
6917
|
+
res,
|
|
6918
|
+
{ action, plan: { ...plan2, orgSecret, files: [...plan2.files.keys()].sort() } },
|
|
6919
|
+
true
|
|
6920
|
+
)
|
|
6896
6921
|
);
|
|
6897
6922
|
} catch (err) {
|
|
6898
6923
|
res.writeHead(400, { "content-type": "application/json" });
|
|
@@ -6966,7 +6991,7 @@ async function portalCommand(argv) {
|
|
|
6966
6991
|
return;
|
|
6967
6992
|
}
|
|
6968
6993
|
if (url.pathname === "/api/fix") {
|
|
6969
|
-
gather(w, url.searchParams.get("offline") === "1").then((items) => json(res, { items, text: fixBrief(w, items) })).catch((err) => {
|
|
6994
|
+
gather(w, url.searchParams.get("offline") === "1").then((items) => json(res, { items, text: fixBrief(w, items) }, true)).catch((err) => {
|
|
6970
6995
|
res.writeHead(200, { "content-type": "application/json" });
|
|
6971
6996
|
res.end(JSON.stringify({ items: [], error: String(err?.message ?? err) }));
|
|
6972
6997
|
});
|
|
@@ -7491,12 +7516,13 @@ function refuseWrite(res) {
|
|
|
7491
7516
|
res.writeHead(403, { "content-type": "application/json" });
|
|
7492
7517
|
res.end(JSON.stringify({ error: "write actions need a local POST from the portal" }));
|
|
7493
7518
|
}
|
|
7494
|
-
function json(res, data) {
|
|
7519
|
+
function json(res, data, advice = false) {
|
|
7495
7520
|
res.writeHead(200, {
|
|
7496
7521
|
"content-type": "application/json; charset=utf-8",
|
|
7497
7522
|
"cache-control": "no-store"
|
|
7498
7523
|
});
|
|
7499
|
-
|
|
7524
|
+
const text = JSON.stringify(data);
|
|
7525
|
+
res.end(advice ? withBin(text) : text);
|
|
7500
7526
|
}
|
|
7501
7527
|
function hireFlags(params) {
|
|
7502
7528
|
const num = (k) => params.get(k) ? Number(params.get(k)) : void 0;
|
|
@@ -7900,11 +7926,24 @@ var HELPS = {
|
|
|
7900
7926
|
export: exportHelp
|
|
7901
7927
|
};
|
|
7902
7928
|
async function main(argv) {
|
|
7929
|
+
const raw = rewriteOutput();
|
|
7903
7930
|
const [command, ...rest] = argv;
|
|
7904
7931
|
if (!command) return portalCommand([]);
|
|
7905
7932
|
if (command === "help" || command === "--help" || command === "-h") {
|
|
7906
7933
|
const topic = rest[0];
|
|
7907
|
-
|
|
7934
|
+
if (!topic || !HELPS[topic]) {
|
|
7935
|
+
const name = bin();
|
|
7936
|
+
if (name !== "roster") {
|
|
7937
|
+
raw(
|
|
7938
|
+
`
|
|
7939
|
+
Run these as ${name} <command>, or install it: npm install -g @nanocollective/roster
|
|
7940
|
+
`
|
|
7941
|
+
);
|
|
7942
|
+
}
|
|
7943
|
+
raw(HELP);
|
|
7944
|
+
return 0;
|
|
7945
|
+
}
|
|
7946
|
+
process.stdout.write(HELPS[topic]);
|
|
7908
7947
|
return 0;
|
|
7909
7948
|
}
|
|
7910
7949
|
const run6 = COMMANDS[command];
|
package/docs/commands.md
CHANGED
|
@@ -6,6 +6,11 @@ sidebar_order: 8
|
|
|
6
6
|
|
|
7
7
|
# Commands
|
|
8
8
|
|
|
9
|
+
**`roster` here means either of these.** With nothing installed, run commands through npx:
|
|
10
|
+
`npx @nanocollective/roster@latest upgrade`. Or install it once with
|
|
11
|
+
`npm install -g @nanocollective/roster` and type `roster upgrade`. When you run through npx,
|
|
12
|
+
the commands roster suggests are printed the npx way, so they can be pasted as they are.
|
|
13
|
+
|
|
9
14
|
Every command that changes anything prints a plan and changes nothing unless you pass
|
|
10
15
|
`--apply`. `lint`, `prompt`, `export`, `brief`, `doctor` and `fix` never change anything.
|
|
11
16
|
`portal` is the exception: it is interactive, and each change there is a button you press after
|
package/docs/getting-started.md
CHANGED
|
@@ -144,6 +144,9 @@ member on their next run.
|
|
|
144
144
|
|
|
145
145
|
The same road, command by command. Each prints its plan and changes nothing without `--apply`.
|
|
146
146
|
|
|
147
|
+
`roster` below means `npx @nanocollective/roster@latest`, unless you have installed it with
|
|
148
|
+
`npm install -g @nanocollective/roster`.
|
|
149
|
+
|
|
147
150
|
```bash
|
|
148
151
|
roster init --org acme --apply # the ops repo, and its Actions access
|
|
149
152
|
roster brief discover # a brief for org/business.md; write priorities.md too
|
package/docs/upgrading.md
CHANGED
|
@@ -10,9 +10,9 @@ The framework writes templates out. A tenant runs its own copies. So the two dri
|
|
|
10
10
|
`roster upgrade` is what reconciles them without eating your edits.
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
roster upgrade
|
|
14
|
-
roster upgrade --apply
|
|
15
|
-
roster upgrade --check
|
|
13
|
+
npx @nanocollective/roster@latest upgrade # what would change
|
|
14
|
+
npx @nanocollective/roster@latest upgrade --apply # do it
|
|
15
|
+
npx @nanocollective/roster@latest upgrade --check # exit non-zero if anything is pending (for CI)
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
## How it decides
|
package/package.json
CHANGED
|
@@ -148,3 +148,13 @@
|
|
|
148
148
|
.mode:hover{color:var(--ink)}
|
|
149
149
|
.mode.on{background:var(--bg-elev);color:var(--ink);box-shadow:var(--shadow-sm)}
|
|
150
150
|
.modes + div > .paste{border-top:0;margin-top:0;padding-top:0}
|
|
151
|
+
|
|
152
|
+
/* ---------- a checked paste ---------- */
|
|
153
|
+
.verdict{margin:0 0 10px;font:600 13.5px var(--sans)}
|
|
154
|
+
.verdict.ok{color:var(--accent)}
|
|
155
|
+
.verdict.bad{color:var(--err)}
|
|
156
|
+
.pastepreview{border:1px solid var(--line);border-radius:var(--r);padding:14px 16px;margin-bottom:12px;background:var(--bg)}
|
|
157
|
+
.pastenotes{margin:0 0 12px}
|
|
158
|
+
.pastenotes summary{cursor:pointer;font:600 12.5px var(--sans);color:var(--ink-dim);margin-bottom:6px}
|
|
159
|
+
.pastenotes .md{border-left:3px solid var(--line);padding-left:12px}
|
|
160
|
+
.pastefile .diff{white-space:pre-wrap;overflow-wrap:anywhere}
|
|
@@ -17,6 +17,10 @@ const BUSINESS_QUESTIONS = [
|
|
|
17
17
|
|
|
18
18
|
const val = (input) => String(input.value ?? "").trim();
|
|
19
19
|
|
|
20
|
+
/* A file still in the shape it shipped in holds the questions, not answers, and pre-filling the
|
|
21
|
+
fields with "Not a segment. The person…" reads as if somebody had already answered them. */
|
|
22
|
+
const isStub = (text) => /This file is a stub|Or answer these by hand/.test(text);
|
|
23
|
+
|
|
20
24
|
/**
|
|
21
25
|
* org/business.md as five questions. With `text`, the answers already in the file fill the
|
|
22
26
|
* fields, so changing one answer does not mean retyping the other four.
|
|
@@ -25,7 +29,7 @@ const val = (input) => String(input.value ?? "").trim();
|
|
|
25
29
|
*/
|
|
26
30
|
export function businessForm(o) {
|
|
27
31
|
const form = el("div");
|
|
28
|
-
const known = sections(o.text ?? "");
|
|
32
|
+
const known = isStub(o.text ?? "") ? new Map() : sections(o.text ?? "");
|
|
29
33
|
const fields = BUSINESS_QUESTIONS.map(([id, label, heading, required]) => {
|
|
30
34
|
const input = id === "line" ? el("input", { type: "text" }) : el("textarea", { className: "pastebox", rows: 3 });
|
|
31
35
|
const had = known.get(heading.toLowerCase());
|
|
@@ -64,7 +68,7 @@ export function businessForm(o) {
|
|
|
64
68
|
/** org/priorities.md as three lines and an out-of-scope list, filled from the file if it has them. */
|
|
65
69
|
export function prioritiesForm(o) {
|
|
66
70
|
const form = el("div");
|
|
67
|
-
const { items, outs } = parsePriorities(o.text ?? "");
|
|
71
|
+
const { items, outs } = isStub(o.text ?? "") ? { items: [], outs: [] } : parsePriorities(o.text ?? "");
|
|
68
72
|
const ranks = [1, 2, 3].map((i) => {
|
|
69
73
|
const input = el("input", { type: "text", placeholder: i === 1 ? "e.g. Tasks can be ticked off and removed" : "" });
|
|
70
74
|
if (items[i - 1]) input.value = items[i - 1];
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* Nothing here writes. The panel parses, shows a diff, and waits for a second click. */
|
|
8
8
|
|
|
9
9
|
import { getBrief, parsePaste, saveFile } from "../api.js";
|
|
10
|
+
import { mdlite } from "../md.js";
|
|
10
11
|
import { el, toClipboard } from "../dom.js";
|
|
11
12
|
import { unifiedDiff, diffStat } from "../textdiff.js";
|
|
12
13
|
|
|
@@ -123,23 +124,47 @@ export function paste(opts) {
|
|
|
123
124
|
result.append(note);
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
// What the AI said around the file: its caveats and "check these" are worth reading first.
|
|
128
|
+
const notes = String(answer.value ?? "")
|
|
129
|
+
.replace(/<<<ROSTER FILE [^>]+>>>[\s\S]*?<<<ROSTER END>>>/g, "")
|
|
130
|
+
.trim();
|
|
131
|
+
|
|
126
132
|
for (const file of data.files ?? []) {
|
|
127
|
-
if (file.unchanged) continue;
|
|
128
133
|
const card = el("div", { className: "pastefile" });
|
|
129
|
-
const
|
|
134
|
+
const name = file.path.split("/").slice(-2).join("/");
|
|
135
|
+
if (file.unchanged) {
|
|
136
|
+
card.append(el("p", { className: "verdict", textContent: "✓ Same as what's already in " + name + ". Nothing to save." }));
|
|
137
|
+
result.append(card);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
130
140
|
card.append(
|
|
131
|
-
el("
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
141
|
+
el("p", {
|
|
142
|
+
className: "verdict" + (file.writable ? " ok" : " bad"),
|
|
143
|
+
textContent: file.writable
|
|
144
|
+
? "✓ Looks good. Saving replaces " + name + " with this:"
|
|
145
|
+
: "✗ " + file.path + " can't be written from here.",
|
|
135
146
|
}),
|
|
136
147
|
);
|
|
137
|
-
|
|
148
|
+
card.append(el("div", { className: "md doc pastepreview", innerHTML: mdlite(file.text) }));
|
|
149
|
+
|
|
150
|
+
if (notes) {
|
|
151
|
+
const said = el("details", { className: "pastenotes", open: true });
|
|
152
|
+
said.append(el("summary", { textContent: "Your AI also said" }), el("div", { className: "md doc", innerHTML: mdlite(notes) }));
|
|
153
|
+
card.append(said);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const diff = el("pre", { className: "diff", hidden: true });
|
|
138
157
|
diff.textContent = unifiedDiff(file.before, file.text, file.path);
|
|
139
|
-
|
|
158
|
+
const stat = diffStat(file.before, file.text);
|
|
159
|
+
const toggle = el("button", { className: "ghbtn", textContent: `Show changes (+${stat.added} −${stat.removed})` });
|
|
160
|
+
toggle.onclick = () => {
|
|
161
|
+
diff.hidden = !diff.hidden;
|
|
162
|
+
toggle.textContent = (diff.hidden ? "Show changes" : "Hide changes") + ` (+${stat.added} −${stat.removed})`;
|
|
163
|
+
};
|
|
140
164
|
|
|
165
|
+
const row = el("div", { className: "row" });
|
|
141
166
|
if (file.writable) {
|
|
142
|
-
const save = el("button", { className: "btn primary", textContent: "Save
|
|
167
|
+
const save = el("button", { className: "btn primary", textContent: "Save" });
|
|
143
168
|
save.onclick = async () => {
|
|
144
169
|
save.disabled = true;
|
|
145
170
|
save.textContent = "Saving…";
|
|
@@ -150,17 +175,19 @@ export function paste(opts) {
|
|
|
150
175
|
opts.onSaved?.();
|
|
151
176
|
} catch (err) {
|
|
152
177
|
save.disabled = false;
|
|
153
|
-
save.textContent = "Save
|
|
178
|
+
save.textContent = "Save";
|
|
154
179
|
card.append(el("p", { className: "err", textContent: String(err.message || err) }));
|
|
155
180
|
}
|
|
156
181
|
};
|
|
157
|
-
|
|
182
|
+
row.append(save);
|
|
158
183
|
}
|
|
184
|
+
row.append(toggle);
|
|
185
|
+
card.append(row, diff);
|
|
159
186
|
result.append(card);
|
|
160
187
|
}
|
|
161
188
|
|
|
162
189
|
if (!(data.problems ?? []).length && !(data.files ?? []).length) {
|
|
163
|
-
result.append(el("p", { className: "
|
|
190
|
+
result.append(el("p", { className: "verdict bad", textContent: "✗ No file found in that reply." }));
|
|
164
191
|
}
|
|
165
192
|
};
|
|
166
193
|
|
|
@@ -219,13 +219,9 @@ export function viewStaff(m) {
|
|
|
219
219
|
|
|
220
220
|
box.append(el("div", { className: "planhead", textContent: "Then, on the new card" }));
|
|
221
221
|
box.append(
|
|
222
|
-
line(
|
|
223
|
-
"todo",
|
|
224
|
-
"GitHub App, or roster app " + s.handle + " --apply: creates it, sets its secrets, and " +
|
|
225
|
-
"opens the install page with the repos already ticked",
|
|
226
|
-
),
|
|
222
|
+
line("todo", "Create their GitHub App, then install it on the repos it opens with ticked"),
|
|
227
223
|
line("todo", "Write the charter"),
|
|
228
|
-
line("todo", "Run once now,
|
|
224
|
+
line("todo", "Run once now, to check everything works"),
|
|
229
225
|
);
|
|
230
226
|
for (const w of plan.warnings ?? []) box.append(line("warn", S.data.staff.length ? w : portalWords(w)));
|
|
231
227
|
|