@geraldmaron/construct 1.2.1 → 1.2.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.
- package/README.md +6 -6
- package/bin/construct +41 -67
- package/commands/plan/decide.md +1 -1
- package/commands/remember/runbook.md +1 -1
- package/examples/README.md +1 -1
- package/examples/distribution/README.md +1 -1
- package/examples/distribution/sources/adr.md +2 -2
- package/examples/distribution/sources/deck-one-pager.md +1 -1
- package/examples/distribution/sources/prd-platform.md +1 -1
- package/examples/distribution/sources/research-brief.md +2 -2
- package/examples/distribution/sources/rfc-platform.md +1 -1
- package/examples/distribution/sources/runbook.md +2 -2
- package/examples/distribution/sources/strategy.md +1 -1
- package/lib/agent-instructions/inject.mjs +1 -1
- package/lib/artifact-type-from-path.mjs +9 -5
- package/lib/audit-specialists.mjs +1 -1
- package/lib/audit-trail.mjs +3 -3
- package/lib/auto-docs.mjs +10 -10
- package/lib/bootstrap/built-ins.mjs +3 -3
- package/lib/boundary.mjs +3 -2
- package/lib/brand-prose.mjs +1 -1
- package/lib/cache-governor.js +4 -4
- package/lib/certification/document-io-fixtures.mjs +1 -1
- package/lib/chat/config.mjs +2 -2
- package/lib/chat/desktop-binary.mjs +5 -3
- package/lib/cli-commands.mjs +3 -3
- package/lib/cli-service-inventory.mjs +1 -1
- package/lib/comment-lint.mjs +9 -5
- package/lib/config/alias.mjs +3 -2
- package/lib/config/intake-policy.mjs +8 -78
- package/lib/config/project-config.mjs +95 -5
- package/lib/config/schema.mjs +9 -19
- package/lib/config/xdg.mjs +63 -0
- package/lib/contracts/violation-log.mjs +2 -2
- package/lib/cost-ledger.mjs +4 -3
- package/lib/cost.mjs +3 -2
- package/lib/decisions/registry.mjs +3 -3
- package/lib/distill.mjs +2 -2
- package/lib/docs-verify.mjs +2 -0
- package/lib/doctor/audit.mjs +4 -3
- package/lib/doctor/cli.mjs +0 -1
- package/lib/doctor/index.mjs +4 -4
- package/lib/doctor/report.mjs +3 -3
- package/lib/doctor/watchers/bd-watch.mjs +5 -5
- package/lib/doctor/watchers/cost.mjs +4 -4
- package/lib/doctor/watchers/disk.mjs +4 -3
- package/lib/doctor/watchers/service-health.mjs +5 -5
- package/lib/document-export.mjs +1 -1
- package/lib/document-extract.mjs +3 -3
- package/lib/document-ingest.mjs +13 -3
- package/lib/efficiency.mjs +2 -1
- package/lib/embed/approval-queue.mjs +3 -2
- package/lib/embed/cli.mjs +7 -5
- package/lib/embed/config.mjs +6 -5
- package/lib/embed/daemon.mjs +6 -5
- package/lib/embed/inbox-live-watcher.mjs +1 -1
- package/lib/embed/inbox.mjs +52 -37
- package/lib/embed/intake-metrics.mjs +3 -2
- package/lib/embed/reconcile.mjs +1 -1
- package/lib/embed/semantic.mjs +3 -2
- package/lib/embed/supervision.mjs +4 -2
- package/lib/engine/registry.mjs +3 -3
- package/lib/env-config.mjs +34 -12
- package/lib/features.mjs +4 -3
- package/lib/health-check.mjs +7 -6
- package/lib/hook-health.mjs +5 -4
- package/lib/hooks/_lib/log.mjs +4 -3
- package/lib/hooks/_lib/output-mode.mjs +2 -1
- package/lib/hooks/agent-tracker.mjs +3 -4
- package/lib/hooks/audit-reads.mjs +2 -2
- package/lib/hooks/audit-trail.mjs +2 -2
- package/lib/hooks/bash-output-logger.mjs +3 -3
- package/lib/hooks/brand-prose-lint.mjs +1 -1
- package/lib/hooks/ci-status-check.mjs +3 -2
- package/lib/hooks/context-watch.mjs +2 -2
- package/lib/hooks/context-window-recovery.mjs +5 -5
- package/lib/hooks/dep-audit.mjs +2 -2
- package/lib/hooks/doc-coupling-check.mjs +3 -2
- package/lib/hooks/edit-accumulator.mjs +3 -2
- package/lib/hooks/edit-guard.mjs +3 -3
- package/lib/hooks/guard-bash.mjs +2 -2
- package/lib/hooks/model-fallback.mjs +2 -1
- package/lib/hooks/policy-engine.mjs +4 -3
- package/lib/hooks/pre-compact.mjs +7 -7
- package/lib/hooks/readme-age-check.mjs +2 -2
- package/lib/hooks/session-optimize.mjs +4 -3
- package/lib/hooks/session-start.mjs +6 -5
- package/lib/hooks/stop-notify.mjs +9 -8
- package/lib/hooks/stop-typecheck.mjs +4 -3
- package/lib/hooks/test-watch.mjs +2 -2
- package/lib/host-disposition.mjs +1 -1
- package/lib/ingest/provider-extract.mjs +2 -1
- package/lib/init/detect-existing-structure.mjs +2 -2
- package/lib/init/doc-lanes.mjs +3 -10
- package/lib/init-docs.mjs +12 -54
- package/lib/init-unified.mjs +35 -81
- package/lib/init.mjs +7 -5
- package/lib/install/desktop-binary-download.mjs +5 -2
- package/lib/intake/daemon.mjs +31 -8
- package/lib/intake/intake-config.mjs +5 -32
- package/lib/integrations/intake-integrations.mjs +8 -7
- package/lib/knowledge/rag.mjs +2 -2
- package/lib/knowledge/search.mjs +15 -15
- package/lib/maintenance/cleanup.mjs +14 -10
- package/lib/mcp/server.mjs +1 -1
- package/lib/mcp/tools/project.mjs +2 -1
- package/lib/model-cheapest-provider.mjs +4 -3
- package/lib/model-pricing.mjs +3 -2
- package/lib/model-router.mjs +4 -3
- package/lib/models/catalog.mjs +2 -1
- package/lib/models/execution-capability-profile.mjs +2 -1
- package/lib/models/provider-poll.mjs +2 -1
- package/lib/ollama-manager.mjs +0 -1
- package/lib/op-log.mjs +2 -1
- package/lib/opencode-runtime-plugin.mjs +3 -2
- package/lib/oracle/artifact-gate.mjs +3 -0
- package/lib/oracle/cli.mjs +2 -1
- package/lib/oracle/execute.mjs +3 -2
- package/lib/oracle/index.mjs +2 -1
- package/lib/oracle/read-model.mjs +3 -2
- package/lib/performance/generate.mjs +4 -3
- package/lib/platforms/capabilities.mjs +1 -1
- package/lib/plugin-registry.mjs +2 -1
- package/lib/profiles/lifecycle.mjs +3 -3
- package/lib/project-profile.mjs +2 -1
- package/lib/project-root.mjs +9 -7
- package/lib/provider-capabilities.js +4 -3
- package/lib/providers/auth-manager.mjs +2 -1
- package/lib/providers/copilot-auth.mjs +3 -2
- package/lib/providers/creds.mjs +3 -2
- package/lib/providers/registry.mjs +3 -3
- package/lib/providers/secret-resolver.mjs +3 -2
- package/lib/read-tracker-store.mjs +2 -1
- package/lib/reconcile/mcp-entry-reconcile.mjs +2 -2
- package/lib/reflect.mjs +2 -1
- package/lib/registry/generate-docs.mjs +2 -2
- package/lib/roles/approval-surface.mjs +5 -4
- package/lib/roles/event-bus.mjs +0 -1
- package/lib/roles/gateway.mjs +6 -2
- package/lib/rules-delivery.mjs +1 -1
- package/lib/runtime-pressure.mjs +5 -3
- package/lib/sandbox.mjs +3 -2
- package/lib/scheduler/solo.mjs +6 -4
- package/lib/server/auth.mjs +4 -3
- package/lib/server/index.mjs +24 -26
- package/lib/server/insights.mjs +5 -4
- package/lib/server/webhook.mjs +2 -1
- package/lib/service-manager.mjs +5 -3
- package/lib/setup.mjs +9 -8
- package/lib/status.mjs +6 -5
- package/lib/storage/embeddings-local.mjs +3 -2
- package/lib/storage/sync.mjs +2 -2
- package/lib/telemetry/client.mjs +0 -1
- package/lib/telemetry/intent-verifications.mjs +6 -6
- package/lib/telemetry/model-pricing-catalog.mjs +4 -3
- package/lib/telemetry/rule-calls.mjs +3 -3
- package/lib/telemetry/skill-calls.mjs +3 -3
- package/lib/template-registry.mjs +1 -0
- package/lib/templates/visual-requirements.mjs +1 -1
- package/lib/test-corpus-inventory.mjs +1 -1
- package/lib/uninstall/uninstall.mjs +12 -10
- package/package.json +3 -3
- package/platforms/claude/settings.template.json +43 -43
- package/rules/common/no-fabrication.md +1 -1
- package/rules/common/release-gates.md +1 -1
- package/rules/common/research.md +1 -1
- package/rules/common/review-before-change.md +1 -1
- package/scripts/sync-specialists.mjs +11 -8
- package/skills/docs/adr-workflow.md +3 -3
- package/skills/docs/init-docs.md +9 -9
- package/skills/docs/prd-workflow.md +5 -5
- package/skills/docs/product-intelligence-review.md +1 -1
- package/skills/docs/product-intelligence-workflow.md +2 -2
- package/skills/docs/product-signal-workflow.md +1 -1
- package/skills/docs/runbook-workflow.md +4 -4
- package/skills/exploration/unknown-codebase-onboarding.md +1 -1
- package/skills/operating/orchestration-reference.md +1 -1
- package/skills/routing.md +3 -3
- package/specialists/prompts/cx-architect.md +1 -1
- package/specialists/prompts/cx-docs-keeper.md +1 -1
- package/specialists/prompts/cx-researcher.md +1 -1
- package/specialists/prompts/cx-sre.md +1 -1
- package/specialists/role-manifests.json +6 -6
- package/templates/docs/README.md +125 -0
- package/templates/docs/construct_guide.md +3 -4
- package/templates/docs/persona-artifact.md +1 -1
- package/templates/docs/prds/README.md +25 -0
- package/templates/docs/prds/templates/_template.md +206 -0
- package/templates/docs/prds/templates/meta-prd.template.md +177 -0
- package/templates/docs/prds/templates/prd-business.template.md +61 -0
- package/templates/docs/prds/templates/prd-platform.template.md +81 -0
- package/templates/docs/prds/templates/prfaq.template.md +38 -0
- package/templates/docs/rfcs/README.md +22 -0
- package/templates/docs/rfcs/templates/_template.md +58 -0
- package/templates/docs/rfcs/templates/rfc-platform.template.md +72 -0
- package/templates/homebrew/construct.rb +1 -1
- package/templates/workflows/new-feature.yml +8 -8
- package/lib/intake/legacy-paths.mjs +0 -5
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
import { spawnSync } from 'node:child_process';
|
|
15
15
|
import { existsSync } from 'node:fs';
|
|
16
16
|
import { join } from 'node:path';
|
|
17
|
-
import os from 'node:os';
|
|
18
17
|
import { registerResource, getResource } from './resources.mjs';
|
|
18
|
+
import { cacheDir } from '../config/xdg.mjs';
|
|
19
19
|
|
|
20
20
|
function probeCommandVersion(cmd, args = ['--version']) {
|
|
21
21
|
const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: 3000 });
|
|
@@ -56,8 +56,8 @@ async function probePostgresContainer() {
|
|
|
56
56
|
|
|
57
57
|
async function probeEmbeddingModel() {
|
|
58
58
|
const cachePath = join(
|
|
59
|
-
|
|
60
|
-
'
|
|
59
|
+
cacheDir(),
|
|
60
|
+
'embeddings',
|
|
61
61
|
'Xenova', 'all-MiniLM-L6-v2', 'onnx', 'model_quantized.onnx'
|
|
62
62
|
);
|
|
63
63
|
if (existsSync(cachePath)) {
|
package/lib/boundary.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Verifies a parent Construct instance is reachable, validates an HMAC
|
|
5
5
|
* signature over `childInstanceId|nonce` when a shared secret is configured,
|
|
6
|
-
* and persists the binding to
|
|
6
|
+
* and persists the binding to boundary.json in the XDG config dir (mode 0600). A
|
|
7
7
|
* different parent rotates the registration only when explicitly allowed via
|
|
8
8
|
* CONSTRUCT_BOUNDARY_ALLOW_OVERRIDE=1; prior configs are archived alongside
|
|
9
9
|
* the active one. Exposed as a standalone module so the dashboard endpoint
|
|
@@ -16,11 +16,12 @@ import { homedir } from 'node:os';
|
|
|
16
16
|
import { createHmac, timingSafeEqual, randomBytes } from 'node:crypto';
|
|
17
17
|
import { request as httpRequest } from 'node:http';
|
|
18
18
|
import { request as httpsRequest } from 'node:https';
|
|
19
|
+
import { configDir } from './config/xdg.mjs';
|
|
19
20
|
|
|
20
21
|
export const BOUNDARY_VERSION = '1.0';
|
|
21
22
|
|
|
22
23
|
function boundaryDir(home = homedir()) {
|
|
23
|
-
return
|
|
24
|
+
return configDir(home);
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
export function boundaryConfigPath(home = homedir()) {
|
package/lib/brand-prose.mjs
CHANGED
|
@@ -37,7 +37,7 @@ const EXCLUDE_PATH =
|
|
|
37
37
|
/(node_modules|\.git|audit-artifacts|scripts\/audit|CHANGELOG\.md|fonts\/legacy\/|\.next\/|\/out\/|tests\/e2e\/reports\/|package-lock\.json)/;
|
|
38
38
|
|
|
39
39
|
const DOC_EXEMPT =
|
|
40
|
-
/(?:^|\/)(docs\/STYLE\.md|docs\/reference\/branding\.md|templates\/distribution\/fonts\/README\.md)$/;
|
|
40
|
+
/(?:^|\/)(docs\/STYLE\.md|docs\/guides\/reference\/branding\.md|templates\/distribution\/fonts\/README\.md)$/;
|
|
41
41
|
|
|
42
42
|
const HOOK_SCOPED =
|
|
43
43
|
/^(docs\/|skills\/|specialists\/|personas\/|templates\/|rules\/|apps\/(dashboard|docs|chat)\/)/;
|
package/lib/cache-governor.js
CHANGED
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
|
-
import { homedir } from 'node:os';
|
|
14
13
|
import { normalizeCostEntry } from './cost.mjs';
|
|
14
|
+
import { doctorRoot } from './config/xdg.mjs';
|
|
15
15
|
|
|
16
|
-
const STRATEGY_PATH = join(
|
|
16
|
+
const STRATEGY_PATH = join(doctorRoot(), 'cache-strategy.json');
|
|
17
17
|
const REVIEW_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes between adjustments
|
|
18
18
|
|
|
19
19
|
function readStrategy() {
|
|
@@ -27,7 +27,7 @@ function readStrategy() {
|
|
|
27
27
|
|
|
28
28
|
function writeStrategy(data) {
|
|
29
29
|
try {
|
|
30
|
-
mkdirSync(
|
|
30
|
+
mkdirSync(doctorRoot(), { recursive: true });
|
|
31
31
|
writeFileSync(STRATEGY_PATH, JSON.stringify(data, null, 2));
|
|
32
32
|
} catch { /* best effort */ }
|
|
33
33
|
}
|
|
@@ -153,7 +153,7 @@ export function onPostToolUse({ costLogPath, provider, dryRun = false } = {}) {
|
|
|
153
153
|
// Read cost log
|
|
154
154
|
const entries = [];
|
|
155
155
|
try {
|
|
156
|
-
const logPath = costLogPath || join(
|
|
156
|
+
const logPath = costLogPath || join(doctorRoot(), 'session-cost.jsonl');
|
|
157
157
|
if (!existsSync(logPath)) return { adjusted: false, reason: 'No cost log' };
|
|
158
158
|
|
|
159
159
|
const lines = readFileSync(logPath, 'utf8')
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lib/certification/document-io-fixtures.mjs — intake fixture catalog for document I/O tests.
|
|
3
3
|
*
|
|
4
|
-
* Maps docs/reference/document-io.md supported intake categories to committed
|
|
4
|
+
* Maps docs/guides/reference/document-io.md supported intake categories to committed
|
|
5
5
|
* files under tests/fixtures/document-io/<category>/.
|
|
6
6
|
*/
|
|
7
7
|
|
package/lib/chat/config.mjs
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
|
|
15
15
|
import fs from 'node:fs';
|
|
16
16
|
import path from 'node:path';
|
|
17
|
-
import os from 'node:os';
|
|
18
17
|
import { resolveProjectScopedPath } from '../project-root.mjs';
|
|
18
|
+
import { doctorRoot } from '../config/xdg.mjs';
|
|
19
19
|
|
|
20
20
|
export const LAYER_KEYS = ['thinking', 'path', 'specialists', 'tools', 'observability'];
|
|
21
21
|
export const PERMISSION_MODES = ['ask', 'allow_once', 'allow_always', 'reject'];
|
|
@@ -64,7 +64,7 @@ function mergeConfig(base, override) {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
export function globalConfigPath() {
|
|
67
|
-
return path.join(
|
|
67
|
+
return path.join(doctorRoot(), CONFIG_BASENAME);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
export function projectConfigPath({ cwd = process.cwd() } = {}) {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lib/chat/desktop-binary.mjs — resolve the Construct chat desktop window binary.
|
|
3
3
|
*
|
|
4
|
-
* Search order: CONSTRUCT_CHAT_DESKTOP_BIN, repo dev build,
|
|
5
|
-
* then packaged sidecar next to construct.
|
|
4
|
+
* Search order: CONSTRUCT_CHAT_DESKTOP_BIN, repo dev build, the bin/ subdir of
|
|
5
|
+
* the XDG state dir, then packaged sidecar next to construct.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import fs from 'node:fs';
|
|
@@ -10,6 +10,8 @@ import os from 'node:os';
|
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
|
|
13
|
+
import { stateDir } from '../config/xdg.mjs';
|
|
14
|
+
|
|
13
15
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
14
16
|
const REPO_ROOT = path.resolve(HERE, '..', '..');
|
|
15
17
|
|
|
@@ -50,7 +52,7 @@ export function desktopBinaryCandidates({ repoRoot = REPO_ROOT, homeDir = os.hom
|
|
|
50
52
|
env.CONSTRUCT_CHAT_DESKTOP_BIN,
|
|
51
53
|
path.join(tauriRoot, 'release', name),
|
|
52
54
|
path.join(tauriRoot, 'debug', name),
|
|
53
|
-
path.join(homeDir, '
|
|
55
|
+
path.join(stateDir(homeDir), 'bin', name),
|
|
54
56
|
path.join(repoRoot, 'apps', 'chat', 'desktop', 'bin', name),
|
|
55
57
|
].filter(Boolean);
|
|
56
58
|
}
|
package/lib/cli-commands.mjs
CHANGED
|
@@ -1112,7 +1112,7 @@ export const CLI_COMMANDS = [
|
|
|
1112
1112
|
emoji: '📄',
|
|
1113
1113
|
category: 'Diagnostics',
|
|
1114
1114
|
core: false,
|
|
1115
|
-
description: 'Regenerate generated reference pages under docs/reference/',
|
|
1115
|
+
description: 'Regenerate generated reference pages under docs/guides/reference/',
|
|
1116
1116
|
usage: 'construct docs:site [--check]',
|
|
1117
1117
|
},
|
|
1118
1118
|
{
|
|
@@ -1146,7 +1146,7 @@ export const CLI_COMMANDS = [
|
|
|
1146
1146
|
{ name: 'specialist', category: 'Internal', core: false, internal: true, description: 'Maintainer tool: scaffold, edit, and lint specialist prompts', usage: 'construct specialist <create|edit|lint>' },
|
|
1147
1147
|
{ name: 'registry:status', category: 'Internal', core: false, internal: true, surface: 'internal', description: 'Dev: capability registry inspector', usage: 'construct registry:status [--json]' },
|
|
1148
1148
|
{ name: 'registry:validate', category: 'Internal', core: false, internal: true, surface: 'internal', description: 'Validate registry/capabilities.json against repo reality', usage: 'construct registry:validate [--json]' },
|
|
1149
|
-
{ name: 'registry:generate-docs', category: 'Internal', core: false, internal: true, surface: 'internal', description: 'Generate docs/reference/capabilities.md from registry', usage: 'construct registry:generate-docs' },
|
|
1149
|
+
{ name: 'registry:generate-docs', category: 'Internal', core: false, internal: true, surface: 'internal', description: 'Generate docs/guides/reference/capabilities.md from registry', usage: 'construct registry:generate-docs' },
|
|
1150
1150
|
{ name: 'oracle', category: 'Internal', core: false, internal: true, surface: 'internal', description: 'Oracle meta-controller — fleet health review and bounded-auto maintenance', usage: 'construct oracle start|status|review|pending|approve|gaps|reconcile' },
|
|
1151
1151
|
{ name: 'matrix', category: 'Internal', core: false, internal: true, surface: 'internal', description: 'Living dependency matrix — build/inspect the typed file↔capability↔workflow↔test graph', usage: 'construct matrix build|stat|query <node-id> [--json]' },
|
|
1152
1152
|
{ name: 'impact', category: 'Diagnostics', core: false, internal: false, surface: 'thin-cli', description: 'Change-impact analysis — map changed files to affected tests, capabilities, and workflows', usage: 'construct impact [files…] [--stdin] [--run] [--json]' },
|
|
@@ -1271,7 +1271,7 @@ export function formatCommandHelp(name, { colors = false } = {}) {
|
|
|
1271
1271
|
lines.push(`${c.dim}Next:${c.reset} ${next}`);
|
|
1272
1272
|
lines.push('');
|
|
1273
1273
|
}
|
|
1274
|
-
lines.push(`${c.dim}See also:${c.reset} run \`construct --help\` for all commands; full reference in docs/reference/cli/.`);
|
|
1274
|
+
lines.push(`${c.dim}See also:${c.reset} run \`construct --help\` for all commands; full reference in docs/guides/reference/cli/.`);
|
|
1275
1275
|
lines.push('');
|
|
1276
1276
|
return lines.join('\n');
|
|
1277
1277
|
}
|
|
@@ -25,7 +25,7 @@ function handlerNames(rootDir) {
|
|
|
25
25
|
export function buildCliServiceInventory({ rootDir = process.cwd() } = {}) {
|
|
26
26
|
const handlers = handlerNames(rootDir);
|
|
27
27
|
return CLI_COMMANDS.filter((spec) => !spec.internal).map((spec) => {
|
|
28
|
-
const page = path.join(rootDir, 'docs', 'reference', 'cli', `${slugify(spec.category)}.md`);
|
|
28
|
+
const page = path.join(rootDir, 'docs', 'guides', 'reference', 'cli', `${slugify(spec.category)}.md`);
|
|
29
29
|
const pageText = fs.existsSync(page) ? fs.readFileSync(page, 'utf8') : '';
|
|
30
30
|
return {
|
|
31
31
|
name: spec.name,
|
package/lib/comment-lint.mjs
CHANGED
|
@@ -34,11 +34,15 @@ const MD_HEADER_GLOBS = [
|
|
|
34
34
|
// Scope is deliberately narrow — READMEs, cookbook, concepts, and reference are
|
|
35
35
|
// unaffected. The patterns live in BANNED_ARTIFACT below; the severity (block
|
|
36
36
|
// vs warn) is controlled by CONSTRUCT_ARTIFACT_LINT_MODE.
|
|
37
|
+
// Match this repo's bucketed layout (docs/specs/prd, docs/decisions/adr, …) and
|
|
38
|
+
// the flat init-lane layout that `construct init` scaffolds downstream (docs/prd,
|
|
39
|
+
// docs/adr, …), so artifact-prose linting fires in either project shape.
|
|
40
|
+
|
|
37
41
|
const ARTIFACT_PATH_GLOBS = [
|
|
38
|
-
/^docs\/prd\//,
|
|
39
|
-
/^docs\/adr\//,
|
|
40
|
-
/^docs\/rfc\//,
|
|
41
|
-
/^docs\/research\//,
|
|
42
|
+
/^docs\/(?:specs\/)?prd\//,
|
|
43
|
+
/^docs\/(?:decisions\/)?adr\//,
|
|
44
|
+
/^docs\/(?:decisions\/)?rfc\//,
|
|
45
|
+
/^docs\/(?:notes\/)?research\//,
|
|
42
46
|
/^\.cx\/knowledge\//,
|
|
43
47
|
/^\.cx\/handoffs\//,
|
|
44
48
|
/^\.cx\/research\//,
|
|
@@ -55,7 +59,7 @@ function isArtifactPath(rel) {
|
|
|
55
59
|
// Deliverable surfaces where a tool-identity leak (rules/common/tool-invisibility.md)
|
|
56
60
|
// matters: project docs and the durable .cx knowledge / research / handoff / strategy
|
|
57
61
|
// stores. Broader than the fabrication-scoped ARTIFACT_PATH_GLOBS because a strategy
|
|
58
|
-
// lands in docs/ root, not only docs/prd.
|
|
62
|
+
// lands in docs/ root, not only docs/specs/prd.
|
|
59
63
|
|
|
60
64
|
const DELIVERABLE_LEAK_GLOBS = [
|
|
61
65
|
/^docs\/.*\.md$/,
|
package/lib/config/alias.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Lookup precedence:
|
|
5
5
|
* 1. CONSTRUCT_ALIAS env var (per-session override)
|
|
6
|
-
* 2.
|
|
6
|
+
* 2. config.json.aliasOverride in the XDG config dir (user-level, lets each
|
|
7
7
|
* user on the same project see their own branding without
|
|
8
8
|
* committing it)
|
|
9
9
|
* 3. construct.config.json.alias (project-level, committed)
|
|
@@ -17,6 +17,7 @@ import fs from 'node:fs';
|
|
|
17
17
|
import os from 'node:os';
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
import { loadProjectConfig } from './project-config.mjs';
|
|
20
|
+
import { configDir } from './xdg.mjs';
|
|
20
21
|
|
|
21
22
|
export const DEFAULT_ALIAS = 'Construct';
|
|
22
23
|
export const ALIAS_ENV_KEY = 'CONSTRUCT_ALIAS';
|
|
@@ -29,7 +30,7 @@ export function resolveAlias({ cwd = process.cwd(), env = process.env, homeDir =
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
try {
|
|
32
|
-
const userCfgPath = path.join(homeDir, '
|
|
33
|
+
const userCfgPath = path.join(configDir(homeDir), 'config.json');
|
|
33
34
|
if (fs.existsSync(userCfgPath)) {
|
|
34
35
|
const userCfg = JSON.parse(fs.readFileSync(userCfgPath, 'utf8'));
|
|
35
36
|
const userAlias = userCfg?.aliasOverride;
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lib/config/intake-policy.mjs — project-scoped intake watcher policy.
|
|
3
3
|
*
|
|
4
|
-
* Resolves
|
|
5
|
-
* construct.config.json intakePolicy
|
|
6
|
-
*
|
|
4
|
+
* Resolves the canonical drop zone, scan depth, and additional directories from
|
|
5
|
+
* construct.config.json intakePolicy (the only config source), with env
|
|
6
|
+
* overrides and a default when intakePolicy is absent.
|
|
7
|
+
*
|
|
8
|
+
* Single-zone model (ADR-0045 §C): the only drop zone is the project-root
|
|
9
|
+
* `inbox/`, always watched. `additionalDirs` (opt-in extra dirs) and `maxDepth`
|
|
10
|
+
* remain configurable. There are no other zones — `.cx/inbox/` and `docs/intake/`
|
|
11
|
+
* are not watched and not scaffolded.
|
|
7
12
|
*/
|
|
8
13
|
|
|
9
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
10
14
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
11
15
|
import { loadProjectConfig, writeProjectConfig, findProjectConfigPath, PROJECT_CONFIG_FILENAME } from './project-config.mjs';
|
|
12
16
|
import {
|
|
@@ -15,17 +19,11 @@ import {
|
|
|
15
19
|
INTAKE_DEPTH_GUIDANCE,
|
|
16
20
|
describeIntakeDepth,
|
|
17
21
|
} from '../intake/constants.mjs';
|
|
18
|
-
import { INTAKE_CONFIG_FILE } from '../intake/legacy-paths.mjs';
|
|
19
22
|
|
|
20
23
|
export { INTAKE_DEFAULT_MAX_DEPTH, INTAKE_HARD_MAX_DEPTH, INTAKE_DEPTH_GUIDANCE, describeIntakeDepth };
|
|
21
24
|
|
|
22
25
|
export const DEFAULT_INTAKE_POLICY = Object.freeze({
|
|
23
26
|
maxDepth: INTAKE_DEFAULT_MAX_DEPTH,
|
|
24
|
-
zones: Object.freeze({
|
|
25
|
-
rootInbox: true,
|
|
26
|
-
projectInbox: true,
|
|
27
|
-
docsIntake: true,
|
|
28
|
-
}),
|
|
29
27
|
additionalDirs: [],
|
|
30
28
|
});
|
|
31
29
|
|
|
@@ -48,69 +46,24 @@ function parseEnvDirs(env) {
|
|
|
48
46
|
return raw.split(':').map((p) => p.trim()).filter(Boolean);
|
|
49
47
|
}
|
|
50
48
|
|
|
51
|
-
function readLegacyIntakeConfig(rootDir) {
|
|
52
|
-
const file = join(rootDir, INTAKE_CONFIG_FILE);
|
|
53
|
-
if (!existsSync(file)) return null;
|
|
54
|
-
try {
|
|
55
|
-
return JSON.parse(readFileSync(file, 'utf8'));
|
|
56
|
-
} catch {
|
|
57
|
-
return null;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
49
|
export function intakePolicyFromProjectConfig(config) {
|
|
62
50
|
const raw = config?.intakePolicy ?? {};
|
|
63
|
-
const zones = raw.zones ?? {};
|
|
64
51
|
return {
|
|
65
52
|
maxDepth: clampDepth(raw.maxDepth ?? DEFAULT_INTAKE_POLICY.maxDepth),
|
|
66
|
-
zones: {
|
|
67
|
-
rootInbox: zones.rootInbox !== false,
|
|
68
|
-
projectInbox: zones.projectInbox !== false,
|
|
69
|
-
docsIntake: zones.docsIntake !== false,
|
|
70
|
-
},
|
|
71
53
|
additionalDirs: Array.isArray(raw.additionalDirs)
|
|
72
54
|
? raw.additionalDirs.map((d) => String(d).trim()).filter(Boolean)
|
|
73
55
|
: [],
|
|
74
56
|
};
|
|
75
57
|
}
|
|
76
58
|
|
|
77
|
-
export function legacyIntakeToPolicy(stored = {}) {
|
|
78
|
-
return {
|
|
79
|
-
maxDepth: clampDepth(stored.maxDepth ?? INTAKE_DEFAULT_MAX_DEPTH),
|
|
80
|
-
zones: {
|
|
81
|
-
rootInbox: stored.includeArchetypeInbox === true || stored.includeRootInbox === true,
|
|
82
|
-
projectInbox: stored.includeProjectInbox !== false,
|
|
83
|
-
docsIntake: stored.includeDocsIntake !== false,
|
|
84
|
-
},
|
|
85
|
-
additionalDirs: Array.isArray(stored.parentDirs) ? stored.parentDirs : [],
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function policyToLegacyIntakeShape(policy) {
|
|
90
|
-
return {
|
|
91
|
-
parentDirs: policy.additionalDirs,
|
|
92
|
-
maxDepth: policy.maxDepth,
|
|
93
|
-
includeProjectInbox: policy.zones.projectInbox,
|
|
94
|
-
includeDocsIntake: policy.zones.docsIntake,
|
|
95
|
-
includeArchetypeInbox: policy.zones.rootInbox,
|
|
96
|
-
includeRootInbox: policy.zones.rootInbox,
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
59
|
export function loadIntakePolicy(rootDir, env = process.env) {
|
|
101
60
|
const { config, path: configPath, raw } = loadProjectConfig(rootDir, env);
|
|
102
|
-
const legacy = readLegacyIntakeConfig(rootDir);
|
|
103
61
|
let policy;
|
|
104
62
|
let source = 'default';
|
|
105
|
-
let legacyWarning = null;
|
|
106
63
|
|
|
107
64
|
if (configPath && raw?.intakePolicy) {
|
|
108
65
|
policy = intakePolicyFromProjectConfig(config);
|
|
109
66
|
source = 'project-config';
|
|
110
|
-
} else if (legacy) {
|
|
111
|
-
policy = legacyIntakeToPolicy(legacy);
|
|
112
|
-
source = 'legacy-intake-config';
|
|
113
|
-
legacyWarning = `Using deprecated ${INTAKE_CONFIG_FILE}; migrate with \`construct intake config migrate\``;
|
|
114
67
|
} else {
|
|
115
68
|
policy = structuredClone(DEFAULT_INTAKE_POLICY);
|
|
116
69
|
source = 'default';
|
|
@@ -134,10 +87,8 @@ export function loadIntakePolicy(rootDir, env = process.env) {
|
|
|
134
87
|
|
|
135
88
|
return {
|
|
136
89
|
maxDepth,
|
|
137
|
-
zones: { ...policy.zones },
|
|
138
90
|
additionalDirs: uniqueDirs,
|
|
139
91
|
source,
|
|
140
|
-
legacyWarning,
|
|
141
92
|
};
|
|
142
93
|
}
|
|
143
94
|
|
|
@@ -146,12 +97,7 @@ export function resolvedIntakeConfig(rootDir, env = process.env) {
|
|
|
146
97
|
return {
|
|
147
98
|
parentDirs: policy.additionalDirs,
|
|
148
99
|
maxDepth: policy.maxDepth,
|
|
149
|
-
includeProjectInbox: policy.zones.projectInbox,
|
|
150
|
-
includeDocsIntake: policy.zones.docsIntake,
|
|
151
|
-
includeArchetypeInbox: policy.zones.rootInbox,
|
|
152
|
-
includeRootInbox: policy.zones.rootInbox,
|
|
153
100
|
source: policy.source,
|
|
154
|
-
legacyWarning: policy.legacyWarning,
|
|
155
101
|
};
|
|
156
102
|
}
|
|
157
103
|
|
|
@@ -160,14 +106,8 @@ export function saveIntakePolicy(rootDir, patch = {}, options = {}) {
|
|
|
160
106
|
const { config } = loadProjectConfig(rootDir, {});
|
|
161
107
|
const current = intakePolicyFromProjectConfig(config.intakePolicy ? config : { intakePolicy: DEFAULT_INTAKE_POLICY });
|
|
162
108
|
|
|
163
|
-
const nextZones = { ...current.zones, ...(patch.zones ?? {}) };
|
|
164
109
|
const next = {
|
|
165
110
|
maxDepth: clampDepth(patch.maxDepth ?? current.maxDepth),
|
|
166
|
-
zones: {
|
|
167
|
-
rootInbox: patch.rootInbox !== undefined ? Boolean(patch.rootInbox) : (patch.zones?.rootInbox ?? nextZones.rootInbox),
|
|
168
|
-
projectInbox: patch.projectInbox !== undefined ? Boolean(patch.projectInbox) : (patch.zones?.projectInbox ?? nextZones.projectInbox),
|
|
169
|
-
docsIntake: patch.docsIntake !== undefined ? Boolean(patch.docsIntake) : (patch.zones?.docsIntake ?? nextZones.docsIntake),
|
|
170
|
-
},
|
|
171
111
|
additionalDirs: Array.isArray(patch.additionalDirs)
|
|
172
112
|
? patch.additionalDirs.map((dir) => normalizeDir(dir, rootDir)).filter(Boolean)
|
|
173
113
|
: current.additionalDirs,
|
|
@@ -197,13 +137,3 @@ export function saveIntakePolicy(rootDir, patch = {}, options = {}) {
|
|
|
197
137
|
writeProjectConfig(configPath, updated);
|
|
198
138
|
return { policy: next, configPath };
|
|
199
139
|
}
|
|
200
|
-
|
|
201
|
-
export function migrateLegacyIntakeConfig(rootDir) {
|
|
202
|
-
const legacy = readLegacyIntakeConfig(rootDir);
|
|
203
|
-
if (!legacy) {
|
|
204
|
-
return { migrated: false, reason: 'no legacy file' };
|
|
205
|
-
}
|
|
206
|
-
const policy = legacyIntakeToPolicy(legacy);
|
|
207
|
-
saveIntakePolicy(rootDir, policy);
|
|
208
|
-
return { migrated: true, policy };
|
|
209
|
-
}
|
|
@@ -8,9 +8,15 @@
|
|
|
8
8
|
* corresponding env-var value at load time. Pointers keep API keys in
|
|
9
9
|
* `.env` where they belong; the JSON config never sees a secret literal.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
* `
|
|
13
|
-
*
|
|
11
|
+
* Two project tiers, most-specific wins: a gitignored
|
|
12
|
+
* `construct.config.local.json` sibling merges OVER the committed
|
|
13
|
+
* `construct.config.json`. List settings (sources.targets, intake
|
|
14
|
+
* additionalDirs, permission allowlists) merge + dedupe; scalars and objects
|
|
15
|
+
* override key-by-key.
|
|
16
|
+
*
|
|
17
|
+
* Precedence rule for consumers: env var if set > config (local ▷ committed)
|
|
18
|
+
* > default. `resolveSetting(config, jsonPath, env, envKey, default)` encodes
|
|
19
|
+
* the rule once so call sites stay consistent.
|
|
14
20
|
*/
|
|
15
21
|
import fs from 'node:fs';
|
|
16
22
|
import path from 'node:path';
|
|
@@ -18,6 +24,7 @@ import { CONFIG_SCHEMA_VERSION, DEFAULT_PROJECT_CONFIG, validateProjectConfig, F
|
|
|
18
24
|
import { validateSourceTargets } from './source-targets.mjs';
|
|
19
25
|
|
|
20
26
|
export const PROJECT_CONFIG_FILENAME = 'construct.config.json';
|
|
27
|
+
export const PROJECT_LOCAL_CONFIG_FILENAME = 'construct.config.local.json';
|
|
21
28
|
|
|
22
29
|
export function findProjectConfigPath(cwd = process.cwd()) {
|
|
23
30
|
let dir = path.resolve(cwd);
|
|
@@ -62,6 +69,64 @@ function deepMerge(base, override) {
|
|
|
62
69
|
return out;
|
|
63
70
|
}
|
|
64
71
|
|
|
72
|
+
// Identity for list-tier dedupe: object entries collapse on their `id`
|
|
73
|
+
// (source targets, typed selectors) so a local override that re-declares an id
|
|
74
|
+
// replaces the committed entry rather than duplicating it; everything else
|
|
75
|
+
// dedupes on structural equality.
|
|
76
|
+
|
|
77
|
+
function listKey(item) {
|
|
78
|
+
if (item !== null && typeof item === 'object' && !Array.isArray(item) && typeof item.id === 'string') {
|
|
79
|
+
return `id:${item.id}`;
|
|
80
|
+
}
|
|
81
|
+
return `json:${JSON.stringify(item)}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function mergeList(base, override) {
|
|
85
|
+
const out = [];
|
|
86
|
+
const seen = new Map();
|
|
87
|
+
for (const item of [...(base || []), ...(override || [])]) {
|
|
88
|
+
const key = listKey(item);
|
|
89
|
+
if (seen.has(key)) {
|
|
90
|
+
out[seen.get(key)] = item;
|
|
91
|
+
} else {
|
|
92
|
+
seen.set(key, out.length);
|
|
93
|
+
out.push(item);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Project-local tier merge (most-specific wins): lists merge + dedupe so a local
|
|
100
|
+
// override adds to committed lists (extra source targets, intake dirs, allowlist
|
|
101
|
+
// entries) instead of clobbering them; scalars and objects override key-by-key.
|
|
102
|
+
|
|
103
|
+
function mergeConfigTier(base, override) {
|
|
104
|
+
if (override === undefined) return base;
|
|
105
|
+
if (Array.isArray(base) && Array.isArray(override)) return mergeList(base, override);
|
|
106
|
+
if (override === null || typeof override !== 'object' || Array.isArray(override)) return override;
|
|
107
|
+
const out = Array.isArray(base) ? [...(base || [])] : { ...(base || {}) };
|
|
108
|
+
for (const [k, v] of Object.entries(override)) {
|
|
109
|
+
out[k] = mergeConfigTier(base?.[k], v);
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function loadLocalConfigOverlay(configPath) {
|
|
115
|
+
const localPath = path.join(path.dirname(configPath), PROJECT_LOCAL_CONFIG_FILENAME);
|
|
116
|
+
if (!fs.existsSync(localPath)) return { raw: null, path: null, errors: [] };
|
|
117
|
+
let raw;
|
|
118
|
+
try {
|
|
119
|
+
raw = JSON.parse(fs.readFileSync(localPath, 'utf8'));
|
|
120
|
+
} catch (err) {
|
|
121
|
+
return { raw: null, path: localPath, errors: [`failed to parse ${localPath}: ${err.message}`] };
|
|
122
|
+
}
|
|
123
|
+
const validation = validateProjectConfig(raw, { partial: true });
|
|
124
|
+
if (!validation.valid) {
|
|
125
|
+
return { raw: null, path: localPath, errors: (validation.errors ?? []).map((e) => `${localPath}: ${e}`) };
|
|
126
|
+
}
|
|
127
|
+
return { raw, path: localPath, errors: [] };
|
|
128
|
+
}
|
|
129
|
+
|
|
65
130
|
export function loadProjectConfig(cwd = process.cwd(), env = process.env) {
|
|
66
131
|
const configPath = findProjectConfigPath(cwd);
|
|
67
132
|
if (!configPath) {
|
|
@@ -99,13 +164,38 @@ export function loadProjectConfig(cwd = process.cwd(), env = process.env) {
|
|
|
99
164
|
errors: allErrors,
|
|
100
165
|
};
|
|
101
166
|
}
|
|
167
|
+
const overlay = loadLocalConfigOverlay(configPath);
|
|
168
|
+
if (overlay.errors.length) {
|
|
169
|
+
return {
|
|
170
|
+
path: configPath,
|
|
171
|
+
raw,
|
|
172
|
+
config: structuredClone(DEFAULT_PROJECT_CONFIG),
|
|
173
|
+
source: 'invalid',
|
|
174
|
+
errors: overlay.errors,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (overlay.raw?.sources?.targets !== undefined) {
|
|
178
|
+
const overlayTargetErrors = validateSourceTargets(overlay.raw.sources.targets);
|
|
179
|
+
if (overlayTargetErrors.length) {
|
|
180
|
+
return {
|
|
181
|
+
path: configPath,
|
|
182
|
+
raw,
|
|
183
|
+
config: structuredClone(DEFAULT_PROJECT_CONFIG),
|
|
184
|
+
source: 'invalid',
|
|
185
|
+
errors: overlayTargetErrors.map((e) => `${overlay.path}: ${e}`),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
102
190
|
const merged = deepMerge(structuredClone(DEFAULT_PROJECT_CONFIG), raw);
|
|
103
|
-
const
|
|
191
|
+
const withLocal = overlay.raw ? mergeConfigTier(merged, overlay.raw) : merged;
|
|
192
|
+
const resolved = interpolateSecrets(withLocal, env);
|
|
104
193
|
return {
|
|
105
194
|
path: configPath,
|
|
106
195
|
raw,
|
|
196
|
+
localPath: overlay.path,
|
|
107
197
|
config: resolved,
|
|
108
|
-
source: 'file',
|
|
198
|
+
source: overlay.raw ? 'file+local' : 'file',
|
|
109
199
|
errors: [],
|
|
110
200
|
};
|
|
111
201
|
}
|
package/lib/config/schema.mjs
CHANGED
|
@@ -53,11 +53,6 @@ export const DEFAULT_PROJECT_CONFIG = Object.freeze({
|
|
|
53
53
|
}),
|
|
54
54
|
intakePolicy: Object.freeze({
|
|
55
55
|
maxDepth: 4,
|
|
56
|
-
zones: Object.freeze({
|
|
57
|
-
rootInbox: true,
|
|
58
|
-
projectInbox: true,
|
|
59
|
-
docsIntake: true,
|
|
60
|
-
}),
|
|
61
56
|
additionalDirs: [],
|
|
62
57
|
}),
|
|
63
58
|
artifactWorkflow: Object.freeze({
|
|
@@ -151,15 +146,6 @@ export const FIELD_RULES = {
|
|
|
151
146
|
fields: {
|
|
152
147
|
maxDepth: { type: 'number' },
|
|
153
148
|
additionalDirs: { type: 'array' },
|
|
154
|
-
zones: {
|
|
155
|
-
type: 'object',
|
|
156
|
-
required: false,
|
|
157
|
-
fields: {
|
|
158
|
-
rootInbox: { type: 'boolean' },
|
|
159
|
-
projectInbox: { type: 'boolean' },
|
|
160
|
-
docsIntake: { type: 'boolean' },
|
|
161
|
-
},
|
|
162
|
-
},
|
|
163
149
|
},
|
|
164
150
|
},
|
|
165
151
|
artifactWorkflow: {
|
|
@@ -269,10 +255,10 @@ function checkType(value, expected) {
|
|
|
269
255
|
return typeof value === expected;
|
|
270
256
|
}
|
|
271
257
|
|
|
272
|
-
function validateField(value, rule, path) {
|
|
258
|
+
function validateField(value, rule, path, partial = false) {
|
|
273
259
|
const errors = [];
|
|
274
260
|
if (value === undefined) {
|
|
275
|
-
if (rule.required) errors.push(`${path}: required field missing`);
|
|
261
|
+
if (rule.required && !partial) errors.push(`${path}: required field missing`);
|
|
276
262
|
return errors;
|
|
277
263
|
}
|
|
278
264
|
if (!checkType(value, rule.type)) {
|
|
@@ -287,19 +273,23 @@ function validateField(value, rule, path) {
|
|
|
287
273
|
}
|
|
288
274
|
if (rule.fields && checkType(value, 'object')) {
|
|
289
275
|
for (const [key, subRule] of Object.entries(rule.fields)) {
|
|
290
|
-
errors.push(...validateField(value[key], subRule, `${path}.${key}
|
|
276
|
+
errors.push(...validateField(value[key], subRule, `${path}.${key}`, partial));
|
|
291
277
|
}
|
|
292
278
|
}
|
|
293
279
|
return errors;
|
|
294
280
|
}
|
|
295
281
|
|
|
296
|
-
|
|
282
|
+
// `partial` validation skips the required-field check so a sparse override file
|
|
283
|
+
// (construct.config.local.json) validates, while still type- and enum-checking
|
|
284
|
+
// every key that is present.
|
|
285
|
+
|
|
286
|
+
export function validateProjectConfig(raw, { partial = false } = {}) {
|
|
297
287
|
const errors = [];
|
|
298
288
|
if (!checkType(raw, 'object')) {
|
|
299
289
|
return { valid: false, errors: ['root: must be an object'] };
|
|
300
290
|
}
|
|
301
291
|
for (const [key, rule] of Object.entries(FIELD_RULES)) {
|
|
302
|
-
errors.push(...validateField(raw[key], rule, key));
|
|
292
|
+
errors.push(...validateField(raw[key], rule, key, partial));
|
|
303
293
|
}
|
|
304
294
|
if (raw.version !== undefined && raw.version !== CONFIG_SCHEMA_VERSION) {
|
|
305
295
|
errors.push(`version: expected ${CONFIG_SCHEMA_VERSION}, got ${raw.version}`);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/config/xdg.mjs — single source of truth for Construct's user-scope base
|
|
3
|
+
* directories, following the XDG Base Directory specification.
|
|
4
|
+
*
|
|
5
|
+
* Keeps config (versionable settings), state (durable runtime data), and cache
|
|
6
|
+
* (regenerable transients) on separate roots so each can be backed up, synced,
|
|
7
|
+
* and pruned independently:
|
|
8
|
+
*
|
|
9
|
+
* configDir() → $XDG_CONFIG_HOME/construct (default ~/.config/construct)
|
|
10
|
+
* stateDir() → $XDG_STATE_HOME/construct (default ~/.local/state/construct)
|
|
11
|
+
* cacheDir() → $XDG_CACHE_HOME/construct (default ~/.cache/construct)
|
|
12
|
+
*
|
|
13
|
+
* Per the XDG spec, an env var is honored only when it is set AND absolute;
|
|
14
|
+
* a relative or empty value is ignored and the default applies. Every helper
|
|
15
|
+
* takes an optional homeDir for testability, mirroring the existing homeDir
|
|
16
|
+
* params across the codebase.
|
|
17
|
+
*
|
|
18
|
+
* Clean break: there is no legacy `~/.construct/*` read here. Existing installs
|
|
19
|
+
* re-run `construct install --scope=user` to repopulate at the XDG paths.
|
|
20
|
+
*/
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
// Leaf segment appended under each resolved base.
|
|
25
|
+
|
|
26
|
+
const APP = 'construct';
|
|
27
|
+
|
|
28
|
+
// XDG honors an env override only when it names an absolute path; anything
|
|
29
|
+
// relative or empty falls through to the spec default so a stray cwd-relative
|
|
30
|
+
// value can never relocate user state under the process working directory.
|
|
31
|
+
|
|
32
|
+
function resolveBase(envValue, fallbackSegments, homeDir) {
|
|
33
|
+
if (typeof envValue === 'string' && path.isAbsolute(envValue)) {
|
|
34
|
+
return envValue;
|
|
35
|
+
}
|
|
36
|
+
return path.join(homeDir, ...fallbackSegments);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function configDir(homeDir = os.homedir(), env = process.env) {
|
|
40
|
+
const base = resolveBase(env.XDG_CONFIG_HOME, ['.config'], homeDir);
|
|
41
|
+
return path.join(base, APP);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function stateDir(homeDir = os.homedir(), env = process.env) {
|
|
45
|
+
const base = resolveBase(env.XDG_STATE_HOME, ['.local', 'state'], homeDir);
|
|
46
|
+
return path.join(base, APP);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The global doctor/telemetry/runtime axis (formerly rooted at ~/.cx) now
|
|
50
|
+
// resolves to the XDG state dir. CONSTRUCT_DOCTOR_ROOT, shared by the
|
|
51
|
+
// telemetry/doctor/oracle ecosystem for test isolation, still wins when set to
|
|
52
|
+
// a non-empty value; otherwise stateDir() supplies the durable default.
|
|
53
|
+
|
|
54
|
+
export function doctorRoot(homeDir = os.homedir(), env = process.env) {
|
|
55
|
+
const override = env.CONSTRUCT_DOCTOR_ROOT;
|
|
56
|
+
if (typeof override === 'string' && override.trim()) return override;
|
|
57
|
+
return stateDir(homeDir, env);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function cacheDir(homeDir = os.homedir(), env = process.env) {
|
|
61
|
+
const base = resolveBase(env.XDG_CACHE_HOME, ['.cache'], homeDir);
|
|
62
|
+
return path.join(base, APP);
|
|
63
|
+
}
|