@stravica-ai/digital-operator-pa 0.1.3 → 0.1.4
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.
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
// not silently start hiding projects on a package bump.
|
|
34
34
|
|
|
35
35
|
import { readFile } from 'node:fs/promises';
|
|
36
|
+
import { readFileSync } from 'node:fs';
|
|
36
37
|
import { join } from 'node:path';
|
|
37
38
|
|
|
38
39
|
/**
|
|
@@ -93,6 +94,30 @@ export function filterProjectsByTeam(projects, opts = {}) {
|
|
|
93
94
|
return projects.filter((p) => isOwnProject(p, set));
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Normalise a parsed preferences.json object (or null when the file was
|
|
99
|
+
* missing/malformed) into the effective team-scope config. Shared by the
|
|
100
|
+
* async and sync readers so both derive `teamIdentities`, `scopeEnabled`
|
|
101
|
+
* and `team` identically.
|
|
102
|
+
*
|
|
103
|
+
* `team` is the DEVICE's display team id (e.g. "hq"), a distinct namespace
|
|
104
|
+
* from `teamIdentities` (agent/role identities): teams are the named
|
|
105
|
+
* library-repo teams the workspace nav scopes display by (see nav.mjs).
|
|
106
|
+
* Absent/blank -> null, which means "do not team-scope nav" (fail open,
|
|
107
|
+
* backwards compatible: pre-team devices show every nav fragment).
|
|
108
|
+
*/
|
|
109
|
+
function normaliseTeamScopeConfig(config) {
|
|
110
|
+
const c = config && typeof config === 'object' ? config : {};
|
|
111
|
+
const paName = c.paName || c.agentName || 'pa';
|
|
112
|
+
const scopeEnabled = c.scopeProjectsToTeam === true;
|
|
113
|
+
const configured = Array.isArray(c.teamIdentities)
|
|
114
|
+
? c.teamIdentities.filter((x) => typeof x === 'string' && x.length > 0)
|
|
115
|
+
: null;
|
|
116
|
+
const team = typeof c.team === 'string' && c.team.length > 0 ? c.team : null;
|
|
117
|
+
const teamIdentities = [...new Set([paName, ...(configured || [])])];
|
|
118
|
+
return { paName, teamIdentities, scopeEnabled, team };
|
|
119
|
+
}
|
|
120
|
+
|
|
96
121
|
/**
|
|
97
122
|
* Read the team-scoping config out of operator/config/preferences.json.
|
|
98
123
|
* Falls back to disabled scoping + no identity if the file is missing or
|
|
@@ -104,26 +129,40 @@ export function filterProjectsByTeam(projects, opts = {}) {
|
|
|
104
129
|
* even when the configured list omits it (documented belt-and-braces: the
|
|
105
130
|
* local agent can never scope out its own directly-assigned projects).
|
|
106
131
|
*
|
|
132
|
+
* `team` is the device's display team id (or null); see
|
|
133
|
+
* normaliseTeamScopeConfig above.
|
|
134
|
+
*
|
|
107
135
|
* @param {string} repoRoot
|
|
108
|
-
* @returns {Promise<{ paName: string, teamIdentities: string[], scopeEnabled: boolean }>}
|
|
136
|
+
* @returns {Promise<{ paName: string, teamIdentities: string[], scopeEnabled: boolean, team: string|null }>}
|
|
109
137
|
*/
|
|
110
138
|
export async function readTeamScopeConfig(repoRoot) {
|
|
111
|
-
let
|
|
112
|
-
let configured = null;
|
|
113
|
-
let scopeEnabled = false;
|
|
139
|
+
let config = null;
|
|
114
140
|
try {
|
|
115
|
-
|
|
116
|
-
const config = JSON.parse(raw);
|
|
117
|
-
paName = config.paName || config.agentName || 'pa';
|
|
118
|
-
scopeEnabled = config.scopeProjectsToTeam === true;
|
|
119
|
-
if (Array.isArray(config.teamIdentities)) {
|
|
120
|
-
configured = config.teamIdentities.filter((x) => typeof x === 'string' && x.length > 0);
|
|
121
|
-
}
|
|
141
|
+
config = JSON.parse(await readFile(join(repoRoot, 'operator', 'config', 'preferences.json'), 'utf8'));
|
|
122
142
|
} catch {
|
|
123
|
-
// Missing/malformed file -> defaults
|
|
143
|
+
// Missing/malformed file -> defaults (scoping disabled, no team).
|
|
124
144
|
}
|
|
125
|
-
|
|
126
|
-
|
|
145
|
+
return normaliseTeamScopeConfig(config);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Synchronous mirror of {@link readTeamScopeConfig}. The workspace nav loader
|
|
150
|
+
* (nav.mjs) is a synchronous, mtime-cached read surface, so it resolves the
|
|
151
|
+
* device team through this variant rather than awaiting the async reader.
|
|
152
|
+
* Same fail-open posture: a missing/malformed file yields disabled scoping
|
|
153
|
+
* and a null team.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} repoRoot
|
|
156
|
+
* @returns {{ paName: string, teamIdentities: string[], scopeEnabled: boolean, team: string|null }}
|
|
157
|
+
*/
|
|
158
|
+
export function readTeamScopeConfigSync(repoRoot) {
|
|
159
|
+
let config = null;
|
|
160
|
+
try {
|
|
161
|
+
config = JSON.parse(readFileSync(join(repoRoot, 'operator', 'config', 'preferences.json'), 'utf8'));
|
|
162
|
+
} catch {
|
|
163
|
+
// Missing/malformed file -> defaults (scoping disabled, no team).
|
|
164
|
+
}
|
|
165
|
+
return normaliseTeamScopeConfig(config);
|
|
127
166
|
}
|
|
128
167
|
|
|
129
168
|
/**
|
|
@@ -138,6 +138,8 @@ The main sidebar is driven by a declarative manifest rather than hard-coded mark
|
|
|
138
138
|
|
|
139
139
|
**Extension manifest (optional):** `operator/extensions/workspace/nav.json`. Same shape, merged after core. Missing file is not an error.
|
|
140
140
|
|
|
141
|
+
**Team fragments (optional):** alongside `nav.json`, both dirs may carry `nav.<team>.json` files (e.g. `nav.hq.json`, `nav.wsd.json`). These are composed together, base `nav.json` first then team fragments alphabetically. They exist because the extension dir is materialised from multiple library repos merged by file path — a single shared `nav.json` collides (highest-precedence repo wins the whole file, dropping the other teams' entries), whereas distinctly-named fragments don't collide, so every team's entries materialise side by side. Which of them a device *displays* is then decided by team scoping (below).
|
|
142
|
+
|
|
141
143
|
**Shape:**
|
|
142
144
|
|
|
143
145
|
```json
|
|
@@ -158,10 +160,13 @@ The main sidebar is driven by a declarative manifest rather than hard-coded mark
|
|
|
158
160
|
| `href` | yes | Navigation target. Extension pages typically use `/<name>.html`; core pages use `/core/tools/workspace/app/<name>.html`. |
|
|
159
161
|
| `section` | no | Section header. Defaults to `Navigation`. Any other string creates a new section appended after the core sections (in manifest order). |
|
|
160
162
|
| `aliases` | no | Additional paths that should highlight this nav item when loaded (e.g. `project-detail.html` highlights `Projects`). |
|
|
163
|
+
| `team` | no | The team this entry belongs to (used by team scoping — see below). Resolution order: this inline field > the `nav.<team>.json` filename it lives in > `null` (untagged). Untagged entries are shown on every device. |
|
|
161
164
|
|
|
162
165
|
**Endpoint:** `GET /__api/nav` returns `{ "items": [...] }` — the merged manifest in final order. The shell fetches this on load and renders the sidebar dynamically. Read-through cached with mtime invalidation; no restart needed after editing a manifest. If the endpoint fails, the shell falls back to a minimal Home-only nav rather than rendering a white page.
|
|
163
166
|
|
|
164
|
-
**Missing pages:** if an `href` doesn't resolve to a real page (neither a core page nor an extension overlay), a warning
|
|
167
|
+
**Missing pages:** if an `href` doesn't resolve to a real page (neither a core page nor an extension overlay), the item is dropped with a `[nav] hidden:` warning — `library-sync` only writes a page file when the owning repo is mounted here, so page-presence is the per-entry materialisation signal (external/deep hrefs whose shape can't be resolved are left untouched).
|
|
168
|
+
|
|
169
|
+
**Team scoping (display):** a device declares its display team in `operator/config/preferences.json:team` (e.g. `"team": "hq"`), reusing the same team-scope config the project surfaces read (`readTeamScopeConfig` in `packages/core/src/desk/team-scope.mjs`). The default view keeps entries whose team matches the device team **or** are untagged (`null`), and hides other teams' — so a device that is *entitled* to another team's tooling (its pages are on disk) still only *shows* its own team's nav. This is the entitlement-vs-display split. Defaults are fail-open and backwards compatible: a device with no `team`, or nav that carries no team tags, renders unfiltered exactly as before. `team` is resolved once at server start (a change is a restart, like the port beside it). Escape hatches mirror the desk/projects convention: `GET /__api/nav?allTeams=1` bypasses scoping entirely, and `?team=<id>` is a strict single-team inspection filter (untagged excluded).
|
|
165
170
|
|
|
166
171
|
**Example:** copy `core/tools/workspace/nav.json.example` to `operator/extensions/workspace/nav.json` and adjust for your pages. A worked demo:
|
|
167
172
|
|
|
@@ -1,53 +1,110 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Workspace nav manifest loader.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* the
|
|
6
|
-
*
|
|
4
|
+
* Composes the sidebar nav from a set of manifest FRAGMENTS discovered on
|
|
5
|
+
* disk, then scopes the result two ways: by page presence (per-device
|
|
6
|
+
* materialisation) and by team (per-device display).
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* -
|
|
13
|
-
* -
|
|
8
|
+
* Fragments
|
|
9
|
+
* ---------
|
|
10
|
+
* Each of the two source dirs (core app dir, operator extension dir) may
|
|
11
|
+
* contribute:
|
|
12
|
+
* - `nav.json` — the base fragment, entries team = null (untagged).
|
|
13
|
+
* - `nav.<team>.json` — a team fragment; every entry inherits team
|
|
14
|
+
* `<team>` from the filename unless it carries its
|
|
15
|
+
* own `"team"` field.
|
|
16
|
+
* Fragments are read in a deterministic order (base `nav.json` first, then
|
|
17
|
+
* team fragments alphabetically) so composition is stable across devices.
|
|
14
18
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* field and no coupling to the .library-sync manifest internals: the page file
|
|
25
|
-
* library-sync lays down (or omits) is the source of truth. Hrefs whose shape
|
|
26
|
-
* we can't resolve to a local page (external links, deep paths) are left
|
|
27
|
-
* untouched.
|
|
19
|
+
* Why fragments instead of a single extension nav.json: the operator
|
|
20
|
+
* extension dir is materialised from MULTIPLE library repos (company, hq,
|
|
21
|
+
* wsd) merged by file path. A single shared `nav.json` collides — the
|
|
22
|
+
* highest-precedence repo's copy wins the whole file and silently drops the
|
|
23
|
+
* other teams' entries (this is exactly the HQ regression that motivated
|
|
24
|
+
* this change: wsd's nav.json won and HQ's article-ideas/content-calendar
|
|
25
|
+
* vanished from the sidebar). Distinctly-named `nav.<team>.json` fragments do
|
|
26
|
+
* not collide, so every team's entries materialise side by side; team
|
|
27
|
+
* scoping (below) then decides which to DISPLAY on a given device.
|
|
28
28
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* `
|
|
33
|
-
*
|
|
34
|
-
*
|
|
29
|
+
* Team declaration + resolution order for each entry:
|
|
30
|
+
* 1. the entry's own `"team"` field (string), if present;
|
|
31
|
+
* 2. else the team encoded in the fragment filename (`nav.<team>.json`);
|
|
32
|
+
* 3. else `null` (untagged — belongs to no team, always shown).
|
|
33
|
+
*
|
|
34
|
+
* Team scoping (display)
|
|
35
|
+
* ----------------------
|
|
36
|
+
* The device declares its team via `operator/config/preferences.json:team`
|
|
37
|
+
* (resolved by the server through readTeamScopeConfigSync and passed in as
|
|
38
|
+
* `opts.team`). The default scoped view keeps entries whose team is the
|
|
39
|
+
* device team OR untagged (null). Untagged-always-shown keeps core nav and
|
|
40
|
+
* shared/company pages visible everywhere and makes the whole mechanism
|
|
41
|
+
* backwards compatible: a device with no `team` configured, or fragments
|
|
42
|
+
* that carry no team, are shown unfiltered exactly as before. `?allTeams=1`
|
|
43
|
+
* (opts.allTeams) bypasses scoping; `?team=<id>` (opts.requestedTeam) is a
|
|
44
|
+
* strict single-team inspection filter, mirroring the desk/projects
|
|
45
|
+
* team-scope convention.
|
|
46
|
+
*
|
|
47
|
+
* Page presence (materialisation)
|
|
48
|
+
* -------------------------------
|
|
49
|
+
* Independent of team scoping and applied first: an entry whose resolvable
|
|
50
|
+
* target page is not present on disk is dropped (library-sync only writes a
|
|
51
|
+
* page file when the owning repo is mounted here, so page-presence is the
|
|
52
|
+
* per-entry materialisation signal). Hrefs whose shape we can't resolve to a
|
|
53
|
+
* local page (external links, deep paths) are left untouched. Entitlement
|
|
54
|
+
* (which pages materialise) and display (which team's nav shows) are now
|
|
55
|
+
* separate axes: a wsd-entitled HQ device has wsd pages ON DISK but, being an
|
|
56
|
+
* HQ device, does not SHOW wsd nav entries.
|
|
57
|
+
*
|
|
58
|
+
* Caching: the merged + team-tagged + page-scoped list is device-team
|
|
59
|
+
* independent, so it is cached against a signature over every discovered
|
|
60
|
+
* fragment's mtime (a new/removed fragment or a content change bumps it). The
|
|
61
|
+
* per-request team filter is applied on top of the cached list and, when it
|
|
62
|
+
* is a pass-through (allTeams, or no device team and no requested team),
|
|
63
|
+
* returns the cached array by reference.
|
|
35
64
|
*/
|
|
36
65
|
|
|
37
|
-
import { readFileSync, statSync, existsSync } from 'node:fs';
|
|
66
|
+
import { readFileSync, statSync, existsSync, readdirSync } from 'node:fs';
|
|
38
67
|
import { join } from 'node:path';
|
|
39
68
|
|
|
40
69
|
const CACHE = {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
items: null,
|
|
70
|
+
signature: null,
|
|
71
|
+
tagged: null,
|
|
44
72
|
};
|
|
45
73
|
|
|
74
|
+
const FRAGMENT_RE = /^nav(?:\.([A-Za-z0-9_-]+))?\.json$/;
|
|
75
|
+
|
|
46
76
|
function mtime(path) {
|
|
47
77
|
try { return statSync(path).mtimeMs; } catch { return 0; }
|
|
48
78
|
}
|
|
49
79
|
|
|
50
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Discover nav fragments in a dir. Returns an ordered list of
|
|
82
|
+
* { path, team, source } — base `nav.json` (team null) first, then
|
|
83
|
+
* `nav.<team>.json` fragments sorted by team. A missing dir yields [].
|
|
84
|
+
*/
|
|
85
|
+
function discoverFragments(dir, sourceLabel) {
|
|
86
|
+
if (!dir || !existsSync(dir)) return [];
|
|
87
|
+
let names;
|
|
88
|
+
try {
|
|
89
|
+
names = readdirSync(dir);
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
const base = [];
|
|
94
|
+
const teamed = [];
|
|
95
|
+
for (const name of names) {
|
|
96
|
+
const m = name.match(FRAGMENT_RE);
|
|
97
|
+
if (!m) continue;
|
|
98
|
+
const team = m[1] || null;
|
|
99
|
+
const entry = { path: join(dir, name), team, source: sourceLabel + ' nav manifest' };
|
|
100
|
+
if (team === null) base.push(entry);
|
|
101
|
+
else teamed.push(entry);
|
|
102
|
+
}
|
|
103
|
+
teamed.sort((a, b) => (a.team < b.team ? -1 : a.team > b.team ? 1 : 0));
|
|
104
|
+
return base.concat(teamed);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function validateItem(raw, source, fragmentTeam, warn) {
|
|
51
108
|
if (!raw || typeof raw !== 'object') {
|
|
52
109
|
warn('[nav] warning: ' + source + ' contains a non-object entry, skipping');
|
|
53
110
|
return null;
|
|
@@ -69,6 +126,8 @@ function validateItem(raw, source, warn) {
|
|
|
69
126
|
label: raw.label,
|
|
70
127
|
href: raw.href,
|
|
71
128
|
section: typeof raw.section === 'string' && raw.section ? raw.section : 'Workspace',
|
|
129
|
+
// Resolution order: inline entry.team > fragment-filename team > null.
|
|
130
|
+
team: typeof raw.team === 'string' && raw.team ? raw.team : fragmentTeam,
|
|
72
131
|
};
|
|
73
132
|
if (Array.isArray(raw.aliases)) {
|
|
74
133
|
item.aliases = raw.aliases.filter(function(a) { return typeof a === 'string' && a; });
|
|
@@ -78,7 +137,8 @@ function validateItem(raw, source, warn) {
|
|
|
78
137
|
return item;
|
|
79
138
|
}
|
|
80
139
|
|
|
81
|
-
function
|
|
140
|
+
function readFragment(fragment, warn) {
|
|
141
|
+
const { path, team, source } = fragment;
|
|
82
142
|
if (!existsSync(path)) return [];
|
|
83
143
|
let raw;
|
|
84
144
|
try {
|
|
@@ -93,31 +153,30 @@ function readManifest(path, source, warn) {
|
|
|
93
153
|
}
|
|
94
154
|
const items = [];
|
|
95
155
|
for (let i = 0; i < raw.length; i++) {
|
|
96
|
-
const item = validateItem(raw[i], source, warn);
|
|
156
|
+
const item = validateItem(raw[i], source, team, warn);
|
|
97
157
|
if (item) items.push(item);
|
|
98
158
|
}
|
|
99
159
|
return items;
|
|
100
160
|
}
|
|
101
161
|
|
|
102
162
|
/**
|
|
103
|
-
*
|
|
104
|
-
* the
|
|
105
|
-
*
|
|
106
|
-
* manifest order.
|
|
163
|
+
* Fold `additions` into a copy of `base`. An addition whose id already exists
|
|
164
|
+
* replaces the existing entry in place (preserving order); a new id is
|
|
165
|
+
* appended. `warn` fires on each collision — pass a no-op to fold silently.
|
|
107
166
|
*/
|
|
108
|
-
function
|
|
109
|
-
const merged =
|
|
167
|
+
function foldItems(base, additions, warn) {
|
|
168
|
+
const merged = base.slice();
|
|
110
169
|
const indexById = new Map();
|
|
111
170
|
for (let i = 0; i < merged.length; i++) {
|
|
112
171
|
indexById.set(merged[i].id, i);
|
|
113
172
|
}
|
|
114
|
-
for (const
|
|
115
|
-
if (indexById.has(
|
|
116
|
-
warn('[nav] warning: extension nav id "' +
|
|
117
|
-
merged[indexById.get(
|
|
173
|
+
for (const add of additions) {
|
|
174
|
+
if (indexById.has(add.id)) {
|
|
175
|
+
warn('[nav] warning: extension nav id "' + add.id + '" shadows core item');
|
|
176
|
+
merged[indexById.get(add.id)] = add;
|
|
118
177
|
} else {
|
|
119
|
-
indexById.set(
|
|
120
|
-
merged.push(
|
|
178
|
+
indexById.set(add.id, merged.length);
|
|
179
|
+
merged.push(add);
|
|
121
180
|
}
|
|
122
181
|
}
|
|
123
182
|
return merged;
|
|
@@ -126,11 +185,6 @@ function mergeItems(coreItems, extItems, warn) {
|
|
|
126
185
|
/**
|
|
127
186
|
* Classify an href by whether its target page is materialised on this device.
|
|
128
187
|
* Returns 'present', 'absent', or 'unresolvable'.
|
|
129
|
-
*
|
|
130
|
-
* - Core-shape paths (/core/tools/workspace/app/<file>) resolve under appDir.
|
|
131
|
-
* - Top-level extension-shape paths (/<file>.html) resolve under extDir or appDir.
|
|
132
|
-
* - Any other shape (external links, deep paths) is 'unresolvable' — we cannot
|
|
133
|
-
* (and should not) judge its local presence, so callers leave it untouched.
|
|
134
188
|
*/
|
|
135
189
|
function pagePresence(href, appDir, extDir) {
|
|
136
190
|
const corePrefix = '/core/tools/workspace/app/';
|
|
@@ -150,13 +204,8 @@ function pagePresence(href, appDir, extDir) {
|
|
|
150
204
|
}
|
|
151
205
|
|
|
152
206
|
/**
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
* materialised here), logging why. Entries whose page is present, or whose href
|
|
156
|
-
* shape we can't resolve to a local page, are kept. This is what makes a shared
|
|
157
|
-
* estate-wide nav manifest render cleanly per-device without hand-editing:
|
|
158
|
-
* hq-only entries vanish on non-HQ devices because their pages were never
|
|
159
|
-
* written there.
|
|
207
|
+
* Drop any entry whose target page is resolvable-in-shape but absent from
|
|
208
|
+
* disk (its owning library repo is not materialised here), logging why.
|
|
160
209
|
*/
|
|
161
210
|
function scopeToMaterialised(items, appDir, extDir, warn) {
|
|
162
211
|
const kept = [];
|
|
@@ -171,52 +220,92 @@ function scopeToMaterialised(items, appDir, extDir, warn) {
|
|
|
171
220
|
}
|
|
172
221
|
|
|
173
222
|
/**
|
|
174
|
-
*
|
|
175
|
-
*
|
|
223
|
+
* Apply the per-request team filter to the (cached, device-independent)
|
|
224
|
+
* tagged list. Returns the input array BY REFERENCE when the filter is a
|
|
225
|
+
* pass-through, so the loader preserves cache identity for the common case.
|
|
226
|
+
*
|
|
227
|
+
* Precedence (mirrors filterProjectsByTeam):
|
|
228
|
+
* 1. requestedTeam (?team=<id>) — strict: only entries whose team matches
|
|
229
|
+
* exactly. Untagged entries are excluded from this narrow inspection view.
|
|
230
|
+
* 2. allTeams (?allTeams=1) — bypass, return everything.
|
|
231
|
+
* 3. no device team — bypass, return everything (backwards compatible).
|
|
232
|
+
* 4. default — entries whose team is the device team OR untagged (null).
|
|
233
|
+
*/
|
|
234
|
+
function applyTeamFilter(items, opts) {
|
|
235
|
+
const requestedTeam = opts.requestedTeam || null;
|
|
236
|
+
if (requestedTeam) {
|
|
237
|
+
return items.filter(function(i) { return i.team === requestedTeam; });
|
|
238
|
+
}
|
|
239
|
+
if (opts.allTeams) return items;
|
|
240
|
+
|
|
241
|
+
const deviceTeam = opts.team;
|
|
242
|
+
const teamSet = deviceTeam == null
|
|
243
|
+
? null
|
|
244
|
+
: (deviceTeam instanceof Set
|
|
245
|
+
? deviceTeam
|
|
246
|
+
: new Set(Array.isArray(deviceTeam) ? deviceTeam : [deviceTeam]));
|
|
247
|
+
if (!teamSet || teamSet.size === 0) return items;
|
|
248
|
+
|
|
249
|
+
return items.filter(function(i) { return i.team == null || teamSet.has(i.team); });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Returns the composed, scoped nav items array.
|
|
176
254
|
*
|
|
177
255
|
* @param {object} opts
|
|
178
256
|
* @param {string} opts.appDir absolute path to core app dir
|
|
179
257
|
* @param {string} [opts.extDir] absolute path to operator extension dir
|
|
258
|
+
* @param {string|string[]|Set<string>} [opts.team] device display team(s);
|
|
259
|
+
* absent/null => nav shown unfiltered (backwards compatible)
|
|
260
|
+
* @param {boolean} [opts.allTeams] bypass team scoping (?allTeams=1)
|
|
261
|
+
* @param {string} [opts.requestedTeam] strict single-team filter (?team=<id>)
|
|
180
262
|
* @param {function(string):void} [opts.warn] warning sink (defaults to console.warn)
|
|
181
|
-
* @returns {Array<{id:string,label:string,href:string,section:string,aliases:string[]}>}
|
|
263
|
+
* @returns {Array<{id:string,label:string,href:string,section:string,team:string|null,aliases:string[]}>}
|
|
182
264
|
*/
|
|
183
265
|
export function loadNav(opts) {
|
|
184
266
|
const appDir = opts.appDir;
|
|
185
267
|
const extDir = opts.extDir || null;
|
|
186
268
|
const warn = opts.warn || function(m) { console.warn(m); };
|
|
187
269
|
|
|
188
|
-
const
|
|
189
|
-
const extManifest = extDir ? join(extDir, 'nav.json') : null;
|
|
270
|
+
const fragments = discoverFragments(appDir, 'core').concat(discoverFragments(extDir, 'extension'));
|
|
190
271
|
|
|
191
|
-
|
|
192
|
-
|
|
272
|
+
// Signature: every fragment path + mtime, sorted. A content change, a new
|
|
273
|
+
// fragment appearing, or a fragment removed all change the signature.
|
|
274
|
+
const signature = fragments
|
|
275
|
+
.map(function(f) { return f.path + ':' + mtime(f.path); })
|
|
276
|
+
.sort()
|
|
277
|
+
.join('|');
|
|
193
278
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
CACHE.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
return
|
|
200
|
-
}
|
|
279
|
+
let tagged;
|
|
280
|
+
if (CACHE.tagged !== null && CACHE.signature === signature) {
|
|
281
|
+
tagged = CACHE.tagged;
|
|
282
|
+
} else {
|
|
283
|
+
const coreFragments = fragments.filter(function(f) { return f.source.indexOf('core') === 0; });
|
|
284
|
+
const extFragments = fragments.filter(function(f) { return f.source.indexOf('core') !== 0; });
|
|
201
285
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
286
|
+
let coreItems = [];
|
|
287
|
+
for (const f of coreFragments) coreItems = foldItems(coreItems, readFragment(f, warn), function() {});
|
|
288
|
+
let extItems = [];
|
|
289
|
+
for (const f of extFragments) extItems = foldItems(extItems, readFragment(f, warn), function() {});
|
|
290
|
+
|
|
291
|
+
const merged = foldItems(coreItems, extItems, warn);
|
|
292
|
+
tagged = scopeToMaterialised(merged, appDir, extDir, warn);
|
|
293
|
+
|
|
294
|
+
CACHE.signature = signature;
|
|
295
|
+
CACHE.tagged = tagged;
|
|
296
|
+
}
|
|
208
297
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
298
|
+
return applyTeamFilter(tagged, {
|
|
299
|
+
team: opts.team,
|
|
300
|
+
allTeams: opts.allTeams === true,
|
|
301
|
+
requestedTeam: opts.requestedTeam || null,
|
|
302
|
+
});
|
|
213
303
|
}
|
|
214
304
|
|
|
215
305
|
/**
|
|
216
306
|
* Test-only helper: clear the in-memory cache so reloads re-read manifests.
|
|
217
307
|
*/
|
|
218
308
|
export function _resetNavCache() {
|
|
219
|
-
CACHE.
|
|
220
|
-
CACHE.
|
|
221
|
-
CACHE.items = null;
|
|
309
|
+
CACHE.signature = null;
|
|
310
|
+
CACHE.tagged = null;
|
|
222
311
|
}
|
|
@@ -26,6 +26,7 @@ import { resolve, join, extname } from 'node:path';
|
|
|
26
26
|
import { fileURLToPath } from 'node:url';
|
|
27
27
|
import { handleApi } from './api.mjs';
|
|
28
28
|
import { loadNav } from './nav.mjs';
|
|
29
|
+
import { readTeamScopeConfigSync } from '../../engineering-pa-core/src/desk/team-scope.mjs';
|
|
29
30
|
import { startPoller as startStatusMonPoller } from './status-mon/poller.mjs';
|
|
30
31
|
|
|
31
32
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
@@ -73,6 +74,14 @@ const warnAlways = console.warn.bind(console);
|
|
|
73
74
|
const EXT_DIR_PATH = join(REPO_ROOT, 'operator', 'extensions', 'workspace');
|
|
74
75
|
const EXT_DIR = existsSync(EXT_DIR_PATH) ? EXT_DIR_PATH : null;
|
|
75
76
|
|
|
77
|
+
// --- Device display team ---
|
|
78
|
+
// The nav sidebar is scoped to the device's team (operator/config/
|
|
79
|
+
// preferences.json:team), reusing the same team-scope config the project
|
|
80
|
+
// surfaces use (PR #87). null => nav shown unfiltered (backwards compatible).
|
|
81
|
+
// Resolved once at startup; a `team` change is a workspace restart, same as
|
|
82
|
+
// the port it sits beside in preferences.json.
|
|
83
|
+
const DEVICE_TEAM = readTeamScopeConfigSync(REPO_ROOT).team;
|
|
84
|
+
|
|
76
85
|
// --- Args ---
|
|
77
86
|
const args = process.argv.slice(2);
|
|
78
87
|
let port = 4200;
|
|
@@ -315,10 +324,19 @@ const server = createServer(async (req, res) => {
|
|
|
315
324
|
const extRoute = extRoutes.find(r => r.method === req.method && r.pathname === pathname);
|
|
316
325
|
if (extRoute) { extRoute.handler(req, res, url, REPO_ROOT); return; }
|
|
317
326
|
|
|
318
|
-
// GET /__api/nav -- merged core + extension nav manifest
|
|
327
|
+
// GET /__api/nav -- merged core + extension nav manifest, team-scoped to
|
|
328
|
+
// this device. ?allTeams=1 bypasses scoping; ?team=<id> is a strict
|
|
329
|
+
// single-team inspection filter (mirrors the desk/projects convention).
|
|
319
330
|
if (req.method === 'GET' && pathname === '/__api/nav') {
|
|
320
331
|
try {
|
|
321
|
-
const items = loadNav({
|
|
332
|
+
const items = loadNav({
|
|
333
|
+
appDir: APP_DIR,
|
|
334
|
+
extDir: EXT_DIR,
|
|
335
|
+
team: DEVICE_TEAM,
|
|
336
|
+
allTeams: url.searchParams.get('allTeams') === '1',
|
|
337
|
+
requestedTeam: url.searchParams.get('team'),
|
|
338
|
+
warn: warn,
|
|
339
|
+
});
|
|
322
340
|
const body = JSON.stringify({ items: items });
|
|
323
341
|
res.writeHead(200, {
|
|
324
342
|
'Content-Type': 'application/json',
|
package/package.json
CHANGED