@join-pantheon/cli 0.30.0 → 0.31.0

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.
@@ -1,9 +1,11 @@
1
1
  import { existsSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { join, resolve, sep } from "node:path";
3
+ import { holdsRepo } from "./repo-find.js";
3
4
  import { CONFIG_DIR, configFor, loadAll, repoInfo } from "./config.js";
4
- import { Worker } from "./worker.js";
5
+ import { Worker, workspaceOf } from "./worker.js";
5
6
  export class WorkerFleet {
6
7
  opts;
8
+ dupSaid = new Set();
7
9
  slots = new Map();
8
10
  stopped = false;
9
11
  constructor(opts) {
@@ -18,14 +20,23 @@ export class WorkerFleet {
18
20
  const wanted = new Map();
19
21
  if (f) {
20
22
  const several = Object.keys(f.spaces ?? {}).length > 1;
21
- for (const b of Object.values(f.bindings ?? {})) {
22
- const m = f.spaces?.[b.space];
23
- if (!m || !existsSync(join(b.path, ".git")))
23
+ const idCount = new Map();
24
+ for (const m of Object.values(f.spaces ?? {}))
25
+ idCount.set(m.id, (idCount.get(m.id) ?? 0) + 1);
26
+ for (const m of Object.values(f.spaces ?? {})) {
27
+ if ((idCount.get(m.id) ?? 0) > 1) {
28
+ if (!this.dupSaid.has(m.id)) {
29
+ this.dupSaid.add(m.id);
30
+ this.opts.log(`fleet: two entries in config.json claim team ${m.id} — serving neither until one is removed (pantheon spaces)`);
31
+ }
24
32
  continue;
25
- if (wanted.has(m.id))
33
+ }
34
+ const ws = teamWorkspaces(f, m.id);
35
+ const home = ws.find((w) => w.kind === "repo") ?? ws[0];
36
+ if (!home)
26
37
  continue;
27
- const repo = repoInfo(b.path);
28
- wanted.set(m.id, { path: b.path, cfg: configFor(f, m, repo) });
38
+ const repo = home.kind === "repo" ? repoInfo(home.path) : null;
39
+ wanted.set(m.id, { path: home.path, cfg: configFor(f, m, repo) });
29
40
  void several;
30
41
  }
31
42
  for (const [id, w] of wanted) {
@@ -36,6 +47,7 @@ export class WorkerFleet {
36
47
  const wopts = {
37
48
  ...rest,
38
49
  project: w.path,
50
+ workspaces: () => teamWorkspaces((this.opts.readConfig ?? loadAll)(), id),
39
51
  scratch: join(home, "worktrees", encodeURIComponent(id)),
40
52
  home,
41
53
  requirePersonaSpaces: several,
@@ -66,7 +78,7 @@ export class WorkerFleet {
66
78
  for (const s of stopped)
67
79
  this.opts.log(`fleet: worker for "${s}" stopped — binding gone`);
68
80
  if (!this.slots.size && !started.length)
69
- this.opts.log(`fleet: no bound repos on this machine — bind one with \`pantheon use <team> --here\` from its checkout`);
81
+ this.opts.log(`fleet: nothing bound on this machine — bind a repo or a docs folder with \`pantheon use <team> --here\` from inside it`);
70
82
  await new Promise((r) => setTimeout(r, poll));
71
83
  if (this.opts.once && this.slots.size === 0)
72
84
  break;
@@ -81,4 +93,13 @@ export class WorkerFleet {
81
93
  s.worker.stop();
82
94
  }
83
95
  }
96
+ export function teamWorkspaces(f, spaceId) {
97
+ const bound = Object.entries(f?.bindings ?? {}).filter(([, b]) => b.space === spaceId && existsSync(b.path));
98
+ const repos = bound.filter(([k, b]) => !k.startsWith("d_") && existsSync(join(b.path, ".git"))).map(([, b]) => workspaceOf(b.path));
99
+ const all = Object.values(f?.bindings ?? {}).map((b) => resolve(b.path));
100
+ const folders = bound.filter(([k]) => k.startsWith("d_")).map(([, b]) => workspaceOf(b.path))
101
+ .filter((w) => w.kind === "folder" && !all.some((p) => p !== resolve(w.path) && p.startsWith(`${resolve(w.path)}${sep}`)) && !holdsRepo(w.path));
102
+ const seen = new Set();
103
+ return [...repos, ...folders].filter((w) => (seen.has(w.path) ? false : (seen.add(w.path), true)));
104
+ }
84
105
  //# sourceMappingURL=worker-fleet.js.map
@@ -3,6 +3,35 @@ import { dirname, join } from "node:path";
3
3
  import { CONFIG_PATH } from "./config.js";
4
4
  import { trustWrite } from "./home-guard.js";
5
5
  import { parseJsonText } from "./text-file.js";
6
+ export const WAKE_NO_MS = 30 * 24 * 60 * 60 * 1_000;
7
+ export const wakeKey = (team, member) => `${team}|${member}`;
8
+ export function mayAskWake(p, teams, member, now = Date.now()) {
9
+ const ts = teams.filter((t) => !!t);
10
+ if (isApprovedWaker(p, ts, member))
11
+ return false;
12
+ if (ts.some((t) => (p.wakeNo?.[wakeKey(t, member)] ?? 0) > now - WAKE_NO_MS))
13
+ return false;
14
+ return !Object.values(p.wakeAsks ?? {}).some((a) => ts.includes(a.team) && a.member === member && now - a.at < ASK_EXPIRY_MS);
15
+ }
16
+ export function takeWakeOnce(p, teams, member) {
17
+ for (const t of teams) {
18
+ if (!t)
19
+ continue;
20
+ const k = wakeKey(t, member);
21
+ const n = p.wakeOnce?.[k] ?? 0;
22
+ if (n > 0) {
23
+ if (n > 1)
24
+ p.wakeOnce[k] = n - 1;
25
+ else
26
+ delete p.wakeOnce[k];
27
+ return true;
28
+ }
29
+ }
30
+ return false;
31
+ }
32
+ export function hasWakeOnce(p, teams, member) {
33
+ return teams.some((t) => !!t && (p.wakeOnce?.[wakeKey(t, member)] ?? 0) > 0);
34
+ }
6
35
  export const ASK_COOLDOWN_MS = 24 * 60 * 60 * 1_000;
7
36
  export const ASK_EXPIRY_MS = 14 * 24 * 60 * 60 * 1_000;
8
37
  export function policyHome(home) {
@@ -35,6 +64,10 @@ export function loadPolicy(home) {
35
64
  ...(wakers ? { wakers } : {}),
36
65
  ...(isRecord(raw.wakersCarried) ? { wakersCarried: raw.wakersCarried } : {}),
37
66
  ...(isRecord(raw.wakersDeferred) ? { wakersDeferred: raw.wakersDeferred } : {}),
67
+ ...(isRecord(raw.wakeAsks) ? { wakeAsks: Object.fromEntries(Object.entries(raw.wakeAsks).filter(([, v]) => isRecord(v) && typeof v.team === "string" && typeof v.member === "string" && typeof v.at === "number")) } : {}),
68
+ ...(isRecord(raw.wakeOnce) ? { wakeOnce: Object.fromEntries(Object.entries(raw.wakeOnce).filter(([, v]) => typeof v === "number" && v > 0 && v < 100)) } : {}),
69
+ ...(isRecord(raw.wakeNo) ? { wakeNo: Object.fromEntries(Object.entries(raw.wakeNo).filter(([, v]) => typeof v === "number")) } : {}),
70
+ ...(isRecord(raw.wakeTeamFlag) ? { wakeTeamFlag: Object.fromEntries(Object.entries(raw.wakeTeamFlag).filter(([, v]) => typeof v === "number")) } : {}),
38
71
  };
39
72
  }
40
73
  catch {
@@ -117,14 +150,40 @@ export function setLocalWaker(teams, member, approved, home, env = process.env)
117
150
  savePolicy(p, home);
118
151
  return { ok: true, changed: true };
119
152
  }
153
+ export function markTeamFlagOnYes(team, member, home, env = process.env) {
154
+ if (inWokenRun(env))
155
+ return;
156
+ const p = loadPolicy(home);
157
+ const k = wakeKey(team, member);
158
+ (p.wakeTeamFlag ??= {})[k] = Date.now();
159
+ savePolicy(p, home);
160
+ }
161
+ export function takeTeamFlagOnYes(p, teams, member) {
162
+ for (const t of teams) {
163
+ if (!t)
164
+ continue;
165
+ const k = wakeKey(t, member);
166
+ const at = p.wakeTeamFlag?.[k];
167
+ if (at !== undefined) {
168
+ delete p.wakeTeamFlag[k];
169
+ return Date.now() - at < 7 * 24 * 3600_000;
170
+ }
171
+ }
172
+ return false;
173
+ }
120
174
  export function isApprovedWaker(p, teams, member) {
121
175
  return teams.some((t) => { const list = t ? p.wakers?.[t] : undefined; return Array.isArray(list) && list.includes(member); });
122
176
  }
123
177
  export function savePolicy(p, home) {
124
178
  const file = policyPath(home);
125
179
  mkdirSync(dirname(file), { recursive: true });
180
+ let prev = null;
181
+ try {
182
+ prev = readFileSync(file);
183
+ }
184
+ catch { }
126
185
  writeFileSync(file, JSON.stringify(p, null, 2) + "\n", { mode: 0o600 });
127
- trustWrite(file);
186
+ trustWrite(file, prev);
128
187
  }
129
188
  export function useGrant(p, cap) {
130
189
  const g = p.grants[cap];
@@ -141,6 +200,11 @@ export function grant(p, cap, mode, by) {
141
200
  }
142
201
  export function revoke(p, cap) {
143
202
  delete p.grants[cap];
203
+ if (cap === "unsandboxed_runs")
204
+ p.denied[cap] = Date.now();
205
+ }
206
+ export function unsandboxedAllowed(p) {
207
+ return !!p.grants.unsandboxed_runs || p.denied.unsandboxed_runs === undefined;
144
208
  }
145
209
  export function mayAsk(p, cap, now = Date.now()) {
146
210
  if (p.grants[cap])
@@ -155,22 +219,42 @@ export function recordAsk(p, cap, escalateActId) {
155
219
  p.asks[cap] = { act: escalateActId, at: Date.now() };
156
220
  }
157
221
  export function parseGrantAnswer(body) {
158
- const t = body.trim().toLowerCase();
159
- if (/^(allow[\s-]?always|always[\s-]?allow|yes[\s-]?always)\b/.test(t))
222
+ const t = body.trim().toLowerCase().replace(/[.!]+$/, "").replace(/\s+/g, " ").replace(/’/g, "'");
223
+ if (/^(always|yes always|yes, always|always allow)$/.test(t) || /^(allow[\s-]always)\b/.test(t))
160
224
  return "allow-always";
161
- if (/^(allow([\s-]?once)?|yes([\s-]?once)?|approve[d]?)\b/.test(t))
225
+ if (/^(once|just this once|this once|yes once|yes, once|yes|allow|approve|approved)$/.test(t) || /^(allow[\s-](it[\s-])?once)\b/.test(t))
162
226
  return "allow-once";
163
- if (/^(deny|no|reject(ed)?|don'?t|do not)\b/.test(t))
227
+ if (/^(deny|denied|no|reject|rejected|don't allow|do not allow|don't|do not|never)\b/.test(t))
164
228
  return "deny";
165
229
  return null;
166
230
  }
167
231
  export const WOKEN_RUN_ENV = "PANTHEON_WOKEN_RUN";
168
232
  export const inWokenRun = (env = process.env) => !!env[WOKEN_RUN_ENV];
169
- export const CAPABILITY_ARGS = { self_upgrade: "self-upgrade", run_commands: "run-commands" };
233
+ export const CAPABILITY_ARGS = { self_upgrade: "self-upgrade", run_commands: "run-commands", unsandboxed_runs: "unsandboxed-runs" };
170
234
  export function applyLocalAnswer(refActId, body, home, env = process.env) {
171
235
  if (!refActId)
172
236
  return null;
173
237
  const p = loadPolicy(home);
238
+ const wa = p.wakeAsks?.[refActId];
239
+ if (wa) {
240
+ if (inWokenRun(env))
241
+ return { applied: false, why: "a woken run cannot let anyone wake this machine — a person has to answer it here" };
242
+ const verdict = parseGrantAnswer(body);
243
+ if (!verdict)
244
+ return null;
245
+ const k = wakeKey(wa.team, wa.member);
246
+ if (verdict === "allow-always")
247
+ setWaker(p, wa.team, wa.member, true);
248
+ else if (verdict === "allow-once")
249
+ (p.wakeOnce ??= {})[k] = Math.min(99, (p.wakeOnce[k] ?? 0) + 1);
250
+ else
251
+ (p.wakeNo ??= {})[k] = Date.now();
252
+ if (verdict !== "deny" && wa.teamBlocked)
253
+ (p.wakeTeamFlag ??= {})[k] = Date.now();
254
+ delete p.wakeAsks[refActId];
255
+ savePolicy(p, home);
256
+ return { applied: true, wake: { team: wa.team, member: wa.member }, verdict };
257
+ }
174
258
  if (!Object.values(p.asks).some((a) => a?.act === refActId))
175
259
  return null;
176
260
  if (inWokenRun(env))
@@ -184,6 +268,12 @@ export function applyLocalAnswer(refActId, body, home, env = process.env) {
184
268
  export function describeLocalAnswer(g) {
185
269
  if (!g.applied)
186
270
  return `not granted: ${g.why}`;
271
+ if ("wake" in g) {
272
+ const who = g.wake.member;
273
+ return g.verdict === "allow-always" ? `${who} may start work on this machine from now on — undo: pantheon wake deny ${who}`
274
+ : g.verdict === "allow-once" ? `${who} may start work on this machine this once — their request runs now`
275
+ : `${who} will not start work on this machine; their requests wait for you (not asked again for a month; pantheon wake allow ${who} to change it)`;
276
+ }
187
277
  const name = CAPABILITY_ARGS[g.cap];
188
278
  if (g.verdict === "deny")
189
279
  return `denied ${name} on this machine — the worker will not ask again for a day`;