@geraldmaron/construct 1.0.20 → 1.0.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 +23 -5
- package/bin/construct +194 -16
- package/bin/construct-postinstall.mjs +25 -15
- package/lib/acp/server.mjs +113 -0
- package/lib/agent-instructions/inject.mjs +94 -0
- package/lib/auto-docs.mjs +10 -2
- package/lib/cli-commands.mjs +44 -16
- package/lib/comment-lint.mjs +115 -0
- package/lib/completions.mjs +7 -1
- package/lib/config/schema.mjs +22 -0
- package/lib/decisions/enforced-baseline.json +3 -0
- package/lib/docs-verify.mjs +15 -18
- package/lib/doctor/cli.mjs +8 -1
- package/lib/document-export.mjs +124 -0
- package/lib/document-ingest.mjs +13 -4
- package/lib/embed/daemon.mjs +1 -1
- package/lib/embed/inbox.mjs +25 -7
- package/lib/embedded-contract/triage.mjs +24 -2
- package/lib/embedded-contract/workflow-invoke.mjs +21 -0
- package/lib/features.mjs +11 -11
- package/lib/git-hooks-path.mjs +61 -0
- package/lib/home-namespace.mjs +60 -0
- package/lib/hooks/ci-status-check.mjs +62 -40
- package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
- package/lib/hooks/pre-push-gate.mjs +15 -6
- package/lib/hooks/stop-notify.mjs +32 -17
- package/lib/hooks/stop-typecheck.mjs +7 -2
- package/lib/host-capabilities.mjs +24 -8
- package/lib/host-disposition.mjs +76 -0
- package/lib/ingest/provider-extract.mjs +1 -1
- package/lib/ingest/strategy.mjs +35 -3
- package/lib/init-docs.mjs +1 -1
- package/lib/init-unified.mjs +320 -219
- package/lib/init-update.mjs +4 -84
- package/lib/init.mjs +9 -25
- package/lib/install/stage-project.mjs +8 -2
- package/lib/intent-classifier.mjs +1 -1
- package/lib/knowledge/search.mjs +52 -3
- package/lib/mcp/server.mjs +57 -14
- package/lib/mcp/tools/memory.mjs +2 -2
- package/lib/mcp/tools/orchestration-run.mjs +125 -0
- package/lib/model-registry.mjs +40 -33
- package/lib/observation-store.mjs +6 -2
- package/lib/opencode-config.mjs +1 -1
- package/lib/orchestration/events.mjs +66 -0
- package/lib/orchestration/run-store-postgres.mjs +85 -0
- package/lib/orchestration/run-store-sqlite.mjs +122 -0
- package/lib/orchestration/runtime.mjs +188 -50
- package/lib/orchestration/store.mjs +102 -0
- package/lib/orchestration/worker.mjs +215 -0
- package/lib/orchestration-policy.mjs +27 -3
- package/lib/parity.mjs +80 -26
- package/lib/policy/unified-gates.mjs +96 -0
- package/lib/project-init-shared.mjs +0 -173
- package/lib/reconcile/adapter-prune.mjs +105 -0
- package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
- package/lib/reconcile/gitignore-coverage.mjs +88 -0
- package/lib/reconcile/index.mjs +171 -0
- package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
- package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
- package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
- package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
- package/lib/reconcile/postgres-namespace.mjs +102 -0
- package/lib/runtime/uv-bootstrap.mjs +27 -3
- package/lib/schema-infer.mjs +16 -2
- package/lib/server/csrf.mjs +14 -2
- package/lib/server/index.mjs +95 -0
- package/lib/service-manager.mjs +39 -14
- package/lib/setup-prompts.mjs +2 -1
- package/lib/setup.mjs +165 -141
- package/lib/storage/backend.mjs +14 -0
- package/lib/storage/unified-storage.mjs +550 -0
- package/lib/template-registry.mjs +73 -0
- package/lib/term-format.mjs +75 -0
- package/lib/uninstall/uninstall.mjs +180 -7
- package/package.json +2 -2
- package/personas/construct.md +7 -8
- package/platforms/claude/settings.template.json +30 -4
- package/platforms/opencode/config.template.json +2 -2
- package/rules/common/neurodivergent-output.md +66 -0
- package/rules/common/tool-invisibility.md +37 -0
- package/scripts/sync-specialists.mjs +381 -95
- package/skills/operating/orchestration-reference.md +2 -16
- package/specialists/policy-inventory.json +14 -0
- package/specialists/prompts/cx-accessibility.md +2 -6
- package/specialists/prompts/cx-ai-engineer.md +0 -4
- package/specialists/prompts/cx-architect.md +3 -5
- package/specialists/prompts/cx-business-strategist.md +0 -5
- package/specialists/prompts/cx-data-analyst.md +0 -4
- package/specialists/prompts/cx-data-engineer.md +0 -4
- package/specialists/prompts/cx-debugger.md +2 -6
- package/specialists/prompts/cx-designer.md +0 -8
- package/specialists/prompts/cx-devil-advocate.md +2 -2
- package/specialists/prompts/cx-docs-keeper.md +0 -13
- package/specialists/prompts/cx-engineer.md +0 -13
- package/specialists/prompts/cx-evaluator.md +2 -2
- package/specialists/prompts/cx-explorer.md +4 -5
- package/specialists/prompts/cx-legal-compliance.md +4 -5
- package/specialists/prompts/cx-operations.md +0 -5
- package/specialists/prompts/cx-orchestrator.md +0 -4
- package/specialists/prompts/cx-platform-engineer.md +0 -8
- package/specialists/prompts/cx-product-manager.md +0 -8
- package/specialists/prompts/cx-qa.md +2 -11
- package/specialists/prompts/cx-rd-lead.md +0 -5
- package/specialists/prompts/cx-release-manager.md +0 -8
- package/specialists/prompts/cx-researcher.md +5 -29
- package/specialists/prompts/cx-reviewer.md +2 -6
- package/specialists/prompts/cx-security.md +2 -11
- package/specialists/prompts/cx-sre.md +0 -15
- package/specialists/prompts/cx-test-automation.md +0 -4
- package/specialists/prompts/cx-trace-reviewer.md +2 -2
- package/specialists/prompts/cx-ux-researcher.md +0 -4
- package/specialists/registry.json +29 -29
- package/templates/distribution/run.mjs +36 -7
- package/templates/docs/accessibility-audit.md +56 -0
- package/templates/docs/architecture-review.md +59 -0
- package/templates/docs/code-review-report.md +46 -0
- package/templates/docs/construct_guide.md +14 -14
- package/templates/docs/debug-investigation.md +53 -0
- package/templates/docs/qa-report.md +48 -0
- package/templates/docs/security-audit-report.md +48 -0
- package/templates/docs/task-packet.md +49 -0
- package/templates/docs/verdict.md +40 -0
- package/lib/server/static/index.html +0 -1
|
@@ -37,12 +37,32 @@ function which(bin) {
|
|
|
37
37
|
return result.stdout.trim().split('\n')[0] || null;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// First-run docling provisioning blocks for minutes (uv install, venv, then a
|
|
41
|
+
// large `uv pip install docling` that pulls ML deps). Without a heartbeat it
|
|
42
|
+
// reads as a hang. Progress goes to stderr only, so JSON consumers on stdout are
|
|
43
|
+
// unaffected; it is the long-operation's own channel, not a suppressible notice.
|
|
44
|
+
|
|
45
|
+
function progress(message) {
|
|
46
|
+
try { process.stderr.write(`[docling setup] ${message}\n`); } catch { /* stderr closed */ }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// spawnSync sets status=null and error.code=ETIMEDOUT on timeout; distinguish a
|
|
50
|
+
// timeout from a real non-zero exit so the message tells the user what to do.
|
|
51
|
+
|
|
52
|
+
function describeStepFailure(label, result, timeoutMs) {
|
|
53
|
+
if (result.error?.code === 'ETIMEDOUT' || result.signal === 'SIGTERM') {
|
|
54
|
+
return `${label} timed out after ${Math.round(timeoutMs / 1000)}s — docling's one-time download did not complete. Check network/proxy and re-run; or set ingest.fallback to "adapter" to skip docling.`;
|
|
55
|
+
}
|
|
56
|
+
return `${label} failed (${result.status}): ${result.stderr || result.stdout || result.error?.message || 'unknown error'}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
40
59
|
function ensureUv(installDir) {
|
|
41
60
|
const fromPath = which('uv');
|
|
42
61
|
if (fromPath) return fromPath;
|
|
43
62
|
const cachedUv = path.join(installDir, 'bin', 'uv');
|
|
44
63
|
if (existsSync(cachedUv)) return cachedUv;
|
|
45
64
|
mkdirSync(installDir, { recursive: true });
|
|
65
|
+
progress('Installing uv (Python toolchain) — first run only…');
|
|
46
66
|
const env = { ...process.env, UV_INSTALL_DIR: path.join(installDir, 'bin'), UV_NO_MODIFY_PATH: '1' };
|
|
47
67
|
const sh = spawnSync('sh', ['-c', `curl -LsSf ${UV_INSTALL_URL} | sh`], {
|
|
48
68
|
env,
|
|
@@ -50,7 +70,7 @@ function ensureUv(installDir) {
|
|
|
50
70
|
encoding: 'utf8',
|
|
51
71
|
});
|
|
52
72
|
if (sh.status !== 0) {
|
|
53
|
-
throw new Error(
|
|
73
|
+
throw new Error(describeStepFailure('uv install', sh, UV_TIMEOUT_MS));
|
|
54
74
|
}
|
|
55
75
|
if (!existsSync(cachedUv)) {
|
|
56
76
|
throw new Error(`uv install reported success but binary not found at ${cachedUv}`);
|
|
@@ -86,23 +106,27 @@ export function ensureDoclingVenv({ runtimeDir = defaultRuntimeDir(), force = fa
|
|
|
86
106
|
return { pythonBin: existingPython, venvDir, runtimeDir, fresh: false };
|
|
87
107
|
}
|
|
88
108
|
|
|
109
|
+
progress('Provisioning the docling document extractor (first run). This downloads a Python runtime and ML dependencies and can take several minutes; later runs are instant.');
|
|
89
110
|
const uv = ensureUv(runtimeDir);
|
|
90
111
|
|
|
112
|
+
progress('Creating Python 3.11 virtualenv…');
|
|
91
113
|
const venvResult = spawnSync(uv, ['venv', venvDir, '--python', '3.11'], {
|
|
92
114
|
timeout: VENV_TIMEOUT_MS,
|
|
93
115
|
encoding: 'utf8',
|
|
94
116
|
});
|
|
95
117
|
if (venvResult.status !== 0) {
|
|
96
|
-
throw new Error(
|
|
118
|
+
throw new Error(describeStepFailure('uv venv', venvResult, VENV_TIMEOUT_MS));
|
|
97
119
|
}
|
|
98
120
|
|
|
121
|
+
progress(`Installing docling ${DOCLING_PIN} and its dependencies — the slow step (large download)…`);
|
|
99
122
|
const installResult = spawnSync(uv, ['pip', 'install', '--python', pythonBinFor(venvDir), `docling==${DOCLING_PIN}`], {
|
|
100
123
|
timeout: INSTALL_TIMEOUT_MS,
|
|
101
124
|
encoding: 'utf8',
|
|
102
125
|
});
|
|
103
126
|
if (installResult.status !== 0) {
|
|
104
|
-
throw new Error(
|
|
127
|
+
throw new Error(describeStepFailure('docling install', installResult, INSTALL_TIMEOUT_MS));
|
|
105
128
|
}
|
|
129
|
+
progress('docling extractor ready.');
|
|
106
130
|
|
|
107
131
|
const pythonBin = pythonBinFor(venvDir);
|
|
108
132
|
writeMarker(markerPath, {
|
package/lib/schema-infer.mjs
CHANGED
|
@@ -210,9 +210,23 @@ async function callModel(userContent, { traceId } = {}) {
|
|
|
210
210
|
}
|
|
211
211
|
|
|
212
212
|
// If only Anthropic key available, use configured fast tier directly.
|
|
213
|
+
// Construct ships with no implicit model defaults (ADR-0027,
|
|
214
|
+
// tests/model-router-no-defaults.test.mjs), so an unconfigured fast tier
|
|
215
|
+
// resolves to null. Surface a clear configuration error instead of letting
|
|
216
|
+
// null leak into the candidate loop where /^anthropic\//.test(null) throws.
|
|
217
|
+
|
|
213
218
|
if (candidates.length === 0 && anthropicKey) {
|
|
214
219
|
const envPath = getUserEnvPath();
|
|
215
|
-
|
|
220
|
+
const fast = readCurrentModels(envPath, {}).fast;
|
|
221
|
+
if (!fast) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
"Schema inference requires a fast-tier model. " +
|
|
224
|
+
"Either set OPENROUTER_API_KEY (to use free model fallbacks) or " +
|
|
225
|
+
"configure a fast tier with 'construct models --apply' or " +
|
|
226
|
+
"'export CX_MODEL_FAST=<model-id>'."
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
candidates = [fast];
|
|
216
230
|
}
|
|
217
231
|
|
|
218
232
|
let lastError;
|
|
@@ -261,7 +275,7 @@ async function callModel(userContent, { traceId } = {}) {
|
|
|
261
275
|
headers: {
|
|
262
276
|
Authorization: `Bearer ${orKey}`,
|
|
263
277
|
'content-type': 'application/json',
|
|
264
|
-
'HTTP-Referer': 'https://github.com/construct',
|
|
278
|
+
'HTTP-Referer': 'https://github.com/geraldmaron/construct',
|
|
265
279
|
},
|
|
266
280
|
body: JSON.stringify({
|
|
267
281
|
model: openRouterModelId,
|
package/lib/server/csrf.mjs
CHANGED
|
@@ -66,6 +66,11 @@ export function ensureCsrfCookie(req, res) {
|
|
|
66
66
|
*/
|
|
67
67
|
export function verifyCsrf(req, { skip } = {}) {
|
|
68
68
|
if (SAFE_METHODS.has(req.method)) return true;
|
|
69
|
+
// A request authenticated by an Authorization header (bearer token) is not
|
|
70
|
+
// cookie-driven, so it cannot be forged cross-origin — CSRF defends ambient
|
|
71
|
+
// cookie auth, not header tokens. This is what lets programmatic API clients
|
|
72
|
+
// (editor adapters, CLI, CI) POST without the browser CSRF dance.
|
|
73
|
+
if (req.headers && req.headers.authorization) return true;
|
|
69
74
|
if (typeof skip === 'function' && skip(req.url || '')) return true;
|
|
70
75
|
const headerVal = req.headers[HEADER_NAME];
|
|
71
76
|
const cookieVal = parseCookies(req)[COOKIE_NAME];
|
|
@@ -78,13 +83,20 @@ export function verifyCsrf(req, { skip } = {}) {
|
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
/**
|
|
81
|
-
* Default skip predicate — webhooks
|
|
82
|
-
*
|
|
86
|
+
* Default skip predicate — webhooks, auth/login, and the orchestration API
|
|
87
|
+
* legitimately accept POSTs without the CSRF dance. CSRF defends a browser
|
|
88
|
+
* cookie session; the `/api/orchestration/*` endpoints are consumed only by
|
|
89
|
+
* programmatic clients (the `--remote` CLI, the MCP tool, editor adapters) that
|
|
90
|
+
* carry a bearer token (or nothing in no-token mode), never the dashboard
|
|
91
|
+
* cookie — so CSRF is the wrong control there, and the auth gate still protects
|
|
92
|
+
* them in token mode. Without this, a no-token daemon rejects every orchestration
|
|
93
|
+
* run with a 403.
|
|
83
94
|
*/
|
|
84
95
|
export function defaultSkip(url) {
|
|
85
96
|
if (!url) return false;
|
|
86
97
|
return (
|
|
87
98
|
url.startsWith('/api/webhooks/') ||
|
|
99
|
+
url.startsWith('/api/orchestration/') ||
|
|
88
100
|
url === '/api/auth/login' ||
|
|
89
101
|
url === '/api/slack/commands'
|
|
90
102
|
);
|
package/lib/server/index.mjs
CHANGED
|
@@ -32,6 +32,14 @@ import { onEmbedNotification } from '../embed/notifications.mjs';
|
|
|
32
32
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
33
33
|
const ROOT_DIR = join(__dirname, '..', '..');
|
|
34
34
|
const HOME = homedir();
|
|
35
|
+
|
|
36
|
+
// The daemon is a long-lived, detached service not bound to one project, so its
|
|
37
|
+
// orchestration runs persist under the user data root (HOME → ~/.cx/runtime/
|
|
38
|
+
// orchestration via the run store's relative path) rather than the install
|
|
39
|
+
// directory. A global install dir can be shared or read-only and would mix
|
|
40
|
+
// every project's runs together; HOME is always writable and survives reinstalls.
|
|
41
|
+
|
|
42
|
+
const ORCHESTRATION_RUN_ROOT = HOME;
|
|
35
43
|
const PORT = parseInt(process.env.PORT ?? '4242', 10);
|
|
36
44
|
|
|
37
45
|
// Default bind is loopback regardless of NODE_ENV. Exposing the dashboard to
|
|
@@ -464,6 +472,88 @@ async function handleApprovals(req, res) {
|
|
|
464
472
|
res.end('Method Not Allowed');
|
|
465
473
|
}
|
|
466
474
|
|
|
475
|
+
// Orchestration daemon surface: start a run (background execution), inspect runs,
|
|
476
|
+
// and stream a single run's lifecycle over SSE. Construct's runtime owns the
|
|
477
|
+
// orchestration; this lets any thin client (editor, TUI, CI) reach the outcome.
|
|
478
|
+
// Responses are wrapped in the versioned, secret-free embedded-contract envelope.
|
|
479
|
+
|
|
480
|
+
async function handleOrchestration(req, res) {
|
|
481
|
+
const url = new URL(req.url, `http://${BIND_HOST}:${PORT}`);
|
|
482
|
+
const { startRun, getRun, getRuns } = await import('../orchestration/runtime.mjs');
|
|
483
|
+
const { onRunEvent, requestCancel } = await import('../orchestration/events.mjs');
|
|
484
|
+
const { wrapResponse } = await import('../embedded-contract/envelope.mjs');
|
|
485
|
+
|
|
486
|
+
const send = (code, data) => {
|
|
487
|
+
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
488
|
+
res.end(JSON.stringify(wrapResponse({ data, surface: 'sdk' })));
|
|
489
|
+
};
|
|
490
|
+
const fail = (code, message) => {
|
|
491
|
+
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
492
|
+
res.end(JSON.stringify({ error: message }));
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
if (url.pathname === '/api/orchestration/runs' && req.method === 'POST') {
|
|
496
|
+
let body = '';
|
|
497
|
+
req.on('data', (c) => { body += c; });
|
|
498
|
+
req.on('end', async () => {
|
|
499
|
+
try {
|
|
500
|
+
const d = JSON.parse(body || '{}');
|
|
501
|
+
if (!d.request || typeof d.request !== 'string') throw new Error('Missing "request" string');
|
|
502
|
+
const run = await startRun(
|
|
503
|
+
{ request: d.request, workflowType: d.workflowType, requestedStrategy: d.requestedStrategy, host: d.host, hostModel: d.hostModel, hostProvider: d.hostProvider, fileCount: d.fileCount, moduleCount: d.moduleCount },
|
|
504
|
+
{ cwd: ORCHESTRATION_RUN_ROOT, workerBackend: d.workerBackend || null },
|
|
505
|
+
);
|
|
506
|
+
send(202, run);
|
|
507
|
+
} catch (err) {
|
|
508
|
+
fail(400, err.message);
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (url.pathname === '/api/orchestration/runs' && req.method === 'GET') {
|
|
515
|
+
const limit = Number(url.searchParams.get('limit') || 20);
|
|
516
|
+
send(200, { runs: await getRuns(ORCHESTRATION_RUN_ROOT, { limit }) });
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const match = url.pathname.match(/^\/api\/orchestration\/runs\/([^/]+)(\/events|\/cancel)?$/);
|
|
521
|
+
if (match) {
|
|
522
|
+
const runId = decodeURIComponent(match[1]);
|
|
523
|
+
const sub = match[2];
|
|
524
|
+
|
|
525
|
+
if (!sub && req.method === 'GET') {
|
|
526
|
+
const run = await getRun(ORCHESTRATION_RUN_ROOT, runId);
|
|
527
|
+
if (!run) { fail(404, `Run not found: ${runId}`); return; }
|
|
528
|
+
send(200, run);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (sub === '/cancel' && req.method === 'POST') {
|
|
533
|
+
requestCancel(runId);
|
|
534
|
+
send(202, { runId, cancelRequested: true });
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (sub === '/events' && req.method === 'GET') {
|
|
539
|
+
const run = await getRun(ORCHESTRATION_RUN_ROOT, runId);
|
|
540
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'Access-Control-Allow-Origin': '*' });
|
|
541
|
+
res.write(': connected\n\n');
|
|
542
|
+
if (run) {
|
|
543
|
+
const snapshot = { type: 'snapshot', runId, status: run.status, chainOfThought: run.chainOfThought ?? null, tasks: (run.tasks || []).map((t) => ({ id: t.id, role: t.role, status: t.status, executor: t.executor, reasoning: t.reasoning ?? null })) };
|
|
544
|
+
res.write(`data: ${JSON.stringify(snapshot)}\n\n`);
|
|
545
|
+
}
|
|
546
|
+
const off = onRunEvent(runId, (event) => {
|
|
547
|
+
try { res.write(`data: ${JSON.stringify(event)}\n\n`); } catch { off(); }
|
|
548
|
+
});
|
|
549
|
+
req.on('close', off);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
fail(404, 'Not found');
|
|
555
|
+
}
|
|
556
|
+
|
|
467
557
|
// ── Config handler ────────────────────────────────────────────────────────
|
|
468
558
|
|
|
469
559
|
function handleConfig(req, res) {
|
|
@@ -701,6 +791,11 @@ const server = createServer(async (req, res) => {
|
|
|
701
791
|
return;
|
|
702
792
|
}
|
|
703
793
|
|
|
794
|
+
if (url.pathname.startsWith('/api/orchestration/')) {
|
|
795
|
+
await handleOrchestration(req, res);
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
|
|
704
799
|
if (url.pathname === '/api/registry' && req.method === 'GET') {
|
|
705
800
|
try {
|
|
706
801
|
const registry = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
|
package/lib/service-manager.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import fs from 'node:fs';
|
|
|
10
10
|
import net from 'node:net';
|
|
11
11
|
import os from 'node:os';
|
|
12
12
|
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
13
14
|
|
|
14
15
|
import { findAvailablePort } from './host-capabilities.mjs';
|
|
15
16
|
import { getUserEnvPath, loadConstructEnv, parseEnvFile, writeEnvValues } from './env-config.mjs';
|
|
@@ -22,15 +23,24 @@ import {
|
|
|
22
23
|
isRemoteTelemetry,
|
|
23
24
|
} from './services/local-postgres.mjs';
|
|
24
25
|
import { resolveTraceBackend, telemetryProviderLabel } from './telemetry/client.mjs';
|
|
26
|
+
import { postgresPort, postgresContainerName, memoryPort as derivedMemoryPort } from './home-namespace.mjs';
|
|
25
27
|
|
|
26
28
|
const CONSTRUCT_PG_COMPOSE_DIR = 'services/postgres';
|
|
27
|
-
const CONSTRUCT_PG_CONTAINER =
|
|
28
|
-
const CONSTRUCT_PG_PORT =
|
|
29
|
+
const CONSTRUCT_PG_CONTAINER = postgresContainerName();
|
|
30
|
+
const CONSTRUCT_PG_PORT = postgresPort();
|
|
29
31
|
const CONSTRUCT_PG_HEALTH_RETRIES = 12;
|
|
30
32
|
const CONSTRUCT_PG_HEALTH_INTERVAL_MS = 2000;
|
|
31
33
|
|
|
32
34
|
const DASHBOARD_STATE_FILE = 'dashboard.json';
|
|
33
35
|
|
|
36
|
+
// The caller's `rootDir` is the project (for compose/context/traces), but the
|
|
37
|
+
// dashboard and doctor entrypoints ship with the install, not the project. They
|
|
38
|
+
// must resolve against the install root — derived from this module's own
|
|
39
|
+
// location — or `construct init` spawns `<project>/lib/server/index.mjs` and the
|
|
40
|
+
// process dies with MODULE_NOT_FOUND while still advertising the URL.
|
|
41
|
+
|
|
42
|
+
const INSTALL_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
43
|
+
|
|
34
44
|
// pruneStashDir and verifyTelemetryKeys now live in lib/services/telemetry-backend.mjs.
|
|
35
45
|
// Re-exported below at module bottom for tests that already import from here.
|
|
36
46
|
|
|
@@ -260,7 +270,7 @@ export function startDoctor({ rootDir, homeDir = os.homedir() } = {}) {
|
|
|
260
270
|
}
|
|
261
271
|
const existing = readDoctorState(homeDir);
|
|
262
272
|
if (existing) return { started: false, reused: true, pid: existing.pid };
|
|
263
|
-
const indexPath = path.join(
|
|
273
|
+
const indexPath = path.join(INSTALL_ROOT, 'lib', 'doctor', 'index.mjs');
|
|
264
274
|
if (!fs.existsSync(indexPath)) return { started: false, reason: 'missing-binary' };
|
|
265
275
|
const { logPath } = spawnDetached('node', [indexPath], homeDir, 'doctor.log', { cwd: rootDir, env: { ...process.env } });
|
|
266
276
|
return { started: true, logPath };
|
|
@@ -279,13 +289,30 @@ export async function startDashboard({ rootDir, homeDir = os.homedir(), preferre
|
|
|
279
289
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
280
290
|
const out = fs.openSync(outPath, 'a');
|
|
281
291
|
const err = fs.openSync(outPath, 'a');
|
|
282
|
-
const child = spawn(process.execPath, [path.join(
|
|
292
|
+
const child = spawn(process.execPath, [path.join(INSTALL_ROOT, 'lib', 'server', 'index.mjs')], {
|
|
283
293
|
detached: true,
|
|
284
294
|
stdio: ['ignore', out, err],
|
|
285
295
|
env: { ...process.env, PORT: String(port) },
|
|
286
296
|
});
|
|
287
297
|
child.unref();
|
|
288
298
|
|
|
299
|
+
// Confirm the process actually bound the port before persisting state or
|
|
300
|
+
// advertising the URL. Without this the caller reports a dead PID/URL when the
|
|
301
|
+
// server crashes on boot — the readiness gate turns a silent failure into an
|
|
302
|
+
// honest, actionable one (the log tail names the cause).
|
|
303
|
+
|
|
304
|
+
let ready = false;
|
|
305
|
+
for (let i = 0; i < 30; i += 1) {
|
|
306
|
+
if (await probeRuntimePort(port)) { ready = true; break; }
|
|
307
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!ready) {
|
|
311
|
+
let logTail = '';
|
|
312
|
+
try { logTail = fs.readFileSync(outPath, 'utf8').split('\n').slice(-12).join('\n'); } catch { /* log unreadable */ }
|
|
313
|
+
return { started: false, failed: true, port, logPath: outPath, error: `dashboard did not bind 127.0.0.1:${port} within 6s`, logTail };
|
|
314
|
+
}
|
|
315
|
+
|
|
289
316
|
const state = {
|
|
290
317
|
pid: child.pid,
|
|
291
318
|
port,
|
|
@@ -320,7 +347,7 @@ export async function getRuntimePorts(homeDir = os.homedir(), {
|
|
|
320
347
|
|
|
321
348
|
return {
|
|
322
349
|
dashboard: dashboard?.port ?? await resolvePort('DASHBOARD_PORT', 4242, dashboardProbeFn),
|
|
323
|
-
memory: await resolvePort('MEMORY_PORT',
|
|
350
|
+
memory: await resolvePort('MEMORY_PORT', derivedMemoryPort(), memoryProbeFn),
|
|
324
351
|
bridge: await resolvePort('BRIDGE_PORT', 5173, openCodeProbeFn),
|
|
325
352
|
};
|
|
326
353
|
}
|
|
@@ -375,13 +402,13 @@ async function waitForConstructPostgresHealthy({
|
|
|
375
402
|
}
|
|
376
403
|
|
|
377
404
|
function startConstructPostgres({ rootDir, homeDir = os.homedir(), spawnSyncFn = spawnSync, detectDockerComposeFn = detectDockerCompose } = {}) {
|
|
378
|
-
const composeRunner = detectDockerComposeFn();
|
|
405
|
+
const composeRunner = detectDockerComposeFn({ autoStart: true });
|
|
379
406
|
if (!composeRunner) return { status: 'unavailable', note: 'Docker not available' };
|
|
380
407
|
|
|
381
408
|
const composeFile = constructPgComposePath(rootDir);
|
|
382
409
|
if (!fs.existsSync(composeFile)) return { status: 'unavailable', note: 'Postgres compose file not found — run construct init first' };
|
|
383
410
|
|
|
384
|
-
const args = [...composeRunner.argsPrefix, '-p',
|
|
411
|
+
const args = [...composeRunner.argsPrefix, '-p', CONSTRUCT_PG_CONTAINER, '-f', composeFile, 'up', '-d'];
|
|
385
412
|
const r = spawnSyncFn(composeRunner.command, args, { stdio: 'pipe', encoding: 'utf8' });
|
|
386
413
|
if (r.status === 0) return { status: 'started' };
|
|
387
414
|
return { status: 'error', note: (r.stderr || '').trim().split('\n')[0] || 'compose up failed' };
|
|
@@ -394,7 +421,7 @@ function stopConstructPostgres({ rootDir, homeDir = os.homedir(), spawnSyncFn =
|
|
|
394
421
|
const composeFile = constructPgComposePath(rootDir);
|
|
395
422
|
if (!fs.existsSync(composeFile)) return { status: 'skipped', note: 'no compose file' };
|
|
396
423
|
|
|
397
|
-
const args = [...composeRunner.argsPrefix, '-p',
|
|
424
|
+
const args = [...composeRunner.argsPrefix, '-p', CONSTRUCT_PG_CONTAINER, '-f', composeFile, 'down'];
|
|
398
425
|
const r = spawnSyncFn(composeRunner.command, args, { stdio: 'pipe', encoding: 'utf8' });
|
|
399
426
|
if (r.status === 0) return { status: 'stopped' };
|
|
400
427
|
return { status: 'error', note: (r.stderr || '').trim().split('\n')[0] || 'compose down failed' };
|
|
@@ -540,11 +567,9 @@ export async function startServices({
|
|
|
540
567
|
// Dashboard
|
|
541
568
|
if (wants('dashboard')) {
|
|
542
569
|
const dashboard = await startDashboardFn({ rootDir, homeDir, preferredPort: ports.dashboard });
|
|
543
|
-
results.push(
|
|
544
|
-
name: 'Dashboard',
|
|
545
|
-
url: dashboard.url,
|
|
546
|
-
status: dashboard.reused ? 'reused' : 'started',
|
|
547
|
-
});
|
|
570
|
+
results.push(dashboard.failed
|
|
571
|
+
? { name: 'Dashboard', status: 'failed', note: `${dashboard.error} — see ${dashboard.logPath}` }
|
|
572
|
+
: { name: 'Dashboard', url: dashboard.url, status: dashboard.reused ? 'reused' : 'started' });
|
|
548
573
|
}
|
|
549
574
|
|
|
550
575
|
const telemetryUrl = liveEnv.CONSTRUCT_TELEMETRY_URL ?? '';
|
|
@@ -705,7 +730,7 @@ export async function stopServices({
|
|
|
705
730
|
// Memory (cm) — find port from config.env or fall back to default
|
|
706
731
|
const envPath = getUserEnvPath(homeDir);
|
|
707
732
|
const envValues = parseEnvFile(envPath);
|
|
708
|
-
const memoryPort = Number(envValues.MEMORY_PORT) ||
|
|
733
|
+
const memoryPort = Number(envValues.MEMORY_PORT) || derivedMemoryPort();
|
|
709
734
|
const cmKilled = killPortOwners(memoryPort, spawnSyncFn);
|
|
710
735
|
results.push({ name: 'Memory (cm)', status: cmKilled ? 'stopped' : 'not-running' });
|
|
711
736
|
|
package/lib/setup-prompts.mjs
CHANGED
|
@@ -29,6 +29,7 @@ export async function consentToInstall({
|
|
|
29
29
|
alreadyConfigured = false,
|
|
30
30
|
alreadyConfiguredNote = '',
|
|
31
31
|
envPath,
|
|
32
|
+
question = '',
|
|
32
33
|
defaultYes = true,
|
|
33
34
|
readlineModule = readline,
|
|
34
35
|
stdin = process.stdin,
|
|
@@ -64,7 +65,7 @@ export async function consentToInstall({
|
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
const answer = await promptYesNo({
|
|
67
|
-
question: `Install local ${name} via Docker?`,
|
|
68
|
+
question: question || `Install local ${name} via Docker?`,
|
|
68
69
|
defaultYes,
|
|
69
70
|
readlineModule,
|
|
70
71
|
stdin,
|