@stravica-ai/digital-operator-pa 0.1.42 → 0.1.44

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.
@@ -360,9 +360,11 @@ Usage:
360
360
  Bind host defaults to loopback (127.0.0.1). Use --host <addr> (or the
361
361
  WORKSPACE_HOST env var) to expose the workspace on another interface.
362
362
 
363
- When the workspace is supervised by launchd (~/Library/LaunchAgents/com.dave.workspace.plist)
364
- or systemd (~/.config/systemd/user/dave-workspace.service), \`stop\` is refused and \`restart\`
363
+ When the workspace is supervised by launchd (~/Library/LaunchAgents/com.<paName>.workspace.plist)
364
+ or systemd (~/.config/systemd/user/<paName>-workspace.service), \`stop\` is refused and \`restart\`
365
365
  delegates to the supervisor — this is the in-place version-pickup path after \`pnpm update\`.
366
+ Unit labels are derived from operator/config/preferences.json:paName; the legacy
367
+ com.dave.workspace / dave-workspace.service is still recognised for pre-0.1.44 installs.
366
368
 
367
369
  Exit codes (tooling standard §6):
368
370
  0 ok | 1 usage error | 2 operational failure | 3 transport/IO failure
@@ -33,7 +33,7 @@ The workspace server hosts `viewer.html` — the PA's canonical surface for long
33
33
 
34
34
  ## Supervisor-aware lifecycle
35
35
 
36
- When the workspace is supervised by launchd (`~/Library/LaunchAgents/com.dave.workspace.plist`) or systemd (`~/.config/systemd/user/dave-workspace.service`), `stop` is refused and `restart` delegates to the supervisor. This is the in-place version-pickup path after `pnpm update @stravica-ai/digital-operator-pa`.
36
+ When the workspace is supervised by launchd (`~/Library/LaunchAgents/com.<paName>.workspace.plist`) or systemd (`~/.config/systemd/user/<paName>-workspace.service`), `stop` is refused and `restart` delegates to the supervisor. This is the in-place version-pickup path after `pnpm update @stravica-ai/digital-operator-pa`. Unit labels follow the scheduler's `com.{agent}.{id}` convention with `id = workspace` and `agent` resolved from `paName` in `operator/config/preferences.json`; pre-0.1.44 installs are still recognised under the legacy `com.dave.workspace` / `dave-workspace.service` name until you run `install-daemon install --migrate` to rename.
37
37
 
38
38
  ## When to use the workspace
39
39
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/engineering-pa-core-extensions-atlassian",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Atlassian (Jira, Confluence, confluence-fetch) CLIs and library surface. Promoted into core for the alpha solo scaffold.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -9,6 +9,7 @@
9
9
  // Zero external dependencies - uses Node.js built-in fetch() and child_process.
10
10
 
11
11
  import { loadAtlassianCredentials } from '../common/secrets.mjs';
12
+ import { readBody, formatHttpError } from '../jira/client.mjs';
12
13
 
13
14
  // ---- Exit-code-aware error ----------------------------------------------
14
15
 
@@ -145,33 +146,12 @@ export function createClient(options = {}) {
145
146
  // Parse response
146
147
  let data = null;
147
148
  if (res.status !== 204) {
148
- const contentType = res.headers.get('Content-Type') || '';
149
- if (contentType.includes('application/json')) {
150
- data = await res.json();
151
- } else {
152
- const text = await res.text();
153
- if (text) data = text;
154
- }
149
+ data = await readBody(res);
155
150
  }
156
151
 
157
152
  // 4xx errors
158
153
  if (res.status >= 400) {
159
- const msgs = [];
160
- if (data && data.errorMessages) msgs.push(...data.errorMessages);
161
- if (data && data.errors) {
162
- if (Array.isArray(data.errors)) {
163
- for (const e of data.errors) {
164
- msgs.push(e.title || e.message || JSON.stringify(e));
165
- }
166
- } else {
167
- for (const [field, msg] of Object.entries(data.errors)) {
168
- msgs.push(`${field}: ${msg}`);
169
- }
170
- }
171
- }
172
- if (data && data.message) msgs.push(data.message);
173
- const detail = msgs.length > 0 ? msgs.join('; ') : `HTTP ${res.status}`;
174
- throw new ConfluenceError(`Confluence API error (${res.status}): ${detail}`, 2);
154
+ throw new ConfluenceError(formatHttpError('Confluence', res.status, data), 2);
175
155
  }
176
156
 
177
157
  return { status: res.status, data, headers: res.headers };
@@ -17,6 +17,71 @@ export class JiraError extends Error {
17
17
  }
18
18
  }
19
19
 
20
+ // ---- Shared response parsing --------------------------------------------
21
+
22
+ /**
23
+ * Read a fetch Response body without ever throwing on a bad body.
24
+ *
25
+ * Atlassian sometimes returns a non-JSON body (bare text, an HTML error
26
+ * page from Cloudflare, a proxy edge string) with a `Content-Type` that
27
+ * still says `application/json`, and a raw `res.json()` on that body throws
28
+ * `SyntaxError: Unexpected token 'C', "Client mus"... is not valid JSON`,
29
+ * which surfaces as an unhandled stack trace instead of an
30
+ * "authentication failed" message. Falls back to raw text on any parse
31
+ * failure and returns null for empty bodies.
32
+ *
33
+ * @param {Response} res
34
+ * @returns {Promise<any|string|null>}
35
+ */
36
+ export async function readBody(res) {
37
+ const contentType = res.headers.get('Content-Type') || '';
38
+ const wantsJson = contentType.includes('application/json');
39
+ const raw = await res.text();
40
+ if (!raw) return null;
41
+ if (wantsJson) {
42
+ try {
43
+ return JSON.parse(raw);
44
+ } catch {
45
+ return raw.trim();
46
+ }
47
+ }
48
+ return raw.trim();
49
+ }
50
+
51
+ /**
52
+ * Turn a parsed error body into a one-line, colleague-facing HTTP
53
+ * error message. `data` may be a parsed JSON object (Atlassian error
54
+ * shape), a plain string (non-JSON body), or null.
55
+ *
56
+ * @param {string} product "Jira" or "Confluence"
57
+ * @param {number} status HTTP status
58
+ * @param {any} data Parsed body, string, or null
59
+ * @returns {string}
60
+ */
61
+ export function formatHttpError(product, status, data) {
62
+ const parts = [];
63
+ if (data && typeof data === 'object') {
64
+ if (Array.isArray(data.errorMessages)) parts.push(...data.errorMessages);
65
+ if (data.errors) {
66
+ if (Array.isArray(data.errors)) {
67
+ for (const e of data.errors) {
68
+ parts.push(e.title || e.message || JSON.stringify(e));
69
+ }
70
+ } else if (typeof data.errors === 'object') {
71
+ for (const [field, msg] of Object.entries(data.errors)) {
72
+ parts.push(`${field}: ${msg}`);
73
+ }
74
+ }
75
+ }
76
+ if (data.message && parts.length === 0) parts.push(data.message);
77
+ } else if (typeof data === 'string' && data.length > 0) {
78
+ const trimmed = data.length > 120 ? `${data.slice(0, 120)}...` : data;
79
+ parts.push(trimmed);
80
+ }
81
+ const detail = parts.length > 0 ? parts.join('; ') : `HTTP ${status}`;
82
+ return `${product} API error (${status}): ${detail}`;
83
+ }
84
+
20
85
  // ---- Credential loading -------------------------------------------------
21
86
 
22
87
  /**
@@ -142,26 +207,12 @@ export function createClient(options = {}) {
142
207
  // Parse response
143
208
  let data = null;
144
209
  if (res.status !== 204) {
145
- const contentType = res.headers.get('Content-Type') || '';
146
- if (contentType.includes('application/json')) {
147
- data = await res.json();
148
- } else {
149
- const text = await res.text();
150
- if (text) data = text;
151
- }
210
+ data = await readBody(res);
152
211
  }
153
212
 
154
213
  // 4xx errors
155
214
  if (res.status >= 400) {
156
- const msgs = [];
157
- if (data && data.errorMessages) msgs.push(...data.errorMessages);
158
- if (data && data.errors) {
159
- for (const [field, msg] of Object.entries(data.errors)) {
160
- msgs.push(`${field}: ${msg}`);
161
- }
162
- }
163
- const detail = msgs.length > 0 ? msgs.join('; ') : `HTTP ${res.status}`;
164
- throw new JiraError(`Jira API error (${res.status}): ${detail}`, 2);
215
+ throw new JiraError(formatHttpError('Jira', res.status, data), 2);
165
216
  }
166
217
 
167
218
  return { status: res.status, data, headers: res.headers };
@@ -309,19 +360,11 @@ export function createClient(options = {}) {
309
360
 
310
361
  let data = null;
311
362
  if (res.status !== 204) {
312
- const contentType = res.headers.get('Content-Type') || '';
313
- if (contentType.includes('application/json')) data = await res.json();
314
- else { const text = await res.text(); if (text) data = text; }
363
+ data = await readBody(res);
315
364
  }
316
365
 
317
366
  if (res.status >= 400) {
318
- const msgs = [];
319
- if (data && data.errorMessages) msgs.push(...data.errorMessages);
320
- if (data && data.errors) {
321
- for (const [field, msg] of Object.entries(data.errors)) msgs.push(`${field}: ${msg}`);
322
- }
323
- const detail = msgs.length > 0 ? msgs.join('; ') : `HTTP ${res.status}`;
324
- throw new JiraError(`Jira API error (${res.status}): ${detail}`, 2);
367
+ throw new JiraError(formatHttpError('Jira', res.status, data), 2);
325
368
  }
326
369
 
327
370
  return { status: res.status, data, headers: res.headers };
@@ -27,6 +27,31 @@ deliberately not a shim for the pre-remote library sweep.
27
27
  - `library status [--json]`
28
28
  GETs `/status` from the service, plus `git rev-list HEAD..origin/main --count`
29
29
  per tier clone.
30
+ - `library up-merge [--repo <owner/name>]... [--pr <number>] [--dry-run] [--once] [--verbose] [--json]`
31
+ Scheduled auto-merger for library up-path PRs. Scans configured library
32
+ repos for open PRs from branch `library-sync-up/<device>` and merges each
33
+ one whose diff is confined to the paths `<device>` owns per
34
+ `operator/registry/repos.json`. Anything else is a refusal: the PR is
35
+ left open with a one-time comment (idempotent via an HTML marker) and no
36
+ merge is ever attempted. Writes one JSONL line per PR per pass to
37
+ `library.upMerge.eventsLog` (default `.cache/library-up-merger/events.jsonl`).
38
+ Runs the same in SIMPLE and REMOTE mode (GitHub-side maintenance verb,
39
+ no ingest touch). Config in the `library.upMerge` stanza; see below.
40
+
41
+ ### Scope rule (verbatim)
42
+
43
+ ```
44
+ A PR is eligible when both:
45
+ (1) its head branch matches "library-sync-up/<device>"
46
+ (2) every changed file's repo-relative path falls under a path <device>
47
+ owns for that library repo per operator/registry/repos.json:
48
+ for each mount M where M.upPath === true and M.upPathOwner === <device>:
49
+ allowed prefix = M.from + include, for each include in M.upPathInclude
50
+ a file matches when its path equals a prefix (single-file include)
51
+ or starts with prefix + "/" (directory include).
52
+ Any file outside the device's allowed prefixes is a refusal - the PR is
53
+ left open, a one-time comment is posted, and no merge is ever attempted.
54
+ ```
30
55
 
31
56
  ## Configuration
32
57
 
@@ -45,6 +70,40 @@ deliberately not a shim for the pre-remote library sweep.
45
70
 
46
71
  - `LIBRARY_PULL_CLONES_ROOT` env — overrides the pull-clones root at runtime.
47
72
 
73
+ - `library.upMerge` stanza in preferences (used by `library up-merge` only):
74
+
75
+ ```json
76
+ {
77
+ "library": {
78
+ "upMerge": {
79
+ "repos": ["Stravica/stravica-library-wsd"],
80
+ "registryPath": "operator/registry/repos.json",
81
+ "eventsLog": ".cache/library-up-merger/events.jsonl",
82
+ "mergeMethod": "merge",
83
+ "branchPrefix": "library-sync-up/",
84
+ "deleteBranchOnMerge": true,
85
+ "ghBin": "gh"
86
+ }
87
+ }
88
+ }
89
+ ```
90
+
91
+ Every field has a safe default; `repos` defaults to `[]` (no repos scanned)
92
+ and can be overridden per invocation with `--repo <owner/name>` (repeatable).
93
+
94
+ ### Migrating from the HQ `library-up-merger` tool
95
+
96
+ `library up-merge` is a behaviour-identical promotion of the HQ tool at
97
+ `operator/extensions/tools/library-up-merger/`. The scheduled command
98
+ changes from
99
+ `node operator/extensions/tools/library-up-merger/bin/library-up-merger`
100
+ to `pnpm exec library up-merge`. The scope rule, refusal marker, events
101
+ log path, and default merge method are unchanged; the HQ tool's
102
+ `config.json` maps 1:1 to the `library.upMerge` stanza above (`repos`,
103
+ `registryPath`, `eventsLog`, `mergeMethod`, `branchPrefix`,
104
+ `deleteBranchOnMerge`). Repoint the schedule at the new command and
105
+ retire the HQ tool.
106
+
48
107
  Ingest authenticates by subkey alone (spec §3.4, main ruling 2026-08-20 option b);
49
108
  no install-key header is sent. The former `REMOTE_LIBRARY_INSTALL_KEY` env
50
109
  interim was removed in the same pass.
@@ -22,6 +22,7 @@ import { runGet } from './verbs/get.mjs';
22
22
  import { runSearch } from './verbs/search.mjs';
23
23
  import { runStatus } from './verbs/status.mjs';
24
24
  import { runRefreshClones } from './verbs/refresh-clones.mjs';
25
+ import { runUpMerge } from './verbs/up-merge.mjs';
25
26
 
26
27
  const __dirname = dirname(fileURLToPath(import.meta.url));
27
28
 
@@ -52,13 +53,15 @@ Usage:
52
53
  library search <query> [--files-only]
53
54
  library status [--json]
54
55
  library refresh-clones [--json] [--timeout-ms <n>] [--tier <name>]...
56
+ library up-merge [--repo <owner/name>]... [--pr <number>] [--dry-run] [--once] [--verbose] [--json]
55
57
  library -h | --help
56
58
  library -v | --version
57
59
 
58
60
  Reads config from operator/config/preferences.json (+ preferences.local.json overlay).
59
61
  Verbs are only active in REMOTE mode. SIMPLE mode 'library status' prints a
60
62
  one-line mode summary and exits 0; every other verb exits 1 in SIMPLE mode
61
- with a clean not-applicable message.
63
+ with a clean not-applicable message. 'library up-merge' is a GitHub-side
64
+ maintenance verb and runs in both SIMPLE and REMOTE mode.
62
65
 
63
66
  --help and --version never touch config or the vault, so they always work
64
67
  from a directory with no operator config and no vault installed.
@@ -102,6 +105,18 @@ export async function main(argv, seams = {}) {
102
105
  }
103
106
 
104
107
  if (config.mode === 'simple') {
108
+ // `library up-merge` is a GitHub-side maintenance verb: it reads
109
+ // library.upMerge from preferences and hits gh, never the ingest
110
+ // service. It runs the same in SIMPLE and REMOTE mode.
111
+ if (verb === 'up-merge') {
112
+ const ctxUp = { config, log, err };
113
+ try {
114
+ return await runUpMerge({ ...ctxUp, args: parseUpMerge(rest) });
115
+ } catch (e) {
116
+ err(`[library up-merge] ${e.message ?? e}`);
117
+ return typeof e.exitCode === 'number' ? e.exitCode : 2;
118
+ }
119
+ }
105
120
  // `library status` is the one verb useful in SIMPLE mode too: a plain
106
121
  // one-line mode summary that tells the operator the CLI is installed
107
122
  // and the remote path is intentionally not wired. No vault touch,
@@ -141,6 +156,7 @@ export async function main(argv, seams = {}) {
141
156
  case 'search': return await runSearch({ ...ctx, args: parseSearch(rest) });
142
157
  case 'status': return await runStatus({ ...ctx, args: parseStatus(rest) });
143
158
  case 'refresh-clones': return await runRefreshClones({ ...ctx, args: parseRefreshClones(rest) });
159
+ case 'up-merge': return await runUpMerge({ ...ctx, args: parseUpMerge(rest) });
144
160
  default:
145
161
  err(`[library] unknown verb: ${verb}`);
146
162
  log(USAGE);
@@ -195,6 +211,30 @@ function parseStatus(argv) {
195
211
  });
196
212
  return { json: values.json };
197
213
  }
214
+ function parseUpMerge(argv) {
215
+ const { values } = parseArgs({
216
+ args: argv,
217
+ strict: true,
218
+ allowPositionals: false,
219
+ options: {
220
+ repo: { type: 'string', multiple: true },
221
+ pr: { type: 'string' },
222
+ 'dry-run': { type: 'boolean', default: false },
223
+ once: { type: 'boolean', default: false },
224
+ verbose: { type: 'boolean', default: false },
225
+ json: { type: 'boolean', default: false },
226
+ },
227
+ });
228
+ const pr = values.pr ? parseInt(values.pr, 10) : undefined;
229
+ return {
230
+ repos: values.repo,
231
+ pr,
232
+ dryRun: values['dry-run'],
233
+ once: values.once,
234
+ verbose: values.verbose,
235
+ json: values.json,
236
+ };
237
+ }
198
238
  function parseRefreshClones(argv) {
199
239
  const { values } = parseArgs({
200
240
  args: argv,
@@ -0,0 +1,103 @@
1
+ // Thin wrappers around the gh CLI, factored so tests can inject a runner
2
+ // and never spawn a real gh. Auth is handled by gh from the host's own
3
+ // login (TheFoot on smarthome01 for the scheduled path today).
4
+ //
5
+ // The runner signature is deliberately narrow: (cmd, args, { input }) ->
6
+ // Promise<{ code, stdout, stderr }>. Every wrapper takes an optional
7
+ // `runner` seam that defaults to the module-local `defaultRunner`, which
8
+ // is a plain spawn. Tests build a runner that inspects args and returns
9
+ // canned stdout/stderr, so no gh process is ever invoked.
10
+ //
11
+ // Ported from operator/extensions/tools/library-up-merger/src/github.mjs
12
+ // and factored for injection (w-2026-09-16-dave-014, d-2026-09-17-013).
13
+
14
+ import { spawn } from 'node:child_process';
15
+
16
+ /**
17
+ * @typedef {(cmd: string, args: string[], opts?: { input?: string }) => Promise<{ code: number, stdout: string, stderr: string }>} Runner
18
+ */
19
+
20
+ /** @type {Runner} */
21
+ export function defaultRunner(cmd, args, { input } = {}) {
22
+ return new Promise((resolve, reject) => {
23
+ const child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
24
+ let stdout = '';
25
+ let stderr = '';
26
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
27
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
28
+ child.on('error', reject);
29
+ child.on('close', (code) => {
30
+ resolve({ code, stdout, stderr });
31
+ });
32
+ if (input != null) child.stdin.end(input);
33
+ else child.stdin.end();
34
+ });
35
+ }
36
+
37
+ async function ghJson(args, { runner = defaultRunner, ghBin = 'gh' } = {}) {
38
+ const res = await runner(ghBin, args);
39
+ if (res.code !== 0) {
40
+ const err = new Error(`gh ${args.join(' ')} exited ${res.code}: ${res.stderr.trim()}`);
41
+ err.stderr = res.stderr;
42
+ err.stdout = res.stdout;
43
+ throw err;
44
+ }
45
+ const text = res.stdout.trim();
46
+ if (!text) return null;
47
+ return JSON.parse(text);
48
+ }
49
+
50
+ export async function listOpenPrs(repoSlug, opts = {}) {
51
+ const data = await ghJson([
52
+ 'pr', 'list', '--repo', repoSlug, '--state', 'open',
53
+ '--json', 'number,headRefName,author,title,url,isDraft',
54
+ '--limit', '100',
55
+ ], opts);
56
+ return data || [];
57
+ }
58
+
59
+ export async function listChangedFiles(repoSlug, number, opts = {}) {
60
+ const data = await ghJson([
61
+ 'pr', 'view', String(number), '--repo', repoSlug,
62
+ '--json', 'files',
63
+ ], opts);
64
+ return (data && Array.isArray(data.files) ? data.files : []).map((f) => f.path);
65
+ }
66
+
67
+ export async function mergePr(repoSlug, number, {
68
+ method = 'merge', deleteBranch = true, runner = defaultRunner, ghBin = 'gh',
69
+ } = {}) {
70
+ const args = ['pr', 'merge', String(number), '--repo', repoSlug, `--${method}`];
71
+ if (deleteBranch) args.push('--delete-branch');
72
+ const res = await runner(ghBin, args);
73
+ return {
74
+ ok: res.code === 0,
75
+ code: res.code,
76
+ stdout: res.stdout.trim(),
77
+ stderr: res.stderr.trim(),
78
+ };
79
+ }
80
+
81
+ export async function commentOnPr(repoSlug, number, body, {
82
+ runner = defaultRunner, ghBin = 'gh',
83
+ } = {}) {
84
+ const res = await runner(
85
+ ghBin,
86
+ ['pr', 'comment', String(number), '--repo', repoSlug, '--body-file', '-'],
87
+ { input: body },
88
+ );
89
+ return {
90
+ ok: res.code === 0,
91
+ code: res.code,
92
+ stdout: res.stdout.trim(),
93
+ stderr: res.stderr.trim(),
94
+ };
95
+ }
96
+
97
+ export async function listExistingComments(repoSlug, number, opts = {}) {
98
+ const data = await ghJson(
99
+ ['pr', 'view', String(number), '--repo', repoSlug, '--json', 'comments'],
100
+ opts,
101
+ );
102
+ return (data && Array.isArray(data.comments) ? data.comments : []).map((c) => c.body || '');
103
+ }
@@ -0,0 +1,95 @@
1
+ // Scope-rule computation for library up-path PRs.
2
+ //
3
+ // A PR from head branch `<branchPrefix><device>` on library repo <repoSlug> may be merged
4
+ // when and only when EVERY changed file's repo-relative path falls under one of the
5
+ // device's owned up-paths for that library repo, computed from operator/registry/repos.json:
6
+ //
7
+ // For each mount M in repos[<library repo id>].mounts where M.upPath === true
8
+ // and M.upPathOwner === <device>:
9
+ // allowedPrefix = M.from + include, for each include in (M.upPathInclude || [])
10
+ // A file matches when its path === allowedPrefix (single-file include)
11
+ // or starts with allowedPrefix + "/" (directory include).
12
+ //
13
+ // A device with no owned up-paths on the repo, or a file matching none of its allowed
14
+ // prefixes, is a refusal. Refusals never merge.
15
+ //
16
+ // Ported verbatim (behaviour) from operator/extensions/tools/library-up-merger/src/scope.mjs
17
+ // on the promotion to core (w-2026-09-16-dave-014, d-2026-09-17-013). The HQ tool was the
18
+ // reference implementation; this module is the canonical home from 0.1.44 onward.
19
+
20
+ /**
21
+ * @param {object} registry parsed operator/registry/repos.json
22
+ * @param {string} repoSlug e.g. "Stravica/stravica-library-wsd"
23
+ * @returns {string|null} matching registry key, or null
24
+ */
25
+ export function repoIdForSlug(registry, repoSlug) {
26
+ const wanted = repoSlug.toLowerCase();
27
+ for (const [id, cfg] of Object.entries(registry.repos || {})) {
28
+ const remote = (cfg.gitRemote || '').toLowerCase();
29
+ // git@github.com:Owner/name.git -> Owner/name
30
+ const m = remote.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
31
+ if (m && m[1] === wanted) return id;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * Compute the sorted, de-duplicated list of allowed repo-relative path prefixes for a
38
+ * device on a library repo. Each entry is either an exact file path or a directory path
39
+ * (no trailing slash) - the matcher below appends "/" for directory matching.
40
+ */
41
+ export function allowedPathsForDevice(registry, repoId, device) {
42
+ const repo = (registry.repos || {})[repoId];
43
+ if (!repo || !Array.isArray(repo.mounts)) return [];
44
+ const out = new Set();
45
+ for (const mount of repo.mounts) {
46
+ if (!mount || mount.upPath !== true) continue;
47
+ if (mount.upPathOwner !== device) continue;
48
+ const fromPrefix = String(mount.from || '').replace(/\/+$/, '');
49
+ const includes = Array.isArray(mount.upPathInclude) ? mount.upPathInclude : [];
50
+ for (const inc of includes) {
51
+ const clean = String(inc).replace(/\/+$/, '');
52
+ if (!clean) continue;
53
+ out.add(fromPrefix ? `${fromPrefix}/${clean}` : clean);
54
+ }
55
+ }
56
+ return [...out].sort();
57
+ }
58
+
59
+ /**
60
+ * Does `filePath` (repo-relative) fall under one of the `allowed` entries?
61
+ * An entry matches when the path equals it, or starts with `entry + "/"`.
62
+ */
63
+ export function fileAllowed(filePath, allowed) {
64
+ const f = String(filePath || '').replace(/^\/+/, '');
65
+ for (const entry of allowed) {
66
+ if (f === entry) return true;
67
+ if (f.startsWith(`${entry}/`)) return true;
68
+ }
69
+ return false;
70
+ }
71
+
72
+ /**
73
+ * Classify a PR's file list against a device's allowed prefixes.
74
+ * @returns {{ ok: boolean, refused: string[], allowed: string[] }}
75
+ */
76
+ export function classifyFiles(files, allowed) {
77
+ const refused = [];
78
+ const allowedFiles = [];
79
+ for (const f of files) {
80
+ if (fileAllowed(f, allowed)) allowedFiles.push(f);
81
+ else refused.push(f);
82
+ }
83
+ return { ok: refused.length === 0, refused, allowed: allowedFiles };
84
+ }
85
+
86
+ /**
87
+ * Parse a device out of a head-ref of the form `<branchPrefix><device>`.
88
+ * Returns null if the branch does not match.
89
+ */
90
+ export function deviceFromBranch(branch, branchPrefix) {
91
+ if (!branch || !branch.startsWith(branchPrefix)) return null;
92
+ const rest = branch.slice(branchPrefix.length);
93
+ if (!rest || rest.includes('/')) return null;
94
+ return rest;
95
+ }