@monoes/monodesign 1.2.20 → 1.2.23
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/README.md +2 -2
- package/cli/engine/cli/main.mjs +21 -1
- package/cli/engine/design-system-checks.mjs +381 -0
- package/cli/engine/design-system-parse.mjs +433 -0
- package/cli/engine/design-system.mjs +20 -736
- package/cli/engine/detect-antipatterns-browser.js +28 -5
- package/cli/engine/detect-antipatterns.mjs +1 -0
- package/cli/engine/engines/regex/detect-text-matchers.mjs +371 -0
- package/cli/engine/engines/regex/detect-text-run.mjs +186 -0
- package/cli/engine/engines/regex/detect-text.mjs +11 -523
- package/cli/engine/engines/static-html/css-cascade-color.mjs +219 -0
- package/cli/engine/engines/static-html/css-cascade-static-data.mjs +138 -0
- package/cli/engine/engines/static-html/css-cascade-static-dom.mjs +132 -0
- package/cli/engine/engines/static-html/css-cascade-static-parse.mjs +393 -0
- package/cli/engine/engines/static-html/css-cascade.mjs +24 -837
- package/cli/engine/engines/static-html/detect-html.mjs +6 -1
- package/cli/engine/findings-group.mjs +94 -0
- package/cli/engine/fix/index.mjs +1 -26
- package/cli/engine/fix/unified-diff.mjs +107 -0
- package/cli/engine/rules/checks-background.mjs +17 -3
- package/cli/engine/rules/checks-element.mjs +2 -0
- package/cli/engine/rules/checks-pure.mjs +9 -2
- package/cli/lib/monodesign-config-consent.mjs +93 -0
- package/cli/lib/monodesign-config-core.mjs +37 -0
- package/cli/lib/monodesign-config-detection.mjs +133 -0
- package/cli/lib/monodesign-config-filter.mjs +198 -0
- package/cli/lib/monodesign-config-ignore-value.mjs +212 -0
- package/cli/lib/monodesign-config.mjs +21 -623
- package/package.json +2 -2
- package/skill/scripts/context-fields.mjs +65 -0
- package/skill/scripts/context-resolve.mjs +425 -0
- package/skill/scripts/context-update.mjs +151 -0
- package/skill/scripts/context-utils.mjs +68 -0
- package/skill/scripts/context-workspace.mjs +245 -0
- package/skill/scripts/context.mjs +17 -891
- package/skill/scripts/hook-admin-config.mjs +152 -0
- package/skill/scripts/hook-admin-ignore.mjs +131 -0
- package/skill/scripts/hook-admin-manifest.mjs +292 -0
- package/skill/scripts/hook-admin.mjs +8 -541
- package/skill/scripts/hook-lib-cache.mjs +145 -0
- package/skill/scripts/hook-lib-config.mjs +292 -0
- package/skill/scripts/hook-lib-detector.mjs +72 -0
- package/skill/scripts/hook-lib-events.mjs +182 -0
- package/skill/scripts/hook-lib-findings.mjs +201 -0
- package/skill/scripts/hook-lib-ignore.mjs +205 -0
- package/skill/scripts/hook-lib-render.mjs +273 -0
- package/skill/scripts/hook-lib-run.mjs +281 -0
- package/skill/scripts/hook-lib-scan-targets.mjs +128 -0
- package/skill/scripts/hook-lib.mjs +71 -1747
- package/skill/scripts/lib/design-parser-colors.mjs +182 -0
- package/skill/scripts/lib/design-parser-extract.mjs +333 -0
- package/skill/scripts/lib/design-parser-sections.mjs +214 -0
- package/skill/scripts/lib/design-parser-yaml.mjs +135 -0
- package/skill/scripts/lib/design-parser.mjs +10 -783
- package/skill/scripts/live/manual-apply-chunk.mjs +151 -0
- package/skill/scripts/live/manual-apply-compact.mjs +122 -0
- package/skill/scripts/live/manual-apply-evidence.mjs +115 -0
- package/skill/scripts/live/manual-apply-log.mjs +179 -0
- package/skill/scripts/live/manual-apply-transaction.mjs +122 -0
- package/skill/scripts/live/manual-apply.mjs +65 -640
- package/skill/scripts/live/svelte-component-accept.mjs +248 -0
- package/skill/scripts/live/svelte-component-css.mjs +230 -0
- package/skill/scripts/live/svelte-component-markup.mjs +104 -0
- package/skill/scripts/live/svelte-component-scaffold.mjs +118 -0
- package/skill/scripts/live/svelte-component-session.mjs +158 -0
- package/skill/scripts/live/svelte-component.mjs +32 -792
- package/skill/scripts/live-accept-mutate.mjs +152 -0
- package/skill/scripts/live-accept-parse.mjs +370 -0
- package/skill/scripts/live-accept-scrub.mjs +59 -0
- package/skill/scripts/live-accept-search.mjs +58 -0
- package/skill/scripts/live-accept.mjs +15 -631
- package/skill/scripts/live-commit-manual-edits-paths.mjs +27 -0
- package/skill/scripts/live-commit-manual-edits-repair.mjs +166 -0
- package/skill/scripts/live-commit-manual-edits-rollback.mjs +231 -0
- package/skill/scripts/live-commit-manual-edits-utils.mjs +167 -0
- package/skill/scripts/live-commit-manual-edits-verify.mjs +356 -0
- package/skill/scripts/live-commit-manual-edits.mjs +24 -881
- package/skill/scripts/live-copy-edit-agent.mjs +16 -551
- package/skill/scripts/live-copy-edit-compact.mjs +137 -0
- package/skill/scripts/live-copy-edit-post-apply.mjs +175 -0
- package/skill/scripts/live-copy-edit-runners.mjs +297 -0
- package/skill/scripts/live-inject-csp.mjs +132 -0
- package/skill/scripts/live-inject-files.mjs +229 -0
- package/skill/scripts/live-inject-tag.mjs +114 -0
- package/skill/scripts/live-inject.mjs +13 -440
- package/skill/scripts/live-server-poll.mjs +232 -0
- package/skill/scripts/live-server-routes.mjs +449 -0
- package/skill/scripts/live-server-state.mjs +361 -0
- package/skill/scripts/live-server.mjs +10 -946
- package/skill/scripts/live-wrap-css.mjs +132 -0
- package/skill/scripts/live-wrap-manual-edit.mjs +153 -0
- package/skill/scripts/live-wrap-search.mjs +247 -0
- package/skill/scripts/live-wrap.mjs +21 -484
- package/skill/scripts/palette-seeds.mjs +401 -0
- package/skill/scripts/palette.mjs +1 -395
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRODUCT.md field extraction for context.mjs: pulling the `## Register` and
|
|
3
|
+
* `## Platform` section values out of the loaded PRODUCT.md body.
|
|
4
|
+
*
|
|
5
|
+
* Split out of context.mjs. See that file for context.
|
|
6
|
+
*/
|
|
7
|
+
import { escapeRegExp } from './context-utils.mjs';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Read the first non-empty line under a bare `## <heading>` section of
|
|
11
|
+
* PRODUCT.md (e.g. `## Register`, `## Platform`). Returns null when the
|
|
12
|
+
* section is absent. The heading match is exact (`\s*$`) so near-miss
|
|
13
|
+
* headings like `## Register guidelines` don't shadow the real field.
|
|
14
|
+
*/
|
|
15
|
+
export function extractSectionValue(product, heading) {
|
|
16
|
+
if (!product) return null;
|
|
17
|
+
const headingRe = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, 'i');
|
|
18
|
+
const lines = product.split('\n');
|
|
19
|
+
for (let i = 0; i < lines.length; i++) {
|
|
20
|
+
if (headingRe.test(lines[i].trim())) {
|
|
21
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
22
|
+
const next = lines[j].trim();
|
|
23
|
+
// A new heading before any value means the section is empty.
|
|
24
|
+
if (/^#{1,6}\s/.test(next)) return null;
|
|
25
|
+
if (next) return next;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Pull the register (`brand` or `product`) out of PRODUCT.md by looking
|
|
34
|
+
* for a `## Register` section and reading the first non-empty line that
|
|
35
|
+
* follows it. Returns null when the file is legacy / register-less.
|
|
36
|
+
*/
|
|
37
|
+
export function extractRegister(product) {
|
|
38
|
+
const word = (extractSectionValue(product, 'Register') || '').toLowerCase();
|
|
39
|
+
return word === 'brand' || word === 'product' ? word : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Pull the platform (`web`, `ios`, `android`, or `adaptive`) out of PRODUCT.md
|
|
44
|
+
* by looking for a `## Platform` section and reading the first non-empty line
|
|
45
|
+
* that follows it. `adaptive` is for cross-platform apps (Flutter, React
|
|
46
|
+
* Native) that ship both iOS and Android from one codebase; a line that names
|
|
47
|
+
* both targets (e.g. `ios, android`) is also read as `adaptive`. Returns null
|
|
48
|
+
* when the file is legacy / platform-less, which the skill treats as `web`
|
|
49
|
+
* (the default the general rules already assume).
|
|
50
|
+
*/
|
|
51
|
+
export function extractPlatform(product) {
|
|
52
|
+
const value = (extractSectionValue(product, 'Platform') || '').toLowerCase();
|
|
53
|
+
if (!value) return null;
|
|
54
|
+
if (value === 'web' || value === 'ios' || value === 'android' || value === 'adaptive') return value;
|
|
55
|
+
// A short list naming both native targets (`ios, android`, `ios and
|
|
56
|
+
// android`) = adaptive. Only list separators and the two platform words may
|
|
57
|
+
// appear; anything else (prose, negations) is unrecognized and falls
|
|
58
|
+
// through to the CLI's WARNING path.
|
|
59
|
+
const tokens = value.split(/[\s,+&/]+/).filter(t => t && t !== 'and');
|
|
60
|
+
if (tokens.length >= 2 && tokens.every(t => t === 'ios' || t === 'android')
|
|
61
|
+
&& tokens.includes('ios') && tokens.includes('android')) {
|
|
62
|
+
return 'adaptive';
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project/context resolution for context.mjs: locating PRODUCT.md/DESIGN.md
|
|
3
|
+
* for the active project, walking up to find a monorepo root, and (for the
|
|
4
|
+
* CLI's target-selection prompt) discovering candidate child projects.
|
|
5
|
+
*
|
|
6
|
+
* Split out of context.mjs. See that file for context.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import {
|
|
12
|
+
DESIGN_NAMES,
|
|
13
|
+
FALLBACK_DIRS,
|
|
14
|
+
MONOREPO_FALLBACK_PROJECT_DIRS,
|
|
15
|
+
MONOREPO_MARKER_FILES,
|
|
16
|
+
PRODUCT_NAMES,
|
|
17
|
+
WORKSPACE_DISCOVERY_IGNORED_DIRS,
|
|
18
|
+
firstExisting,
|
|
19
|
+
hasTargetOption,
|
|
20
|
+
isPathInside,
|
|
21
|
+
isPathInsideOrEqual,
|
|
22
|
+
safeRead,
|
|
23
|
+
} from './context-utils.mjs';
|
|
24
|
+
import {
|
|
25
|
+
isExcludedByWorkspacePattern,
|
|
26
|
+
normalizeWorkspacePattern,
|
|
27
|
+
readWorkspacePatterns,
|
|
28
|
+
resolveWorkspaceProjectRoot,
|
|
29
|
+
segmentMatches,
|
|
30
|
+
} from './context-workspace.mjs';
|
|
31
|
+
|
|
32
|
+
export function resolveContextDir(cwd = process.cwd(), options = {}) {
|
|
33
|
+
return resolveContext(cwd, options).contextDir;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function loadContext(cwd = process.cwd(), options = {}) {
|
|
37
|
+
const resolved = resolveContext(cwd, options);
|
|
38
|
+
const absCwd = path.resolve(cwd);
|
|
39
|
+
const productPath = resolved.productPath;
|
|
40
|
+
const designPath = resolved.designPath;
|
|
41
|
+
const product = productPath ? safeRead(productPath) : null;
|
|
42
|
+
const design = designPath ? safeRead(designPath) : null;
|
|
43
|
+
return {
|
|
44
|
+
hasProduct: !!product,
|
|
45
|
+
product,
|
|
46
|
+
productPath: productPath ? path.relative(absCwd, productPath).split(path.sep).join('/') : null,
|
|
47
|
+
hasDesign: !!design,
|
|
48
|
+
design,
|
|
49
|
+
designPath: designPath ? path.relative(absCwd, designPath).split(path.sep).join('/') : null,
|
|
50
|
+
contextDir: resolved.contextDir,
|
|
51
|
+
productContextDir: productPath ? path.dirname(productPath) : null,
|
|
52
|
+
designContextDir: designPath ? path.dirname(designPath) : null,
|
|
53
|
+
projectRoot: resolved.projectRoot,
|
|
54
|
+
repoRoot: resolved.repoRoot,
|
|
55
|
+
isMonorepo: resolved.isMonorepo,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resolveContext(cwd = process.cwd(), options = {}) {
|
|
60
|
+
const absCwd = path.resolve(cwd);
|
|
61
|
+
const project = resolveProject(absCwd, options);
|
|
62
|
+
const projectContextDir = resolveLocalContextDir(project.projectRoot);
|
|
63
|
+
// Per-file inheritance from the repo root whenever the active project is
|
|
64
|
+
// nested below it: monorepo workspace children and explicit-target nested
|
|
65
|
+
// products in ordinary repos behave the same way.
|
|
66
|
+
const rootContextDir = project.repoRoot !== project.projectRoot
|
|
67
|
+
? resolveLocalContextDir(project.repoRoot)
|
|
68
|
+
: null;
|
|
69
|
+
|
|
70
|
+
let productPath =
|
|
71
|
+
(projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
|
|
72
|
+
|| (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
|
|
73
|
+
let designPath =
|
|
74
|
+
(projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
|
|
75
|
+
|| (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
|
|
76
|
+
|
|
77
|
+
let envContextDir = null;
|
|
78
|
+
if (!productPath && !designPath) {
|
|
79
|
+
envContextDir = resolveEnvContextDir(absCwd);
|
|
80
|
+
if (envContextDir) {
|
|
81
|
+
productPath = firstExisting(envContextDir, PRODUCT_NAMES);
|
|
82
|
+
designPath = firstExisting(envContextDir, DESIGN_NAMES);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
contextDir: productPath
|
|
88
|
+
? path.dirname(productPath)
|
|
89
|
+
: designPath
|
|
90
|
+
? path.dirname(designPath)
|
|
91
|
+
: envContextDir || project.projectRoot,
|
|
92
|
+
productPath,
|
|
93
|
+
designPath,
|
|
94
|
+
projectRoot: project.projectRoot,
|
|
95
|
+
repoRoot: project.repoRoot,
|
|
96
|
+
isMonorepo: project.isMonorepo,
|
|
97
|
+
targetDir: project.targetDir,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
|
|
102
|
+
return resolveProject(cwd, options).projectRoot;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
|
|
106
|
+
if (hasTargetOption(options)) return null;
|
|
107
|
+
const project = resolveProject(cwd);
|
|
108
|
+
if (
|
|
109
|
+
!project.isMonorepo
|
|
110
|
+
|| !project.projectRoot
|
|
111
|
+
|| !project.repoRoot
|
|
112
|
+
|| path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
|
|
113
|
+
) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const targetCandidates = discoverTargetCandidates(project.repoRoot);
|
|
117
|
+
// No discoverable child apps (e.g. `workspaces: ["."]`, a root-only workspace,
|
|
118
|
+
// or a marker file with no apps/packages children): there is nothing to choose,
|
|
119
|
+
// so treat the repo root as the active project rather than blocking on an empty
|
|
120
|
+
// selection prompt that the user cannot answer.
|
|
121
|
+
if (targetCandidates.length === 0) return null;
|
|
122
|
+
return {
|
|
123
|
+
targetPath: null,
|
|
124
|
+
projectRoot: project.projectRoot,
|
|
125
|
+
repoRoot: project.repoRoot,
|
|
126
|
+
targetCandidates,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function resolveProject(cwd = process.cwd(), options = {}) {
|
|
131
|
+
const absCwd = path.resolve(cwd);
|
|
132
|
+
const targetDir = resolveTargetDir(absCwd, options);
|
|
133
|
+
let repoRoot = findMonorepoRoot(targetDir);
|
|
134
|
+
if (!repoRoot && targetDir !== absCwd) {
|
|
135
|
+
const cwdRepoRoot = findMonorepoRoot(absCwd);
|
|
136
|
+
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
|
|
137
|
+
repoRoot = cwdRepoRoot;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!repoRoot) {
|
|
141
|
+
return {
|
|
142
|
+
targetDir,
|
|
143
|
+
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
|
|
144
|
+
repoRoot: absCwd,
|
|
145
|
+
isMonorepo: false,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
targetDir,
|
|
150
|
+
projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
|
|
151
|
+
repoRoot,
|
|
152
|
+
isMonorepo: true,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function resolveLocalContextDir(root) {
|
|
157
|
+
if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
|
158
|
+
return root;
|
|
159
|
+
}
|
|
160
|
+
for (const rel of FALLBACK_DIRS) {
|
|
161
|
+
const candidate = path.resolve(root, rel);
|
|
162
|
+
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
|
163
|
+
return candidate;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function resolveEnvContextDir(cwd) {
|
|
170
|
+
const envDir = process.env.MONODESIGN_CONTEXT_DIR;
|
|
171
|
+
if (!envDir?.trim()) return null;
|
|
172
|
+
const trimmed = envDir.trim();
|
|
173
|
+
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function resolveTargetDir(cwd, options = {}) {
|
|
177
|
+
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
|
178
|
+
if (!targetPath || !String(targetPath).trim()) return cwd;
|
|
179
|
+
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
|
180
|
+
try {
|
|
181
|
+
const stat = fs.statSync(abs);
|
|
182
|
+
return stat.isDirectory() ? abs : path.dirname(abs);
|
|
183
|
+
} catch {
|
|
184
|
+
return path.extname(abs) ? path.dirname(abs) : abs;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function findMonorepoRoot(startDir) {
|
|
189
|
+
let dir = path.resolve(startDir);
|
|
190
|
+
const homeDir = path.resolve(os.homedir());
|
|
191
|
+
while (true) {
|
|
192
|
+
if (dir === homeDir) return null;
|
|
193
|
+
// isMonorepoRoot is checked before hasGitBoundary on purpose: a workspace
|
|
194
|
+
// root that also carries its own .git is still recognized. The trade-off is
|
|
195
|
+
// deliberate — a directory with a monorepo *marker* but no workspace patterns
|
|
196
|
+
// and no apps/packages children is not a monorepo root, so its .git stops
|
|
197
|
+
// traversal and a further-up root is not searched. The nested .git is treated
|
|
198
|
+
// as an independent project boundary, which is the intended isolation.
|
|
199
|
+
if (isMonorepoRoot(dir)) return dir;
|
|
200
|
+
if (hasGitBoundary(dir)) return null;
|
|
201
|
+
const parent = path.dirname(dir);
|
|
202
|
+
if (parent === dir) return null;
|
|
203
|
+
dir = parent;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function isMonorepoRoot(dir) {
|
|
208
|
+
if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
|
|
209
|
+
if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
|
|
210
|
+
return hasFallbackWorkspaceChildren(dir);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function hasGitBoundary(dir) {
|
|
214
|
+
return fs.existsSync(path.join(dir, '.git'));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function hasFallbackWorkspaceChildren(dir) {
|
|
218
|
+
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
|
219
|
+
const base = path.join(dir, name);
|
|
220
|
+
let entries;
|
|
221
|
+
try {
|
|
222
|
+
entries = fs.readdirSync(base, { withFileTypes: true });
|
|
223
|
+
} catch {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
|
|
227
|
+
}
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function discoverTargetCandidates(repoRoot) {
|
|
232
|
+
const roots = new Map();
|
|
233
|
+
const patterns = readWorkspacePatterns(repoRoot);
|
|
234
|
+
for (const pattern of patterns) {
|
|
235
|
+
for (const root of discoverRootsForPattern(repoRoot, pattern)) {
|
|
236
|
+
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
|
|
240
|
+
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
|
241
|
+
const base = path.join(repoRoot, name);
|
|
242
|
+
let entries;
|
|
243
|
+
try {
|
|
244
|
+
entries = fs.readdirSync(base, { withFileTypes: true });
|
|
245
|
+
} catch {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
for (const entry of entries) {
|
|
249
|
+
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
|
250
|
+
const root = path.join(base, entry.name);
|
|
251
|
+
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return [...roots.entries()]
|
|
256
|
+
.filter(([rel]) => rel && !rel.startsWith('..'))
|
|
257
|
+
// Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot
|
|
258
|
+
// sends an excluded package back to the repo root, so an excluded folder must not appear as a
|
|
259
|
+
// selectable target — choosing it would silently resolve to the root instead.
|
|
260
|
+
.filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns))
|
|
261
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
262
|
+
.map(([rel, root]) => {
|
|
263
|
+
const targetExample = findTargetExample(repoRoot, root);
|
|
264
|
+
return {
|
|
265
|
+
name: path.basename(root),
|
|
266
|
+
path: rel,
|
|
267
|
+
targetExample,
|
|
268
|
+
...resolveCandidateContextSummary(repoRoot, root, targetExample),
|
|
269
|
+
};
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
|
|
274
|
+
const ctx = resolveContext(repoRoot, { targetPath });
|
|
275
|
+
return {
|
|
276
|
+
productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
|
|
277
|
+
productPath: contextSourcePath(ctx.productPath, repoRoot),
|
|
278
|
+
designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
|
|
279
|
+
designPath: contextSourcePath(ctx.designPath, repoRoot),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Selection candidates surface one of four statuses: 'child' (a canonical
|
|
284
|
+
// PRODUCT.md/DESIGN.md directly in the app root), 'inherited' (resolved from the
|
|
285
|
+
// repo root in a monorepo), 'missing' (no file found), and 'fallback'. 'fallback'
|
|
286
|
+
// intentionally covers two non-canonical locations: a file inside the project
|
|
287
|
+
// root but in a subdirectory (FALLBACK_DIRS, e.g. `.agents/context/`), and a file
|
|
288
|
+
// outside both the project and repo roots (MONODESIGN_CONTEXT_DIR override).
|
|
289
|
+
function contextSourceStatus(filePath, repoRoot, projectRoot) {
|
|
290
|
+
if (!filePath) return 'missing';
|
|
291
|
+
const absPath = path.resolve(filePath);
|
|
292
|
+
const absProjectRoot = path.resolve(projectRoot);
|
|
293
|
+
const absRepoRoot = path.resolve(repoRoot);
|
|
294
|
+
if (isPathInsideOrEqual(absPath, absProjectRoot)) {
|
|
295
|
+
return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
|
|
296
|
+
}
|
|
297
|
+
if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
|
|
298
|
+
return 'inherited';
|
|
299
|
+
}
|
|
300
|
+
return 'fallback';
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function contextSourcePath(filePath, repoRoot) {
|
|
304
|
+
if (!filePath) return null;
|
|
305
|
+
const rel = path.relative(repoRoot, filePath).split(path.sep).join('/');
|
|
306
|
+
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
|
307
|
+
return rel;
|
|
308
|
+
}
|
|
309
|
+
return filePath;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function discoverRootsForPattern(repoRoot, rawPattern) {
|
|
313
|
+
const pattern = normalizeWorkspacePattern(rawPattern);
|
|
314
|
+
if (!pattern || pattern.startsWith('!')) return [];
|
|
315
|
+
const segments = pattern.split('/').filter(Boolean);
|
|
316
|
+
if (!segments.length) return [];
|
|
317
|
+
const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
|
|
318
|
+
const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
|
|
319
|
+
const base = path.join(repoRoot, ...literalPrefix);
|
|
320
|
+
if (!fs.existsSync(base)) return [];
|
|
321
|
+
if (segments.includes('**')) {
|
|
322
|
+
const packageRoots = [];
|
|
323
|
+
walkDirs(base, (dir) => {
|
|
324
|
+
if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
|
|
325
|
+
});
|
|
326
|
+
if (packageRoots.length) return packageRoots;
|
|
327
|
+
return directChildDirs(base);
|
|
328
|
+
}
|
|
329
|
+
return expandSimplePattern(repoRoot, segments);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
|
|
333
|
+
if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
|
|
334
|
+
const segment = patternSegments[index];
|
|
335
|
+
if (!segment.includes('*')) {
|
|
336
|
+
return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
|
|
337
|
+
}
|
|
338
|
+
let entries;
|
|
339
|
+
try {
|
|
340
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
341
|
+
} catch {
|
|
342
|
+
return [];
|
|
343
|
+
}
|
|
344
|
+
const roots = [];
|
|
345
|
+
for (const entry of entries) {
|
|
346
|
+
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
|
347
|
+
if (!segmentMatches(segment, entry.name)) continue;
|
|
348
|
+
roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
|
|
349
|
+
}
|
|
350
|
+
return roots;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function directChildDirs(dir) {
|
|
354
|
+
try {
|
|
355
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
356
|
+
.filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
|
|
357
|
+
.map((entry) => path.join(dir, entry.name));
|
|
358
|
+
} catch {
|
|
359
|
+
return [];
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function walkDirs(root, visit) {
|
|
364
|
+
let entries;
|
|
365
|
+
try {
|
|
366
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
367
|
+
} catch {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
for (const entry of entries) {
|
|
371
|
+
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
|
372
|
+
const dir = path.join(root, entry.name);
|
|
373
|
+
visit(dir);
|
|
374
|
+
walkDirs(dir, visit);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function isCandidateProjectRoot(dir) {
|
|
379
|
+
return !!(
|
|
380
|
+
fs.existsSync(path.join(dir, 'package.json'))
|
|
381
|
+
|| firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
|
382
|
+
|| fs.existsSync(path.join(dir, 'src'))
|
|
383
|
+
|| fs.existsSync(path.join(dir, 'app'))
|
|
384
|
+
|| fs.existsSync(path.join(dir, 'pages'))
|
|
385
|
+
|| fs.existsSync(path.join(dir, 'public'))
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function isIgnoredWorkspaceDiscoveryDir(name) {
|
|
390
|
+
return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function findTargetExample(repoRoot, projectRoot) {
|
|
394
|
+
const examples = [
|
|
395
|
+
'src/App.jsx',
|
|
396
|
+
'src/App.tsx',
|
|
397
|
+
'src/main.jsx',
|
|
398
|
+
'src/main.tsx',
|
|
399
|
+
'src/index.jsx',
|
|
400
|
+
'src/index.ts',
|
|
401
|
+
'app/page.tsx',
|
|
402
|
+
'pages/index.tsx',
|
|
403
|
+
'public/index.html',
|
|
404
|
+
];
|
|
405
|
+
for (const rel of examples) {
|
|
406
|
+
const abs = path.join(projectRoot, rel);
|
|
407
|
+
if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
|
|
408
|
+
}
|
|
409
|
+
return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function nearestTargetContextRoot(absCwd, targetDir) {
|
|
413
|
+
if (!isPathInside(targetDir, absCwd)) return null;
|
|
414
|
+
const rootFallbackDirs = FALLBACK_DIRS.map((rel) => path.resolve(absCwd, rel));
|
|
415
|
+
let dir = path.resolve(targetDir);
|
|
416
|
+
while (dir && dir !== absCwd) {
|
|
417
|
+
if (!rootFallbackDirs.includes(dir) && resolveLocalContextDir(dir)) {
|
|
418
|
+
return dir;
|
|
419
|
+
}
|
|
420
|
+
const parent = path.dirname(dir);
|
|
421
|
+
if (parent === dir) break;
|
|
422
|
+
dir = parent;
|
|
423
|
+
}
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill-version update check for context.mjs's boot output.
|
|
3
|
+
*
|
|
4
|
+
* Split out of context.mjs. See that file for context.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
// ─── Update check ──────────────────────────────────────────────────────────
|
|
12
|
+
// Piggyback a lightweight skill-version check on the once-per-session boot.
|
|
13
|
+
// When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent
|
|
14
|
+
// can offer `monomind design update`. Everything here is best-effort and
|
|
15
|
+
// silent on failure: a network problem, sandbox, or missing cache must never
|
|
16
|
+
// block context output or print an error.
|
|
17
|
+
|
|
18
|
+
const UPDATE_HOST = (process.env.MONODESIGN_UPDATE_HOST || 'https://github.com/monoes/monomind').replace(/\/$/, '');
|
|
19
|
+
const UPDATE_CACHE_PATH =
|
|
20
|
+
process.env.MONODESIGN_UPDATE_CACHE || path.join(os.homedir(), '.monodesign', 'update-check.json');
|
|
21
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to once a day
|
|
22
|
+
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
|
|
23
|
+
const FETCH_TIMEOUT_MS = 1200;
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read the installed skill's own version from the sibling SKILL.md frontmatter
|
|
28
|
+
* (this file lives at `<skill>/scripts/context.mjs`). Returns null when the
|
|
29
|
+
* frontmatter is missing or unreadable.
|
|
30
|
+
*/
|
|
31
|
+
function readLocalSkillVersion() {
|
|
32
|
+
try {
|
|
33
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
const skillMd = path.join(here, '..', 'SKILL.md');
|
|
35
|
+
const content = fs.readFileSync(skillMd, 'utf-8');
|
|
36
|
+
const match = content.match(/^version:\s*(.+)$/m);
|
|
37
|
+
return match ? match[1].trim().replace(/^["']|["']$/g, '') : null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readUpdateCache() {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(fs.readFileSync(UPDATE_CACHE_PATH, 'utf-8'));
|
|
46
|
+
} catch {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeUpdateCache(cache) {
|
|
52
|
+
try {
|
|
53
|
+
fs.mkdirSync(path.dirname(UPDATE_CACHE_PATH), { recursive: true });
|
|
54
|
+
fs.writeFileSync(UPDATE_CACHE_PATH, JSON.stringify(cache));
|
|
55
|
+
} catch {
|
|
56
|
+
// Best-effort: a read-only home dir just means we re-poll next session.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Compare dotted numeric versions. Returns >0 when a is newer than b. */
|
|
61
|
+
function compareSemver(a, b) {
|
|
62
|
+
const pa = String(a).split('.').map(n => parseInt(n, 10) || 0);
|
|
63
|
+
const pb = String(b).split('.').map(n => parseInt(n, 10) || 0);
|
|
64
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
65
|
+
const diff = (pa[i] || 0) - (pb[i] || 0);
|
|
66
|
+
if (diff !== 0) return diff;
|
|
67
|
+
}
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function fetchLatestSkillVersion() {
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(`${UPDATE_HOST}/api/version`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
74
|
+
if (!res.ok) return null;
|
|
75
|
+
const data = await res.json();
|
|
76
|
+
return typeof data?.skills === 'string' ? data.skills : null;
|
|
77
|
+
} catch {
|
|
78
|
+
return null; // offline, sandboxed, timed out, or bad JSON: all non-fatal
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildUpdateDirective(localVersion, latestVersion) {
|
|
83
|
+
return (
|
|
84
|
+
`UPDATE_AVAILABLE: A newer Monodesign skill is available ` +
|
|
85
|
+
`(installed v${localVersion}, latest v${latestVersion}). ` +
|
|
86
|
+
`Before continuing, ask the user once: "A newer Monodesign (v${latestVersion}) is available. ` +
|
|
87
|
+
`Update now? It runs \`monomind design update\`." ` +
|
|
88
|
+
`If they agree, run \`monomind design update\` (the update applies to the next session, not this one). ` +
|
|
89
|
+
`Either way, continue the current task without waiting, and do not raise this again.`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Best-effort update directive for the boot output. Returns a string to append
|
|
95
|
+
* or null. Polls the version endpoint at most once per day (cached globally in
|
|
96
|
+
* the user's home dir) and re-surfaces a given version at most once per week so
|
|
97
|
+
* the agent never nags. Opt out entirely with MONODESIGN_NO_UPDATE_CHECK=1.
|
|
98
|
+
*/
|
|
99
|
+
// Read the unified config's top-level `updateCheck` (local overrides shared).
|
|
100
|
+
// Inlined rather than importing hook-lib so the boot path stays lightweight.
|
|
101
|
+
function updateCheckDisabledByConfig(cwd = process.cwd()) {
|
|
102
|
+
let value;
|
|
103
|
+
for (const name of ['config.json', 'config.local.json']) {
|
|
104
|
+
try {
|
|
105
|
+
const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.monodesign', name), 'utf-8'));
|
|
106
|
+
if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;
|
|
107
|
+
} catch { /* missing or malformed: ignore */ }
|
|
108
|
+
}
|
|
109
|
+
return value === false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function computeUpdateDirective(now = Date.now()) {
|
|
113
|
+
// Update checking is disabled in monomind: the skill ships with the monomind
|
|
114
|
+
// package and follows monomind's own update flow (`monomind update`), so
|
|
115
|
+
// there is no separate registry endpoint to poll. Kept as a stub so the boot
|
|
116
|
+
// path (and MONODESIGN_NO_UPDATE_CHECK-aware tests) stays unchanged.
|
|
117
|
+
return null;
|
|
118
|
+
// biome-ignore lint/correctness/noUnreachable: disabled on purpose (see above); kept to re-enable
|
|
119
|
+
try {
|
|
120
|
+
if (process.env.MONODESIGN_NO_UPDATE_CHECK) return null;
|
|
121
|
+
if (updateCheckDisabledByConfig()) return null;
|
|
122
|
+
const localVersion = readLocalSkillVersion();
|
|
123
|
+
if (!localVersion) return null;
|
|
124
|
+
|
|
125
|
+
const cache = readUpdateCache();
|
|
126
|
+
|
|
127
|
+
// Poll the network only when the throttle window has elapsed. Stamp
|
|
128
|
+
// lastCheck even on failure so an offline machine doesn't poll every boot.
|
|
129
|
+
if (!cache.lastCheck || now - cache.lastCheck > CHECK_INTERVAL_MS) {
|
|
130
|
+
const latest = await fetchLatestSkillVersion();
|
|
131
|
+
cache.lastCheck = now;
|
|
132
|
+
if (latest) cache.latestVersion = latest;
|
|
133
|
+
writeUpdateCache(cache);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const latest = cache.latestVersion;
|
|
137
|
+
if (!latest || compareSemver(latest, localVersion) <= 0) return null;
|
|
138
|
+
|
|
139
|
+
// Anti-nag: surface a given version at most once per RENOTIFY window.
|
|
140
|
+
if (cache.notifiedVersion === latest && cache.notifiedAt && now - cache.notifiedAt < RENOTIFY_INTERVAL_MS) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
cache.notifiedVersion = latest;
|
|
144
|
+
cache.notifiedAt = now;
|
|
145
|
+
writeUpdateCache(cache);
|
|
146
|
+
|
|
147
|
+
return buildUpdateDirective(localVersion, latest);
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|