@stravica-ai/digital-operator-pa 0.1.0 → 0.1.2
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.
|
@@ -23,8 +23,21 @@
|
|
|
23
23
|
// under that subtree) — exactly the case a brand-new mount hits. `=all`
|
|
24
24
|
// makes git enumerate every untracked file individually, so each path
|
|
25
25
|
// is checked against the exclusion prefixes on its own merits.
|
|
26
|
+
//
|
|
27
|
+
// `-z` is equally load-bearing: without it, git C-quotes any path
|
|
28
|
+
// containing a space or non-ASCII byte (`core.quotePath` defaults true),
|
|
29
|
+
// emitting `?? "dir/Club photo.jpeg"` with the double-quotes and octal
|
|
30
|
+
// escapes as LITERAL characters in the output. registry-scope.mjs then
|
|
31
|
+
// slices that quoted string off the line and hands it to `git add --`
|
|
32
|
+
// verbatim, so git looks for a file literally named `"dir/Club
|
|
33
|
+
// photo.jpeg"` (quotes included) and fails with `fatal: pathspec … did
|
|
34
|
+
// not match any files`, taking the whole sync tick down with exit 3.
|
|
35
|
+
// `-z` switches the output to NUL-delimited records that are NEVER
|
|
36
|
+
// quoted (git emits the raw bytes), eliminating the entire quoted-
|
|
37
|
+
// pathspec bug class — spaces, quotes, backslashes, and unicode alike.
|
|
38
|
+
// See parsePorcelainPaths in registry-scope.mjs for the NUL parser.
|
|
26
39
|
export async function statusPorcelain(runner, { cwd }) {
|
|
27
|
-
return runner.spawn('git', ['status', '--porcelain', '--untracked-files=all'], { cwd });
|
|
40
|
+
return runner.spawn('git', ['status', '--porcelain', '-z', '--untracked-files=all'], { cwd });
|
|
28
41
|
}
|
|
29
42
|
|
|
30
43
|
export async function currentBranch(runner, { cwd }) {
|
|
@@ -93,22 +106,37 @@ export async function push(runner, { cwd }) {
|
|
|
93
106
|
// git-status(1) `Short Format` — "Unmerged entries have the following
|
|
94
107
|
// second column"): DD, AU, UD, UA, DU, AA, UU. Anything else is a normal
|
|
95
108
|
// modification (M ), untracked (??), etc.
|
|
109
|
+
//
|
|
110
|
+
// Uses `-z` (NUL-delimited, never C-quoted) for the same reason
|
|
111
|
+
// statusPorcelain does: a conflicted path with a space or non-ASCII byte
|
|
112
|
+
// would otherwise be emitted C-quoted and flow — quotes and all — into
|
|
113
|
+
// `git checkout --ours -- <path>` / `git add -- <path>` during
|
|
114
|
+
// auto-resolve, failing the same way the staging path did. NUL records
|
|
115
|
+
// carry the raw bytes, so the paths passed downstream match on disk.
|
|
96
116
|
export async function listConflictedPaths(runner, { cwd }) {
|
|
97
|
-
const r = await runner.spawn('git', ['status', '--porcelain'], { cwd });
|
|
117
|
+
const r = await runner.spawn('git', ['status', '--porcelain', '-z'], { cwd });
|
|
98
118
|
if (r.code !== 0) {
|
|
99
119
|
return { code: r.code, stderr: r.stderr, paths: [] };
|
|
100
120
|
}
|
|
101
121
|
const paths = [];
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
122
|
+
// `-z` records are NUL-terminated (trailing empty token after the final
|
|
123
|
+
// NUL). Each record is `XY <path>`; XY is exactly two chars followed by
|
|
124
|
+
// a space. Renamed/copied entries (`R`/`C` in the X column) carry a
|
|
125
|
+
// SECOND NUL-terminated field — the source path — which we must step
|
|
126
|
+
// over so it is not mis-scanned as its own record. Conflict codes never
|
|
127
|
+
// apply to renames, so a rename record never contributes a path here.
|
|
128
|
+
const records = r.stdout.split('\0');
|
|
129
|
+
for (let i = 0; i < records.length; i += 1) {
|
|
130
|
+
const rec = records[i];
|
|
131
|
+
if (rec.length < 4) continue;
|
|
132
|
+
const xy = rec.slice(0, 2);
|
|
133
|
+
if (xy[0] === 'R' || xy[0] === 'C') {
|
|
134
|
+
i += 1; // consume the trailing source-path field
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
109
137
|
if (xy === 'DD' || xy === 'AU' || xy === 'UD' || xy === 'UA'
|
|
110
138
|
|| xy === 'DU' || xy === 'AA' || xy === 'UU') {
|
|
111
|
-
paths.push(
|
|
139
|
+
paths.push(rec.slice(3));
|
|
112
140
|
}
|
|
113
141
|
}
|
|
114
142
|
return { code: 0, stderr: '', paths };
|
|
@@ -171,30 +171,44 @@ export function computeCommitScope({ repoRoot, hostnameFn = osHostname }) {
|
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
/**
|
|
174
|
-
* Parse `git status --porcelain` output into a flat list of paths.
|
|
175
|
-
* entries
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
174
|
+
* Parse `git status --porcelain -z` output into a flat list of paths.
|
|
175
|
+
* Rename entries contribute BOTH paths — `git add -- <path>` correctly
|
|
176
|
+
* stages a deletion when given an explicit path that no longer exists on
|
|
177
|
+
* disk (git >= 2.0), so staging both sides of a rename is sufficient
|
|
178
|
+
* without needing `-A` semantics.
|
|
179
179
|
*
|
|
180
|
-
*
|
|
181
|
-
* git.mjs
|
|
182
|
-
*
|
|
180
|
+
* Expects the `-z` (NUL-delimited) format produced by
|
|
181
|
+
* `git.mjs`'s statusPorcelain — NOT the default newline format. `-z`
|
|
182
|
+
* records are never C-quoted (git emits raw path bytes), which is the
|
|
183
|
+
* whole point: the default format wraps any path containing a space or
|
|
184
|
+
* non-ASCII byte in literal double-quotes with octal escapes, and those
|
|
185
|
+
* quoted strings then fail `git add -- <path>` with `fatal: pathspec …
|
|
186
|
+
* did not match any files`. Parsing `-z` eliminates that entire bug class
|
|
187
|
+
* (spaces, quotes, backslashes, unicode).
|
|
188
|
+
*
|
|
189
|
+
* `-z` record shape:
|
|
190
|
+
* - normal entry: `XY <path>` terminated by NUL
|
|
191
|
+
* - rename/copy: `XY <newPath>` NUL `<oldPath>` NUL — the source path
|
|
192
|
+
* is a SEPARATE bare field (no XY prefix) that FOLLOWS the new path.
|
|
193
|
+
* Note the order is reversed vs the newline format's `old -> new`.
|
|
194
|
+
* A trailing empty token follows the final NUL; the length guard skips it.
|
|
183
195
|
*
|
|
184
196
|
* @param {string} porcelainOutput
|
|
185
197
|
* @returns {string[]}
|
|
186
198
|
*/
|
|
187
199
|
export function parsePorcelainPaths(porcelainOutput) {
|
|
188
200
|
const out = [];
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
201
|
+
const records = porcelainOutput.split('\0');
|
|
202
|
+
for (let i = 0; i < records.length; i += 1) {
|
|
203
|
+
const rec = records[i];
|
|
204
|
+
if (rec.length < 4) continue;
|
|
205
|
+
const xy = rec.slice(0, 2);
|
|
206
|
+
out.push(rec.slice(3));
|
|
207
|
+
// Rename/copy: the next NUL field is the source path (bare, unquoted).
|
|
208
|
+
if (xy[0] === 'R' || xy[0] === 'C') {
|
|
209
|
+
const source = records[i + 1];
|
|
210
|
+
if (source !== undefined && source.length > 0) out.push(source);
|
|
211
|
+
i += 1;
|
|
198
212
|
}
|
|
199
213
|
}
|
|
200
214
|
return out;
|
|
@@ -12,9 +12,26 @@
|
|
|
12
12
|
* - A missing or absent operator manifest is not an error.
|
|
13
13
|
* - A malformed manifest is logged and skipped; the other side still loads.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* Per-device scoping: after merging, entries whose target page is not
|
|
16
|
+
* materialised on THIS device are dropped (not just warned). The estate runs
|
|
17
|
+
* one shared nav manifest that lists every page across all library repos, but
|
|
18
|
+
* `library-sync` only writes a page file onto a device when the repo that owns
|
|
19
|
+
* it is mounted there. So the presence of the href's target page file IS the
|
|
20
|
+
* per-entry materialisation signal — an hq-only page (e.g. article-ideas,
|
|
21
|
+
* content-calendar, both under operator/extensions/workspace/) is absent on a
|
|
22
|
+
* device that doesn't mount stravica-library-hq, so its nav entry is hidden
|
|
23
|
+
* rather than rendered as a dangling 404 link. This needs no per-entry "owner"
|
|
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.
|
|
28
|
+
*
|
|
29
|
+
* Caching: each load checks both manifest mtimes. The cached (merged + scoped)
|
|
30
|
+
* list is returned unchanged if neither file changed since the last load.
|
|
31
|
+
* Missing files are tracked with mtime 0 so their appearance triggers a reload.
|
|
32
|
+
* `library-sync` rewrites the extension nav.json as part of the same mount tree
|
|
33
|
+
* whenever it materialises or removes pages, so a materialisation transition
|
|
34
|
+
* always bumps a manifest mtime and re-triggers scoping.
|
|
18
35
|
*/
|
|
19
36
|
|
|
20
37
|
import { readFileSync, statSync, existsSync } from 'node:fs';
|
|
@@ -107,35 +124,50 @@ function mergeItems(coreItems, extItems, warn) {
|
|
|
107
124
|
}
|
|
108
125
|
|
|
109
126
|
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
127
|
+
* Classify an href by whether its target page is materialised on this device.
|
|
128
|
+
* 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.
|
|
113
134
|
*/
|
|
114
|
-
function
|
|
135
|
+
function pagePresence(href, appDir, extDir) {
|
|
136
|
+
const corePrefix = '/core/tools/workspace/app/';
|
|
137
|
+
if (href.indexOf(corePrefix) === 0) {
|
|
138
|
+
const name = href.slice(corePrefix.length).split('?')[0].split('#')[0];
|
|
139
|
+
if (!name) return 'unresolvable';
|
|
140
|
+
return existsSync(join(appDir, name)) ? 'present' : 'absent';
|
|
141
|
+
}
|
|
142
|
+
const topMatch = href.match(/^\/([^/?#]+\.html)(?:[?#].*)?$/);
|
|
143
|
+
if (topMatch) {
|
|
144
|
+
const name = topMatch[1];
|
|
145
|
+
const inExt = extDir && existsSync(join(extDir, name));
|
|
146
|
+
const inApp = existsSync(join(appDir, name));
|
|
147
|
+
return (inExt || inApp) ? 'present' : 'absent';
|
|
148
|
+
}
|
|
149
|
+
return 'unresolvable';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Scope the merged list to this device: drop any entry whose target page is
|
|
154
|
+
* resolvable-in-shape but absent from disk (i.e. its owning library repo is not
|
|
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.
|
|
160
|
+
*/
|
|
161
|
+
function scopeToMaterialised(items, appDir, extDir, warn) {
|
|
162
|
+
const kept = [];
|
|
115
163
|
for (const item of items) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const corePrefix = '/core/tools/workspace/app/';
|
|
119
|
-
if (href.indexOf(corePrefix) === 0) {
|
|
120
|
-
const name = href.slice(corePrefix.length).split('?')[0].split('#')[0];
|
|
121
|
-
if (!existsSync(join(appDir, name))) {
|
|
122
|
-
warn('[nav] warning: nav item "' + item.id + '" points at missing page ' + href);
|
|
123
|
-
}
|
|
164
|
+
if (pagePresence(item.href, appDir, extDir) === 'absent') {
|
|
165
|
+
warn('[nav] hidden: nav item "' + item.id + '" page not materialised on this device (' + item.href + ')');
|
|
124
166
|
continue;
|
|
125
167
|
}
|
|
126
|
-
|
|
127
|
-
const topMatch = href.match(/^\/([^/?#]+\.html)(?:[?#].*)?$/);
|
|
128
|
-
if (topMatch) {
|
|
129
|
-
const name = topMatch[1];
|
|
130
|
-
const inExt = extDir && existsSync(join(extDir, name));
|
|
131
|
-
const inApp = existsSync(join(appDir, name));
|
|
132
|
-
if (!inExt && !inApp) {
|
|
133
|
-
warn('[nav] warning: nav item "' + item.id + '" points at missing page ' + href);
|
|
134
|
-
}
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
// Any other shape we leave alone — might be an external link or a deep core path.
|
|
168
|
+
kept.push(item);
|
|
138
169
|
}
|
|
170
|
+
return kept;
|
|
139
171
|
}
|
|
140
172
|
|
|
141
173
|
/**
|
|
@@ -172,12 +204,12 @@ export function loadNav(opts) {
|
|
|
172
204
|
? readManifest(extManifest, 'extension nav manifest', warn)
|
|
173
205
|
: [];
|
|
174
206
|
const merged = mergeItems(coreItems, extItems, warn);
|
|
175
|
-
|
|
207
|
+
const scoped = scopeToMaterialised(merged, appDir, extDir, warn);
|
|
176
208
|
|
|
177
209
|
CACHE.coreMtimeMs = coreMtime;
|
|
178
210
|
CACHE.extMtimeMs = extMtime;
|
|
179
|
-
CACHE.items =
|
|
180
|
-
return
|
|
211
|
+
CACHE.items = scoped;
|
|
212
|
+
return scoped;
|
|
181
213
|
}
|
|
182
214
|
|
|
183
215
|
/**
|
|
@@ -60,6 +60,11 @@ const APP_DIR = resolve(__dirname, '..', 'app');
|
|
|
60
60
|
const DEBUG = process.env.WS_DEBUG === '1';
|
|
61
61
|
const log = DEBUG ? console.log.bind(console) : function () {};
|
|
62
62
|
const warn = DEBUG ? console.warn.bind(console) : function () {};
|
|
63
|
+
// Always-on warning sink. Most diagnostics stay silent unless WS_DEBUG=1, but
|
|
64
|
+
// failures that silently break operator-visible surface area (e.g. a whole
|
|
65
|
+
// extension failing to load, presenting as a vague UI outage) must ALWAYS be
|
|
66
|
+
// visible so the root cause is diagnosable without re-running under WS_DEBUG.
|
|
67
|
+
const warnAlways = console.warn.bind(console);
|
|
63
68
|
|
|
64
69
|
// --- Extension dir ---
|
|
65
70
|
// Convention: operator/extensions/workspace/ overlays core app pages and
|
|
@@ -461,7 +466,11 @@ async function loadExtensions() {
|
|
|
461
466
|
});
|
|
462
467
|
}
|
|
463
468
|
} catch (e) {
|
|
464
|
-
warn
|
|
469
|
+
// Always warn — a failed extension import takes out every route the
|
|
470
|
+
// extension registers, which surfaces to the operator as a broken UI with
|
|
471
|
+
// no obvious cause. Name the extension and the error; keep the server
|
|
472
|
+
// alive (a bad extension must not take down the whole workspace).
|
|
473
|
+
warnAlways('[workspace] Extension failed to load: ' + extApiPath + ' — ' + (e && e.stack ? e.stack : e));
|
|
465
474
|
}
|
|
466
475
|
}
|
|
467
476
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stravica-ai/digital-operator-pa",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Digital Operator PA — single-bundled public package surface (bins + library) for the platform.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"license": "MIT",
|
|
36
36
|
"repository": {
|
|
37
37
|
"type": "git",
|
|
38
|
-
"url": "git+https://github.com/Stravica/
|
|
38
|
+
"url": "git+https://github.com/Stravica/digital-operator-pa.git",
|
|
39
39
|
"directory": "packages/engineering-pa"
|
|
40
40
|
},
|
|
41
41
|
"publishConfig": {
|