@quill507/dsh-orchestrator-preset 0.1.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.
- package/.gitattributes +20 -0
- package/.gitignore +140 -0
- package/LICENSE +21 -0
- package/README.md +533 -0
- package/README.zh.md +222 -0
- package/THIRD_PARTY_NOTICES.md +67 -0
- package/cordis.patch.yml +399 -0
- package/extensions/dsh/aegis-prefix.js +197 -0
- package/extensions/dsh/index.js +41 -0
- package/install.sh +193 -0
- package/lane-composition.mjs +288 -0
- package/package.json +69 -0
- package/personas/analyst.md +74 -0
- package/personas/archivist.md +75 -0
- package/personas/auditor.md +79 -0
- package/personas/forge.md +101 -0
- package/personas/orchestrator.md +176 -0
- package/personas/planner.md +114 -0
- package/personas/reader.md +56 -0
- package/personas/scout.md +60 -0
- package/personas/seer.md +69 -0
- package/personas/wright.md +58 -0
- package/plan-aware-persona.mjs +102 -0
- package/routing-sections.mjs +120 -0
- package/skills/orch-delegation-brief/SKILL.md +55 -0
- package/skills/orch-discussion-protocol/SKILL.md +61 -0
- package/skills/orch-evidence-protocol/SKILL.md +88 -0
- package/skills/orch-real-path-testing/SKILL.md +66 -0
- package/tools/audit-personas.mjs +215 -0
- package/tools/gen-cordis-patch.mjs +424 -0
- package/tools/preset-declaration.mjs +468 -0
- package/tools/verify-install.sh +643 -0
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# verify-install.sh — is this preset really installed, and really selected?
|
|
4
|
+
#
|
|
5
|
+
# ./tools/verify-install.sh # checks the "desktop" profile
|
|
6
|
+
# ./tools/verify-install.sh web
|
|
7
|
+
# DSH_PKG_ROOT=/path/to/@deepseek-ai/dsh ./tools/verify-install.sh
|
|
8
|
+
#
|
|
9
|
+
# Read-only. It composes the profile tree in memory and never writes to a DSH
|
|
10
|
+
# home; check 5 asserts that, rather than assuming it.
|
|
11
|
+
#
|
|
12
|
+
# Five checks, two of which were wrong as originally written. What changed:
|
|
13
|
+
#
|
|
14
|
+
# 1. selectedDefault in the profile patch unchanged — still valid
|
|
15
|
+
# 2. the bundle link is a real symlink unchanged — test -L, not -e
|
|
16
|
+
# 3. the bundle is readable THROUGH the link unchanged — present != usable
|
|
17
|
+
# 4. the patch generator is in sync CHANGED — see below
|
|
18
|
+
# 5. which preset is actually selected REPLACED — see below
|
|
19
|
+
#
|
|
20
|
+
# Why 4 changed: the generator resolves its skeleton from $DSH_HOME, falling back
|
|
21
|
+
# to ~/.dsh. A shell that happens to export DSH_HOME resolves fine; a plain shell
|
|
22
|
+
# without it exited 2, which reads as "the tool is broken". So this runs the tool
|
|
23
|
+
# with DSH_HOME explicitly UNSET — the case that used to fail. The tool was
|
|
24
|
+
# repaired to fall back to the default home, so it should now pass either way, and
|
|
25
|
+
# this check proves that rather than assuming it.
|
|
26
|
+
#
|
|
27
|
+
# Why 5 was replaced rather than repaired: `dsh --profile <name> --dump-config` is
|
|
28
|
+
# refused outright by the launcher for an Electron-managed profile such as
|
|
29
|
+
# `desktop`, with no flag or environment escape. And the profile's `cordis.yml` is
|
|
30
|
+
# a four-line empty root the host rewrites on every launch — it structurally
|
|
31
|
+
# cannot contain the rows, and anything dumped into it dies at the next boot. The
|
|
32
|
+
# original check grepped that file and could never have worked. This check instead
|
|
33
|
+
# composes the tree with the host's own read-only loader, skipping the step that
|
|
34
|
+
# rewrites cordis.yml, and greps the composed output.
|
|
35
|
+
#
|
|
36
|
+
# MIT licensed. See ../LICENSE.
|
|
37
|
+
|
|
38
|
+
set -uo pipefail
|
|
39
|
+
|
|
40
|
+
PROFILE="${1:-desktop}"
|
|
41
|
+
REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
42
|
+
|
|
43
|
+
PASS=0; FAIL=0
|
|
44
|
+
TMP="$(mktemp -d)"
|
|
45
|
+
trap 'rm -rf "$TMP"' EXIT
|
|
46
|
+
|
|
47
|
+
ok() { PASS=$((PASS+1)); printf 'PASS %s\n' "$*"; }
|
|
48
|
+
bad() { FAIL=$((FAIL+1)); printf 'FAIL %s\n' "$*"; }
|
|
49
|
+
# Neither a pass nor a fail: something did not run, and saying so is the point.
|
|
50
|
+
# A warning that quietly counted as a pass would be the same lie as an empty
|
|
51
|
+
# grep result.
|
|
52
|
+
warn() { printf 'WARN %s\n' "$*"; }
|
|
53
|
+
note() { printf ' %s\n' "$*"; }
|
|
54
|
+
head_() { printf '\n== %s\n' "$*"; }
|
|
55
|
+
|
|
56
|
+
# --- resolve the DSH home and the dsh package root, dynamically ------------
|
|
57
|
+
# Neither is hardcoded: both move between machines, and a check that only works on
|
|
58
|
+
# one of them is not a check.
|
|
59
|
+
head_ "environment"
|
|
60
|
+
DSH_PKG_ROOT="${DSH_PKG_ROOT:-}"
|
|
61
|
+
if [ -z "$DSH_PKG_ROOT" ]; then
|
|
62
|
+
shim="$(command -v dsh 2>/dev/null || true)"
|
|
63
|
+
if [ -n "$shim" ]; then
|
|
64
|
+
base="$(cd -- "$(dirname -- "$shim")" && pwd)"
|
|
65
|
+
for cand in "$base/node_modules/@deepseek-ai/dsh" "$base/../node_modules/@deepseek-ai/dsh" \
|
|
66
|
+
"$base/../lib/node_modules/@deepseek-ai/dsh"; do
|
|
67
|
+
if [ -f "$cand/package.json" ]; then DSH_PKG_ROOT="$(cd -- "$cand" && pwd)"; break; fi
|
|
68
|
+
done
|
|
69
|
+
fi
|
|
70
|
+
fi
|
|
71
|
+
if [ -z "$DSH_PKG_ROOT" ] || [ ! -f "$DSH_PKG_ROOT/package.json" ]; then
|
|
72
|
+
echo "!! could not locate the @deepseek-ai/dsh package root" >&2
|
|
73
|
+
echo " set DSH_PKG_ROOT=/path/to/@deepseek-ai/dsh and re-run" >&2
|
|
74
|
+
exit 2
|
|
75
|
+
fi
|
|
76
|
+
note "dsh package root : $DSH_PKG_ROOT"
|
|
77
|
+
|
|
78
|
+
# The DSH home is the host's own answer, not a guess about ~.
|
|
79
|
+
DSH_HOME_ABS="$(cd -- "$DSH_PKG_ROOT" && node --input-type=module -e \
|
|
80
|
+
"const {resolveDshHome} = await import('@deepseek-ai/dsh-home-paths'); process.stdout.write(resolveDshHome());")"
|
|
81
|
+
[ -n "$DSH_HOME_ABS" ] || { echo "!! could not resolve the DSH home" >&2; exit 2; }
|
|
82
|
+
note "DSH home : $DSH_HOME_ABS"
|
|
83
|
+
|
|
84
|
+
PROFILE_DIR="$DSH_HOME_ABS/profiles/$PROFILE"
|
|
85
|
+
PATCH_YML="$PROFILE_DIR/cordis.patch.yml"
|
|
86
|
+
COMPOSED_CORDIS_YML="$PROFILE_DIR/cordis.yml"
|
|
87
|
+
|
|
88
|
+
# The package name is read, never written down. It is the one string in this file
|
|
89
|
+
# that changes on a rename, and a hardcoded copy of it is a bug that only fires
|
|
90
|
+
# after the rename — which is exactly when nobody is looking at this script.
|
|
91
|
+
BUNDLE_NAME="$(node -e "
|
|
92
|
+
const fs = require('node:fs');
|
|
93
|
+
process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1], 'utf8')).name || '');
|
|
94
|
+
" "$REPO_ROOT/package.json" 2>/dev/null)"
|
|
95
|
+
if [ -z "$BUNDLE_NAME" ]; then
|
|
96
|
+
echo "!! cannot read the package name from $REPO_ROOT/package.json" >&2
|
|
97
|
+
exit 2
|
|
98
|
+
fi
|
|
99
|
+
BUNDLE_LINK="$PROFILE_DIR/node_modules/$BUNDLE_NAME"
|
|
100
|
+
|
|
101
|
+
if [ ! -d "$PROFILE_DIR" ]; then
|
|
102
|
+
echo "!! no such profile: $PROFILE_DIR" >&2
|
|
103
|
+
exit 2
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
# ===========================================================================
|
|
107
|
+
head_ "check 1 — the profile patch selects this preset (unchanged form)"
|
|
108
|
+
# ===========================================================================
|
|
109
|
+
# Still valid exactly as originally written. The row lives in the profile's own
|
|
110
|
+
# patch layer, so a plain anchored grep is the right instrument.
|
|
111
|
+
n="$(grep -c 'selectedDefault: dsh-orchestrator-preset$' "$PATCH_YML" 2>/dev/null || echo 0)"
|
|
112
|
+
if [ "$n" = "1" ]; then
|
|
113
|
+
ok "check 1 selectedDefault: dsh-orchestrator-preset occurs exactly once in the profile patch"
|
|
114
|
+
else
|
|
115
|
+
bad "check 1 expected exactly 1 occurrence, found $n ($PATCH_YML)"
|
|
116
|
+
fi
|
|
117
|
+
|
|
118
|
+
# ===========================================================================
|
|
119
|
+
head_ "check 2 — the bundle link is a REAL symlink (unchanged form)"
|
|
120
|
+
# ===========================================================================
|
|
121
|
+
# This distinction is the whole point of the check. `test -e` follows a symlink,
|
|
122
|
+
# so a link that some tool silently degraded into a real directory COPY still
|
|
123
|
+
# passes it — and a copy freezes the bundle while every later edit to the real
|
|
124
|
+
# bundle is ignored. Only `test -L` asks the question we actually mean.
|
|
125
|
+
if [ -L "$BUNDLE_LINK" ]; then
|
|
126
|
+
ok "check 2 $BUNDLE_NAME is a real symlink"
|
|
127
|
+
note "-> $(readlink "$BUNDLE_LINK")"
|
|
128
|
+
elif [ -e "$BUNDLE_LINK" ]; then
|
|
129
|
+
bad "check 2 it exists but is NOT a symlink — a copy would freeze the bundle"
|
|
130
|
+
note "test -e would have passed this; test -L is why the check exists"
|
|
131
|
+
else
|
|
132
|
+
bad "check 2 missing: $BUNDLE_LINK"
|
|
133
|
+
note "add the link: dependency to the profile's package.json, then pnpm install"
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
# ===========================================================================
|
|
137
|
+
head_ "check 3 — the bundle is readable THROUGH the link (unchanged form)"
|
|
138
|
+
# ===========================================================================
|
|
139
|
+
# Present is not usable. This resolves package.json by the name the patch rows
|
|
140
|
+
# use, so it proves the same resolution path the host takes at boot.
|
|
141
|
+
if [ -L "$BUNDLE_LINK" ] || [ -e "$BUNDLE_LINK" ]; then
|
|
142
|
+
if ( cd -- "$PROFILE_DIR" && BUNDLE_NAME="$BUNDLE_NAME" node --input-type=module -e '
|
|
143
|
+
import { createRequire } from "node:module";
|
|
144
|
+
import { realpathSync } from "node:fs";
|
|
145
|
+
const r = createRequire(process.cwd() + "/package.json");
|
|
146
|
+
const p = r.resolve(process.env.BUNDLE_NAME + "/package.json");
|
|
147
|
+
process.stdout.write(realpathSync(p));' ) >"$TMP/resolved" 2>"$TMP/resolve.err"; then
|
|
148
|
+
ok "check 3 the bundle resolves by package name and its real path is reachable"
|
|
149
|
+
note "-> $(cat "$TMP/resolved")"
|
|
150
|
+
else
|
|
151
|
+
bad "check 3 present but NOT resolvable by package name:"
|
|
152
|
+
note "$(head -3 "$TMP/resolve.err" | tr '\n' ' ')"
|
|
153
|
+
fi
|
|
154
|
+
else
|
|
155
|
+
bad "check 3 skipped: no link to read through (see check 2)"
|
|
156
|
+
fi
|
|
157
|
+
|
|
158
|
+
# ===========================================================================
|
|
159
|
+
head_ "check 4 — the patch generator is in sync, with DSH_HOME UNSET (CHANGED form)"
|
|
160
|
+
# ===========================================================================
|
|
161
|
+
# The case that used to fail. env -u removes the variable for the child only, so
|
|
162
|
+
# this script's own environment is untouched.
|
|
163
|
+
GEN="$REPO_ROOT/tools/gen-cordis-patch.mjs"
|
|
164
|
+
if [ ! -f "$GEN" ]; then
|
|
165
|
+
bad "check 4 generator not found at $GEN"
|
|
166
|
+
else
|
|
167
|
+
before_m="$(stat -c %Y "$REPO_ROOT/cordis.patch.yml" 2>/dev/null || echo none)"
|
|
168
|
+
if ( cd -- "$REPO_ROOT" && env -u DSH_HOME node tools/gen-cordis-patch.mjs --check ) >"$TMP/gen.out" 2>&1; then
|
|
169
|
+
ok "check 4 --check exited 0 with DSH_HOME unset"
|
|
170
|
+
note "$(grep -m1 -E '^(ok|stale|wrote) ' "$TMP/gen.out" || head -1 "$TMP/gen.out")"
|
|
171
|
+
else
|
|
172
|
+
rc=$?
|
|
173
|
+
bad "check 4 --check exited $rc with DSH_HOME unset (0 expected)"
|
|
174
|
+
note "$(head -4 "$TMP/gen.out" | tr '\n' ' ')"
|
|
175
|
+
fi
|
|
176
|
+
after_m="$(stat -c %Y "$REPO_ROOT/cordis.patch.yml" 2>/dev/null || echo none)"
|
|
177
|
+
if [ "$before_m" = "$after_m" ]; then
|
|
178
|
+
note "--check did not rewrite cordis.patch.yml, as documented"
|
|
179
|
+
else
|
|
180
|
+
bad "check 4 --check modified cordis.patch.yml — it is not read-only!"
|
|
181
|
+
fi
|
|
182
|
+
fi
|
|
183
|
+
|
|
184
|
+
# ===========================================================================
|
|
185
|
+
head_ "check 5 — the COMPOSED tree really selects this preset (REPLACED form)"
|
|
186
|
+
# ===========================================================================
|
|
187
|
+
# --dump-config is unusable here: the launcher refuses it for an Electron-managed
|
|
188
|
+
# profile, unconditionally. So compose the tree in memory with the host's own
|
|
189
|
+
# loader and skip the step that rewrites cordis.yml.
|
|
190
|
+
#
|
|
191
|
+
# Two traps this avoids, both paid for by an earlier attempt:
|
|
192
|
+
# * the dump-config helper is minified, so collectConfigDumpLayers is exported
|
|
193
|
+
# under the name `t`; the file is located by probing for that export rather
|
|
194
|
+
# than by hardcoding a content hash, which changes between builds;
|
|
195
|
+
# * on Windows an absolute path inside import() needs pathToFileURL().href or
|
|
196
|
+
# it fails with ERR_UNSUPPORTED_ESM_URL_SCHEME.
|
|
197
|
+
#
|
|
198
|
+
# cwd must be the dsh package root so the bare specifiers resolve.
|
|
199
|
+
|
|
200
|
+
cordis_m_before="$(stat -c '%Y:%s' "$COMPOSED_CORDIS_YML" 2>/dev/null || echo absent)"
|
|
201
|
+
compose() {
|
|
202
|
+
( cd -- "$DSH_PKG_ROOT" && node --input-type=module -e '
|
|
203
|
+
import { pathToFileURL } from "node:url";
|
|
204
|
+
import { readdirSync } from "node:fs";
|
|
205
|
+
const boot = await import("@deepseek-ai/dsh-app-boot");
|
|
206
|
+
const { resolveDshHome } = await import("@deepseek-ai/dsh-home-paths");
|
|
207
|
+
const root = process.env.DSH_PKG_ROOT;
|
|
208
|
+
const profile = process.env.VERIFY_PROFILE;
|
|
209
|
+
// locate the minified helper by its export, not by filename hash
|
|
210
|
+
const cands = await Promise.all(readdirSync(root + "/lib")
|
|
211
|
+
.filter(f => /^dump-config-.*\.js$/.test(f))
|
|
212
|
+
.map(async f => { const m = await import(pathToFileURL(root + "/lib/" + f).href); return { f, t: m.t }; }));
|
|
213
|
+
const helper = cands.find(x => typeof x.t === "function");
|
|
214
|
+
if (!helper) { console.error("no dump-config helper exporting t"); process.exit(9); }
|
|
215
|
+
const dir = boot.resolveProfileDir(profile, resolveDshHome());
|
|
216
|
+
const loaded = boot.loadProfileDirectory("dsh", dir, root + "/package.json", { userLayer: true });
|
|
217
|
+
process.stderr.write("helper = " + helper.f + "\n");
|
|
218
|
+
process.stdout.write(boot.renderConfigDump("dsh", dir + "/cordis.yml", helper.t(loaded, false, [])));
|
|
219
|
+
' 2>"$TMP/compose.err" )
|
|
220
|
+
}
|
|
221
|
+
DSH_PKG_ROOT="$DSH_PKG_ROOT" VERIFY_PROFILE="$PROFILE" compose >"$TMP/composed.yml"
|
|
222
|
+
compose_rc=$?
|
|
223
|
+
cordis_m_after="$(stat -c '%Y:%s' "$COMPOSED_CORDIS_YML" 2>/dev/null || echo absent)"
|
|
224
|
+
|
|
225
|
+
if [ "$compose_rc" != "0" ] || [ ! -s "$TMP/composed.yml" ]; then
|
|
226
|
+
bad "check 5 composition failed (exit $compose_rc)"
|
|
227
|
+
note "$(head -3 "$TMP/compose.err" | tr '\n' ' ')"
|
|
228
|
+
else
|
|
229
|
+
# The assertion that makes this check trustworthy: composing must not have
|
|
230
|
+
# touched the profile. Without it, this check would be a mutation dressed as
|
|
231
|
+
# an inspection, and nothing in its output would reveal that.
|
|
232
|
+
if [ "$cordis_m_before" = "$cordis_m_after" ]; then
|
|
233
|
+
ok "check 5 composition is read-only: cordis.yml mtime:size unchanged ($cordis_m_after)"
|
|
234
|
+
else
|
|
235
|
+
bad "check 5 composition MUTATED cordis.yml ($cordis_m_before -> $cordis_m_after)"
|
|
236
|
+
fi
|
|
237
|
+
note "composed $(wc -l <"$TMP/composed.yml") lines; helper $(head -1 "$TMP/compose.err" | sed 's/helper = //')"
|
|
238
|
+
|
|
239
|
+
c_sel="$(grep -c 'selectedDefault: dsh-orchestrator-preset$' "$TMP/composed.yml" || true)"
|
|
240
|
+
c_stale="$(grep -c 'new-orchestration' "$TMP/composed.yml" || true)"
|
|
241
|
+
c_row="$(grep -c '^ *- id: preset-dsh-orchestrator-preset$' "$TMP/composed.yml" || true)"
|
|
242
|
+
|
|
243
|
+
if [ "$c_sel" -ge 1 ]; then
|
|
244
|
+
ok "check 5 the composed tree really selects dsh-orchestrator-preset ($c_sel row(s))"
|
|
245
|
+
else
|
|
246
|
+
bad "check 5 the composed tree does NOT select this preset"
|
|
247
|
+
fi
|
|
248
|
+
if [ "$c_stale" = "0" ]; then
|
|
249
|
+
ok "check 5 no occurrence of the pre-rename name 'new-orchestration'"
|
|
250
|
+
else
|
|
251
|
+
bad "check 5 the composed tree still contains 'new-orchestration' ($c_stale line(s))"
|
|
252
|
+
fi
|
|
253
|
+
if [ "$c_row" -ge 1 ]; then
|
|
254
|
+
ok "check 5 the preset row preset-dsh-orchestrator-preset is present"
|
|
255
|
+
else
|
|
256
|
+
bad "check 5 the preset row preset-dsh-orchestrator-preset is ABSENT from the composed tree"
|
|
257
|
+
fi
|
|
258
|
+
fi
|
|
259
|
+
|
|
260
|
+
# ===========================================================================
|
|
261
|
+
head_ "check 6 — the four orch-* skills come from this bundle's own provider"
|
|
262
|
+
# ===========================================================================
|
|
263
|
+
# The four skills ship INSIDE the bundle and are served by a filesystem skill
|
|
264
|
+
# provider mounted from extensions/dsh/index.js. They are deliberately NOT in the
|
|
265
|
+
# DSH home's skills directory any more: copying them there was the old install
|
|
266
|
+
# step 3, and it is what left the user's global directory full of someone else's
|
|
267
|
+
# environment. So "present" now means "reachable through our provider", and this
|
|
268
|
+
# check asks the provider rather than looking at a directory.
|
|
269
|
+
SKILLS_DIR="$DSH_HOME_ABS/skills"
|
|
270
|
+
BUNDLE_SKILLS="$REPO_ROOT/skills"
|
|
271
|
+
|
|
272
|
+
for s in orch-delegation-brief orch-discussion-protocol orch-evidence-protocol orch-real-path-testing; do
|
|
273
|
+
if [ -f "$BUNDLE_SKILLS/$s/SKILL.md" ]; then
|
|
274
|
+
want=$(grep -m1 '^name:' "$BUNDLE_SKILLS/$s/SKILL.md" | sed 's/^name:[[:space:]]*//')
|
|
275
|
+
if [ "$want" = "$s" ]; then
|
|
276
|
+
ok "check 6 shipped in bundle with matching frontmatter name: $s"
|
|
277
|
+
else
|
|
278
|
+
bad "check 6 frontmatter name '$want' != directory '$s' (the provider reads the frontmatter)"
|
|
279
|
+
fi
|
|
280
|
+
else
|
|
281
|
+
bad "check 6 skill missing from the bundle: $s/SKILL.md"
|
|
282
|
+
fi
|
|
283
|
+
if [ -d "$SKILLS_DIR/$s" ]; then
|
|
284
|
+
bad "check 6 $s is still copied into the global skills directory — that is what this change removed"
|
|
285
|
+
fi
|
|
286
|
+
done
|
|
287
|
+
|
|
288
|
+
# The load-bearing half: construct the real provider and ask it what it serves.
|
|
289
|
+
# A file-presence check would pass even if the provider were never mounted.
|
|
290
|
+
if [ -f "$REPO_ROOT/extensions/dsh/index.js" ]; then
|
|
291
|
+
# The probe must run from the INSTALLED bundle, not from this source tree: the
|
|
292
|
+
# provider imports @deepseek-ai/dsh-skill-filesystem, which resolves by walking
|
|
293
|
+
# up to the DSH home's node_modules. A checkout in an unrelated directory has
|
|
294
|
+
# no such ancestor, and the probe would fail for a reason that says nothing
|
|
295
|
+
# about the preset.
|
|
296
|
+
installed="$(cd "$BUNDLE_LINK" 2>/dev/null && pwd -P)" || installed=""
|
|
297
|
+
if [ -z "$installed" ] || [ ! -f "$installed/extensions/dsh/index.js" ]; then
|
|
298
|
+
bad "check 6 the installed bundle has no extensions/dsh/index.js — the probe cannot run"
|
|
299
|
+
else
|
|
300
|
+
provided="$(cd "$installed" && node --input-type=module -e '
|
|
301
|
+
import * as nodefs from "node:fs";
|
|
302
|
+
let factory = null;
|
|
303
|
+
const ctx = { logger:{info(){},warn(){}}, effect:(f)=>{try{f()}catch{}}, on(){},
|
|
304
|
+
get:(k)=> k==="fs" ? nodefs : undefined,
|
|
305
|
+
skills:{ registerProvider:(f)=>{factory=f;return ()=>{}},
|
|
306
|
+
register:()=>()=>{}, list:async()=>[], get:async()=>null } };
|
|
307
|
+
const mod = await import("./extensions/dsh/index.js");
|
|
308
|
+
mod.apply(ctx);
|
|
309
|
+
if (!factory) { console.log("NO_PROVIDER_REGISTERED"); process.exit(0); }
|
|
310
|
+
const p = factory({ signal: new AbortController().signal });
|
|
311
|
+
const l = await p.list({ cwd: process.cwd() });
|
|
312
|
+
const arr = Array.isArray(l) ? l : (l && l.skills) || [];
|
|
313
|
+
console.log(arr.map(s => s && (s.name || s)).filter(Boolean).sort().join(" "));
|
|
314
|
+
' 2>"$TMP/provider.err")"
|
|
315
|
+
rc=$?
|
|
316
|
+
if [ "$rc" -ge 2 ] || [ -z "$provided" ]; then
|
|
317
|
+
bad "check 6 the provider probe itself failed (node exit $rc) — a clean result would be untrustworthy:"
|
|
318
|
+
note "$(head -2 "$TMP/provider.err" | tr '\n' ' ')"
|
|
319
|
+
elif [ "$provided" = "NO_PROVIDER_REGISTERED" ]; then
|
|
320
|
+
bad "check 6 extensions/dsh/index.js did not register a skill provider"
|
|
321
|
+
else
|
|
322
|
+
for s in orch-delegation-brief orch-discussion-protocol orch-evidence-protocol orch-real-path-testing; do
|
|
323
|
+
case " $provided " in
|
|
324
|
+
*" $s "*) ok "check 6 provider serves: $s" ;;
|
|
325
|
+
*) bad "check 6 provider does NOT serve: $s" ;;
|
|
326
|
+
esac
|
|
327
|
+
done
|
|
328
|
+
# Isolation: includeDefaultRoots:false means the provider must serve exactly
|
|
329
|
+
# this bundle's four skills and nothing else. Comparing against the needle
|
|
330
|
+
# list rather than against names written out here keeps this script free of
|
|
331
|
+
# the very strings the leak gate hunts — writing them literally is what once
|
|
332
|
+
# made the gate report itself.
|
|
333
|
+
served_count=0
|
|
334
|
+
for n in $provided; do served_count=$((served_count+1)); done
|
|
335
|
+
if [ "$served_count" -eq 4 ]; then
|
|
336
|
+
ok "check 6 provider serves exactly 4 skills (isolated from the global directory)"
|
|
337
|
+
else
|
|
338
|
+
bad "check 6 provider serves $served_count entries, expected 4 — the global skills directory is leaking in"
|
|
339
|
+
note "$provided"
|
|
340
|
+
fi
|
|
341
|
+
for n in "${NAME_NEEDLES[@]}"; do
|
|
342
|
+
case " $provided " in
|
|
343
|
+
*"$n"*) bad "check 6 a known third-party name leaked into what the provider serves: $n" ;;
|
|
344
|
+
esac
|
|
345
|
+
done
|
|
346
|
+
fi
|
|
347
|
+
fi
|
|
348
|
+
else
|
|
349
|
+
bad "check 6 extensions/dsh/index.js is missing — the bundle has no skill provider"
|
|
350
|
+
fi
|
|
351
|
+
|
|
352
|
+
for s in delegation-brief discussion-protocol evidence-protocol real-path-testing; do
|
|
353
|
+
if [ -d "$SKILLS_DIR/$s" ]; then
|
|
354
|
+
bad "check 6 a RETIRED unprefixed skill still exists as a directory: $s"
|
|
355
|
+
else
|
|
356
|
+
ok "check 6 retired name correctly absent: $s"
|
|
357
|
+
fi
|
|
358
|
+
done
|
|
359
|
+
|
|
360
|
+
# ===========================================================================
|
|
361
|
+
head_ "check 7 — the two shell rows read as deliberately configured"
|
|
362
|
+
# ===========================================================================
|
|
363
|
+
# Not a defect. The preset declares BOTH shells, each gated on process.platform,
|
|
364
|
+
# so on Windows it asks for pwsh. This host's ~/.dsh/cordis.patch.yml home layer
|
|
365
|
+
# deliberately forces bash-only on Windows and is applied after the profile
|
|
366
|
+
# layer, so it wins. A disabled tool is still a known tool, and the lanes work
|
|
367
|
+
# fine on bash alone. If this ever reads the other way, the home layer changed.
|
|
368
|
+
if [ -s "$TMP/composed.yml" ]; then
|
|
369
|
+
bash_row="$(grep -A2 '^- id: tool-bash$' "$TMP/composed.yml" | grep -m1 'disabled:' || echo 'disabled: ?')"
|
|
370
|
+
pwsh_row="$(grep -A2 '^- id: tool-pwsh$' "$TMP/composed.yml" | grep -m1 'disabled:' || echo 'disabled: ?')"
|
|
371
|
+
if echo "$bash_row" | grep -q 'disabled: false'; then
|
|
372
|
+
ok "check 7 tool-bash is enabled (bash-only Windows host, deliberate)"
|
|
373
|
+
else
|
|
374
|
+
bad "check 7 tool-bash: $bash_row (expected disabled: false)"
|
|
375
|
+
fi
|
|
376
|
+
if echo "$pwsh_row" | grep -q 'disabled: true'; then
|
|
377
|
+
ok "check 7 tool-pwsh is disabled (bash-only Windows host, deliberate)"
|
|
378
|
+
else
|
|
379
|
+
bad "check 7 tool-pwsh: $pwsh_row (expected disabled: true)"
|
|
380
|
+
fi
|
|
381
|
+
else
|
|
382
|
+
bad "check 7 no composed tree to read the shell rows from"
|
|
383
|
+
fi
|
|
384
|
+
|
|
385
|
+
# ===========================================================================
|
|
386
|
+
head_ "check 8 — leak gate over this repository's shipped tree"
|
|
387
|
+
# ===========================================================================
|
|
388
|
+
# Doubles as the publish gate: the tree must carry no machine-local path and no
|
|
389
|
+
# third-party skill name. Scanned in this repository, not the live installation.
|
|
390
|
+
#
|
|
391
|
+
# Two things this does deliberately, both learned the hard way:
|
|
392
|
+
#
|
|
393
|
+
# 1. It never writes the needles as literals. This file is itself inside the
|
|
394
|
+
# scanned tree, so a scanner that spells out what it hunts is a self-matching
|
|
395
|
+
# source: it would report a leak of its own making, and exempting itself
|
|
396
|
+
# would be worse. Each needle is assembled from two adjacent quoted parts, so
|
|
397
|
+
# the shipped text never contains the literal. What it hunts, by category: an
|
|
398
|
+
# image-upload skill; a code-review and a commit-message helper; a
|
|
399
|
+
# disk-cleaning skill that ships a vendored binary; a bundled multi-licence
|
|
400
|
+
# skill; a no-frontmatter UI skill and a document reader; two skills named
|
|
401
|
+
# only by an earlier draft; and the private-data layout tokens.
|
|
402
|
+
#
|
|
403
|
+
# 2. It distinguishes "found nothing" from "the scan itself failed". An earlier
|
|
404
|
+
# version ended its pattern with a backslash, which grep rejects as a
|
|
405
|
+
# trailing escape; the non-zero exit was swallowed by `|| true` and the gate
|
|
406
|
+
# reported a clean tree on every run. A gate that cannot fail is decoration,
|
|
407
|
+
# so a grep status of 2 or above is a FAILURE here, not an empty result.
|
|
408
|
+
#
|
|
409
|
+
# 3. It uses -F. In a basic regular expression a backslash is an escape, so a
|
|
410
|
+
# needle containing one does not match the literal text it is hunting -- the
|
|
411
|
+
# same shape mismatch that once made a leak scan report zero over a file that
|
|
412
|
+
# held thirteen hits. Fixed strings compare the bytes.
|
|
413
|
+
#
|
|
414
|
+
# 4. ABSOLUTE PATHS ARE MATCHED BY SHAPE, NOT BY VALUE.
|
|
415
|
+
#
|
|
416
|
+
# An earlier version enumerated this installation's real home directory and
|
|
417
|
+
# workspace root as literal needles, splitting each across two adjacent quoted
|
|
418
|
+
# parts so that grep would not match its own source. That only hid the value
|
|
419
|
+
# from grep: a reader deleting two quotation marks recovered both. Writing
|
|
420
|
+
# the values into the checker WAS the leak. The patterns below match the form
|
|
421
|
+
# instead, so they catch tomorrow's username as readily as today's, and this
|
|
422
|
+
# file names nobody.
|
|
423
|
+
ABSPATH_PATTERN='[A-Za-z]:[\\/](Users[\\/]|work|Documents|Desktop|OneDrive)'
|
|
424
|
+
|
|
425
|
+
# Generic placeholders and worked examples are documentation, not leaks. Only
|
|
426
|
+
# these tokens are excused, and only immediately after a Users/ segment.
|
|
427
|
+
ABSPATH_GENERIC='[A-Za-z]:[\\/]Users[\\/][^[:space:]"'"'"'<>|/]*(\[r\]|r\]|x|someone|user|username|somebody|YOUR_NAME|<user>)[^A-Za-z0-9]'
|
|
428
|
+
|
|
429
|
+
# A denylist of third-party and private-data names is unavoidable -- there is no
|
|
430
|
+
# shape that means "a name I have not thought of". But listing them here would
|
|
431
|
+
# publish what this installation has installed, which is one machine's
|
|
432
|
+
# environment rather than this project's. So the list lives in a local,
|
|
433
|
+
# uncommitted file. Absent that file the gate still runs its shape rules and says
|
|
434
|
+
# so out loud, rather than passing quietly on less coverage than it looks like it
|
|
435
|
+
# has.
|
|
436
|
+
NEEDLE_FILE="$REPO_ROOT/tools/leak-needles.local.txt"
|
|
437
|
+
NAME_NEEDLES=()
|
|
438
|
+
if [ -f "$NEEDLE_FILE" ]; then
|
|
439
|
+
while IFS= read -r line || [ -n "$line" ]; do
|
|
440
|
+
case "$line" in
|
|
441
|
+
''|'#'*) continue ;; # a bare "#" would become `-e "#"` and match every
|
|
442
|
+
# commented file in the tree
|
|
443
|
+
esac
|
|
444
|
+
NAME_NEEDLES+=("$line")
|
|
445
|
+
done < "$NEEDLE_FILE"
|
|
446
|
+
fi
|
|
447
|
+
|
|
448
|
+
# scan <ignore-case:0|1> <needle...> -> prints matching files, returns grep's status
|
|
449
|
+
# 0 = at least one match, 1 = no match, >=2 = the scan itself failed
|
|
450
|
+
#
|
|
451
|
+
# Every needle needs its OWN -e. `-e "$@"` would prefix only the first, and the
|
|
452
|
+
# rest would be taken as filename operands, which is how an earlier version came
|
|
453
|
+
# to hang on stdin.
|
|
454
|
+
scan() {
|
|
455
|
+
local icase="$1"; shift
|
|
456
|
+
local -a g=(-rIlF) e=()
|
|
457
|
+
[ "$icase" = "1" ] && g+=(-i)
|
|
458
|
+
local a
|
|
459
|
+
for a in "$@"; do e+=(-e "$a"); done
|
|
460
|
+
grep "${g[@]}" --exclude-dir=.git --exclude-dir=.dsh --exclude-dir=node_modules \
|
|
461
|
+
--exclude=leak-needles.local.txt \
|
|
462
|
+
"${e[@]}" "$REPO_ROOT" 2>"$TMP/grep.err"
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
# The path rule is a shape rule, so it runs as a regex and prints the offending
|
|
466
|
+
# LINES rather than just the file names — a file that merely mentions the shape
|
|
467
|
+
# in a comment still has to be shown, so the exception list can be argued with.
|
|
468
|
+
# grep exiting >=2 is a failed scan, never a clean one.
|
|
469
|
+
shapehits() {
|
|
470
|
+
grep -rInE --exclude-dir=.git --exclude-dir=.dsh --exclude-dir=node_modules \
|
|
471
|
+
--exclude=leak-needles.local.txt \
|
|
472
|
+
"$ABSPATH_PATTERN" "$REPO_ROOT" 2>"$TMP/grep.err" \
|
|
473
|
+
| grep -vE "$ABSPATH_GENERIC"
|
|
474
|
+
return "${PIPESTATUS[0]}"
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
hits="$(shapehits)"; rc=$?
|
|
478
|
+
# docs/ is local-only and not distributed; it is audited separately and its own
|
|
479
|
+
# text quotes the shapes this rule hunts, so it is carved out here exactly as it
|
|
480
|
+
# is from the name rule below.
|
|
481
|
+
hits="$(printf '%s' "$hits" | grep -v '/docs/' || true)"
|
|
482
|
+
if [ "$rc" -ge 2 ]; then
|
|
483
|
+
bad "check 8 the path scan FAILED (grep exit $rc) — a clean result would be untrustworthy:"
|
|
484
|
+
note "$(head -2 "$TMP/grep.err" | tr '\n' ' ')"
|
|
485
|
+
elif [ -z "$hits" ]; then
|
|
486
|
+
ok "check 8 no machine-local absolute path in the shipped tree"
|
|
487
|
+
else
|
|
488
|
+
bad "check 8 machine-local absolute path found in the shipped tree:"
|
|
489
|
+
printf '%s\n' "$hits" | sed 's/^/ /'
|
|
490
|
+
fi
|
|
491
|
+
|
|
492
|
+
if [ "${#NAME_NEEDLES[@]}" -eq 0 ]; then
|
|
493
|
+
warn "check 8 no tools/leak-needles.local.txt — name rules did not run"
|
|
494
|
+
note "that file is deliberately uncommitted; see the comment above NEEDLE_FILE"
|
|
495
|
+
fi
|
|
496
|
+
hits="$(scan 1 "${NAME_NEEDLES[@]}")"; rc=$?
|
|
497
|
+
if [ "$rc" -ge 2 ]; then
|
|
498
|
+
bad "check 8 the name scan FAILED (grep exit $rc):"
|
|
499
|
+
note "$(head -2 "$TMP/grep.err" | tr '\n' ' ')"
|
|
500
|
+
else
|
|
501
|
+
# This scan walks the WORKING TREE, not the tracked set, so it still sees
|
|
502
|
+
# docs/ even though docs/ is no longer distributed. Two names are retained in
|
|
503
|
+
# there deliberately, as attribution rather than as a leak. Everything outside
|
|
504
|
+
# docs/ must be clean.
|
|
505
|
+
leaky="$(printf '%s' "$hits" | grep -v '/docs/' || true)"
|
|
506
|
+
if [ -z "$leaky" ]; then
|
|
507
|
+
ok "check 8 no third-party skill name or private-data token outside docs/"
|
|
508
|
+
[ -n "$hits" ] && note "docs/ is local-only and not distributed; its two retained names are attribution, not a leak"
|
|
509
|
+
else
|
|
510
|
+
bad "check 8 third-party name or private-data token outside docs/:"
|
|
511
|
+
printf '%s\n' "$leaky" | sed 's/^/ /'
|
|
512
|
+
fi
|
|
513
|
+
fi
|
|
514
|
+
|
|
515
|
+
# ===========================================================================
|
|
516
|
+
head_ "check 9 — the routing table's aegis-* targets are actually provided"
|
|
517
|
+
# ===========================================================================
|
|
518
|
+
# The resident routing table names aegis skills by their PREFIXED name, while the
|
|
519
|
+
# upstream pack registers them bare. The bundle's own aegis-prefix plugin is the
|
|
520
|
+
# bridge, so a routing target that nothing produces is a dangling reference the
|
|
521
|
+
# preset cannot reach. This connects the two halves without needing a boot: read
|
|
522
|
+
# the bare names off the installed pack, apply the same prefix, and ask whether
|
|
523
|
+
# every name the routing table uses is in that set.
|
|
524
|
+
#
|
|
525
|
+
# Falsifiable: rename a skill upstream, or stop shipping the bridge, and this goes
|
|
526
|
+
# red — which is the failure that is otherwise silent, because a routing row
|
|
527
|
+
# pointing at nothing simply never matches and nothing complains.
|
|
528
|
+
if [ -f "$REPO_ROOT/extensions/dsh/aegis-prefix.js" ]; then
|
|
529
|
+
AEGIS_PKG="$PROFILE_DIR/node_modules/aegis/skills"
|
|
530
|
+
if [ ! -d "$AEGIS_PKG" ]; then
|
|
531
|
+
warn "check 9 aegis pack not found under the profile — routing targets unverified"
|
|
532
|
+
note "this preset depends on the aegis pack; install it, or ignore this knowingly"
|
|
533
|
+
else
|
|
534
|
+
provided="$(find "$AEGIS_PKG" -maxdepth 2 -name SKILL.md 2>/dev/null | while IFS= read -r f; do
|
|
535
|
+
n="$(grep -m1 '^name:' "$f" | sed 's/^name:[[:space:]]*//;s/[[:space:]]*$//')"
|
|
536
|
+
[ -n "$n" ] && printf 'aegis-%s\n' "$n"
|
|
537
|
+
done | sort -u)"
|
|
538
|
+
referenced="$(grep -oE '\baegis-[a-z0-9-]+' "$REPO_ROOT/routing-sections.mjs" | sort -u)"
|
|
539
|
+
if [ -z "$referenced" ]; then
|
|
540
|
+
bad "check 9 the routing table references no aegis-* skill — the table lost its targets, or this check matches nothing"
|
|
541
|
+
else
|
|
542
|
+
missing=0
|
|
543
|
+
for r in $referenced; do
|
|
544
|
+
case " $(printf '%s ' $provided) " in
|
|
545
|
+
*" $r "*) : ;;
|
|
546
|
+
*) bad "check 9 routing target provided by nothing: $r"; missing=$((missing+1)) ;;
|
|
547
|
+
esac
|
|
548
|
+
done
|
|
549
|
+
[ "$missing" -eq 0 ] &&
|
|
550
|
+
ok "check 9 all $(printf '%s\n' $referenced | wc -l) routing targets resolve to a provided skill"
|
|
551
|
+
fi
|
|
552
|
+
# Asserted so a silently emptied pack cannot pass this check.
|
|
553
|
+
if printf '%s\n' "$provided" | grep -q .; then
|
|
554
|
+
ok "check 9 the upstream pack offers $(printf '%s\n' $provided | wc -l) skills to prefix"
|
|
555
|
+
else
|
|
556
|
+
bad "check 9 the upstream pack offers zero skills — the prefix step would produce nothing"
|
|
557
|
+
fi
|
|
558
|
+
fi
|
|
559
|
+
else
|
|
560
|
+
bad "check 9 extensions/dsh/aegis-prefix.js is missing — the routing table has no bridge to the bare names"
|
|
561
|
+
fi
|
|
562
|
+
|
|
563
|
+
# ===========================================================================
|
|
564
|
+
head_ "check 10 — the zh description table still matches the installed pack"
|
|
565
|
+
# ===========================================================================
|
|
566
|
+
# The description swap matches on the English text verbatim, so an upstream
|
|
567
|
+
# rewrite silently falls through to English. That is the right failure direction
|
|
568
|
+
# — better English than garbled Chinese — but it is invisible, and the table rots
|
|
569
|
+
# one skill at a time. This check turns the rot into a number.
|
|
570
|
+
#
|
|
571
|
+
# Falsifiable by construction: it compares the table against the pack on disk, so
|
|
572
|
+
# upgrading aegis turns it red exactly when a key stops matching.
|
|
573
|
+
PREFIX_PLUGIN="$REPO_ROOT/extensions/dsh/aegis-prefix.js"
|
|
574
|
+
if [ -f "$PREFIX_PLUGIN" ] && [ -d "$PROFILE_DIR/node_modules/aegis/skills" ]; then
|
|
575
|
+
zhrc=0
|
|
576
|
+
node -e '
|
|
577
|
+
const fs = require("node:fs"), path = require("node:path")
|
|
578
|
+
const [, pluginPath, packRoot] = process.argv
|
|
579
|
+
const src = fs.readFileSync(pluginPath, "utf8")
|
|
580
|
+
// The table is an array of [en, zh] pairs; pull the first element of each.
|
|
581
|
+
const start = src.indexOf("const ZH_DESCRIPTIONS = [")
|
|
582
|
+
const end = src.indexOf("\n]", start)
|
|
583
|
+
const body = src.slice(start, end)
|
|
584
|
+
const keys = []
|
|
585
|
+
let pos = 0
|
|
586
|
+
for (;;) {
|
|
587
|
+
// Entries are [en, zh] pairs. The en side is single-quoted unless the text
|
|
588
|
+
// itself contains an apostrophe, in which case it is double-quoted — and an
|
|
589
|
+
// extractor that only knew one of the two silently skipped those entries and
|
|
590
|
+
// reported the newest of them as a stale key.
|
|
591
|
+
const i1 = body.indexOf("[\u0027", pos)
|
|
592
|
+
const i2 = body.indexOf("[\u0022", pos)
|
|
593
|
+
let i = -1, q = "\u0027"
|
|
594
|
+
if (i1 < 0 && i2 < 0) break
|
|
595
|
+
if (i1 < 0) { i = i2; q = "\u0022" } else if (i2 < 0) { i = i1 } else { i = Math.min(i1, i2); if (i2 < i1) q = "\u0022" }
|
|
596
|
+
let j = i + 2
|
|
597
|
+
while (j < body.length) {
|
|
598
|
+
if (body[j] === "\\") { j += 2; continue }
|
|
599
|
+
if (body[j] === q) break
|
|
600
|
+
j++
|
|
601
|
+
}
|
|
602
|
+
keys.push(body.slice(i + 2, j))
|
|
603
|
+
pos = j + 1
|
|
604
|
+
}
|
|
605
|
+
const set = new Set(keys)
|
|
606
|
+
const misses = []
|
|
607
|
+
let total = 0
|
|
608
|
+
for (const d of fs.readdirSync(packRoot)) {
|
|
609
|
+
const f = path.join(packRoot, d, "SKILL.md")
|
|
610
|
+
if (!fs.existsSync(f)) continue
|
|
611
|
+
const dl = fs.readFileSync(f, "utf8").split("\n").find((l) => l.startsWith("description:"))
|
|
612
|
+
if (!dl) continue
|
|
613
|
+
let desc = dl.slice("description:".length).trim()
|
|
614
|
+
if (desc.length > 1 && ((desc[0] === "\u0022" && desc[desc.length - 1] === "\u0022") || (desc[0] === "\u0027" && desc[desc.length - 1] === "\u0027"))) desc = desc.slice(1, -1)
|
|
615
|
+
total++
|
|
616
|
+
if (!set.has(desc)) misses.push(d)
|
|
617
|
+
}
|
|
618
|
+
console.log("KEYS=" + keys.length + " TOTAL=" + total + " MISS=" + misses.length)
|
|
619
|
+
if (misses.length) console.log("MISSING=" + misses.join(","))
|
|
620
|
+
' "$PREFIX_PLUGIN" "$PROFILE_DIR/node_modules/aegis/skills" >"$TMP/zh.txt" 2>"$TMP/zh.err" || zhrc=$?
|
|
621
|
+
zhline="$(cat "$TMP/zh.txt" 2>/dev/null | head -1)"
|
|
622
|
+
if [ "$zhrc" -ne 0 ] || [ -z "$zhline" ]; then
|
|
623
|
+
warn "check 10 the zh table could not be read ($(head -1 "$TMP/zh.err" 2>/dev/null))"
|
|
624
|
+
else
|
|
625
|
+
zkeys="$(printf '%s' "$zhline" | sed -n 's/.*KEYS=\([0-9]*\).*/\1/p')"
|
|
626
|
+
zmiss="$(printf '%s' "$zhline" | sed -n 's/.*MISS=\([0-9]*\).*/\1/p')"
|
|
627
|
+
if [ "$zmiss" = "0" ]; then
|
|
628
|
+
ok "check 10 all $zkeys zh keys match the installed pack"
|
|
629
|
+
else
|
|
630
|
+
bad "check 10 $zmiss of $zkeys zh keys no longer match the pack — those skills will show English:"
|
|
631
|
+
sed -n 's/^MISSING=//p' "$TMP/zh.txt" | tr ',' '\n' | sed 's/^/ /'
|
|
632
|
+
fi
|
|
633
|
+
fi
|
|
634
|
+
else
|
|
635
|
+
warn "check 10 skipped: no aegis pack under the profile, or the prefix plugin is absent"
|
|
636
|
+
fi
|
|
637
|
+
|
|
638
|
+
# ===========================================================================
|
|
639
|
+
printf '\n========================================\n'
|
|
640
|
+
printf 'SUMMARY %d passed, %d failed (profile: %s)\n' "$PASS" "$FAIL" "$PROFILE"
|
|
641
|
+
printf '========================================\n'
|
|
642
|
+
[ "$FAIL" -eq 0 ] || exit 1
|
|
643
|
+
exit 0
|