@descix/cli 1.0.1 → 1.0.3
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/LICENSE.md +671 -0
- package/README.md +43 -4
- package/agent-assets/guides/README.md +2 -2
- package/agent-assets/guides/dynamic-site-pattern.md +29 -14
- package/agent-assets/guides/knowledge-only-pattern.md +5 -5
- package/agent-assets/guides/microservice-pattern.md +48 -4
- package/agent-assets/guides/static-site-pattern.md +8 -0
- package/agent-assets/instructions/app-asset-validation.md +5 -5
- package/agent-assets/instructions/onboarding-flow.md +4 -4
- package/agent-assets/instructions/standard-folder-structure.md +20 -11
- package/agent-assets/reference/app-creation.md +42 -20
- package/agent-assets/reference/architecture.md +24 -22
- package/agent-assets/reference/local-dev.md +230 -67
- package/agent-assets/reference/mcp-integration.md +9 -9
- package/agent-assets/reference/platform-api-consumer.md +244 -0
- package/agent-assets/reference/workspace-config.md +7 -7
- package/agent-assets/rules/descix_mcp.template.mdc +8 -8
- package/bin/descix.js +2559 -1435
- package/bin/kb-deny-lint.mjs +91 -0
- package/bin/mcp-server.js +175 -91
- package/lib/agent-files.js +37 -25
- package/lib/api-client.js +223 -56
- package/lib/auth-guard.js +13 -3
- package/lib/chat-session.js +187 -0
- package/lib/cli-version.js +59 -0
- package/lib/commands/airdrop.js +251 -0
- package/lib/commands/auth.js +254 -72
- package/lib/commands/briefer/errors.js +88 -0
- package/lib/commands/briefer/index.js +319 -0
- package/lib/commands/briefer/sources/canonical-sources.js +129 -0
- package/lib/commands/briefer/sources/entitlements.js +209 -0
- package/lib/commands/briefer/sources/environments.js +205 -0
- package/lib/commands/briefer/sources/identifiers.js +214 -0
- package/lib/commands/briefer/sources/microservice-deploy.js +267 -0
- package/lib/commands/briefer/sources/routing.js +275 -0
- package/lib/commands/briefer/sources/what-is-not.js +223 -0
- package/lib/commands/briefer/stitcher.js +134 -0
- package/lib/commands/briefer/util/source-reader.js +502 -0
- package/lib/commands/config.js +210 -90
- package/lib/commands/corpus.js +978 -0
- package/lib/commands/credits.js +122 -0
- package/lib/commands/health.js +627 -0
- package/lib/commands/kb-list-render.js +32 -0
- package/lib/commands/kb.js +190 -413
- package/lib/commands/mcp.js +5 -5
- package/lib/commands/microservice-restart.js +359 -0
- package/lib/commands/model-config.js +313 -0
- package/lib/commands/retired-kb-sync.js +134 -0
- package/lib/commands/serve.js +26 -1
- package/lib/commands/status.js +23 -51
- package/lib/commands/update.js +36 -107
- package/lib/core/Chunker.js +491 -17
- package/lib/core/CorpusDenyLint.js +326 -0
- package/lib/core/CorpusWalker.js +357 -0
- package/lib/core/Hydrator.js +75 -29
- package/lib/core/ManifestLoader.js +533 -0
- package/lib/core/SiteWalker.js +218 -0
- package/lib/core/Syncer.js +163 -11
- package/lib/diagnostic-log.js +41 -0
- package/lib/environment-report.js +100 -0
- package/lib/global-config.js +4 -1
- package/lib/oauth-client.js +164 -0
- package/lib/origin.js +143 -0
- package/lib/service-api-client.js +95 -0
- package/lib/wallet-environment.js +49 -0
- package/lib/wallet-file.js +16 -2
- package/lib/wizard/setup.js +10 -9
- package/lib/workspace-config.js +380 -235
- package/lib/workspace-identity.js +92 -0
- package/lib/workspace-utils.js +41 -61
- package/package.json +19 -6
- package/templates/agent-claude.md +157 -9
- package/templates/agent-cline.md +68 -8
- package/templates/agent-copilot.md +68 -8
- package/templates/agent-cursor.md +68 -8
- package/templates/alignment-schema.json +20 -0
- package/templates/cloud-brain-hook.sh +20 -0
- package/templates/drive/README.md +1 -1
- package/templates/drive_templates/README.md +2 -2
- package/templates/scaffolds/README.md +6 -10
- package/templates/scaffolds/microservice/SERVICE_README_sdk.md +30 -0
- package/templates/scaffolds/microservice/app.js +51 -10
- package/templates/scaffolds/microservice/app.yaml +2 -2
- package/templates/scaffolds/microservice/defaults-config-dev.json +4 -0
- package/templates/scaffolds/microservice/dev-overrides.example.json +5 -0
- package/templates/scaffolds/microservice/manifest.json +0 -1
- package/templates/scaffolds/microservice/package.json +3 -3
- package/templates/scaffolds/microservice/scripts/register.js +56 -41
- package/templates/scaffolds/microservice/services/mcpClient.js +55 -65
- package/templates/scaffolds/microservice/services/utils.js +20 -238
- package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +24 -0
- package/lib/commands/app-wizard.js +0 -627
- package/lib/commands/folder.js +0 -291
- package/lib/core/PathContext.js +0 -335
- package/templates/scaffolds/microservice/dev-overrides.json.example +0 -5
- package/templates/scaffolds/site/DeSciXAppSDK.js +0 -62
- package/templates/scaffolds/site/README.md +0 -51
- package/templates/scaffolds/site/app.js +0 -22
- package/templates/scaffolds/site/index.html +0 -42
- package/templates/scaffolds/site/styles.css +0 -123
package/lib/core/Hydrator.js
CHANGED
|
@@ -16,6 +16,38 @@
|
|
|
16
16
|
import * as fs from 'fs/promises';
|
|
17
17
|
import * as path from 'path';
|
|
18
18
|
import { fileURLToPath } from 'url';
|
|
19
|
+
|
|
20
|
+
import { SITE_SCAFFOLD_DIR } from '@descix/app-sdk/scaffold';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* ONE OWNER for scaffold path resolution. Hydrator.js lives at descix-cli/lib/core/, so two
|
|
24
|
+
* '..' walks reach the CLI package root. Two functions used to resolve this independently and
|
|
25
|
+
* they DISAGREED — copyScaffold walked two, getAvailableScaffolds walked four (and used a
|
|
26
|
+
* hand-rolled import.meta.url.replace('file://','') instead of fileURLToPath). The four-walk
|
|
27
|
+
* path does not exist, so getAvailableScaffolds returned [] on every call, and its bare catch
|
|
28
|
+
* made a path bug indistinguishable from "no scaffolds are installed".
|
|
29
|
+
*
|
|
30
|
+
* The SITE scaffold is not one of ours: it is app-side browser code whose generated
|
|
31
|
+
* `DeSciXAppSDK.js` is produced from @descix/app-sdk's template + bridgeResolver, so that
|
|
32
|
+
* package OWNS the directory and exports its location. The CLI consumes the constant rather
|
|
33
|
+
* than re-spelling the path, which is what lets `@descix/sdk/app`'s bin scaffold the very same
|
|
34
|
+
* bytes without a second copy existing anywhere.
|
|
35
|
+
*/
|
|
36
|
+
const CLI_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The scaffold registry — ONE owner of BOTH "which scaffold types exist" and "where each one
|
|
40
|
+
* lives". These used to be two facts derived in two places: a hand-kept `validTypes` array
|
|
41
|
+
* inside copyScaffold, and a readdir of the templates directory inside getAvailableScaffolds.
|
|
42
|
+
* Two derivations of one fact disagree silently, and this pair was primed to: moving `site`
|
|
43
|
+
* out of the CLI would have left `validTypes` still advertising it while the readdir stopped
|
|
44
|
+
* reporting it, and the error a developer saw would have named a missing directory rather
|
|
45
|
+
* than the move.
|
|
46
|
+
*/
|
|
47
|
+
const SCAFFOLD_DIRS = {
|
|
48
|
+
site: SITE_SCAFFOLD_DIR,
|
|
49
|
+
microservice: path.join(CLI_ROOT, 'templates', 'scaffolds', 'microservice'),
|
|
50
|
+
};
|
|
19
51
|
import { google } from 'googleapis';
|
|
20
52
|
import * as driveADC from '../google-storage-adc.js';
|
|
21
53
|
|
|
@@ -254,27 +286,35 @@ async function pullFolder(localPath, driveFolderId, options = {}) {
|
|
|
254
286
|
* @returns {Promise<{pulled: number, converted: number, skipped: number, unchanged: number}>}
|
|
255
287
|
*/
|
|
256
288
|
export async function hydrateKb(config, options = {}) {
|
|
257
|
-
const { workspaceRoot, communityId, appId, kbId = 'General', baseFolderId, localPath } = config;
|
|
258
|
-
const {
|
|
259
|
-
verbose = false,
|
|
289
|
+
const { workspaceRoot, communityId, appId, kbId = 'General', baseFolderId, directFolderId, localPath } = config;
|
|
290
|
+
const {
|
|
291
|
+
verbose = false,
|
|
260
292
|
mergeMode = 'merge',
|
|
261
293
|
skipUnchanged = true,
|
|
262
294
|
dryRun = false,
|
|
263
295
|
onProgress = null
|
|
264
296
|
} = options;
|
|
265
|
-
|
|
297
|
+
|
|
266
298
|
// Verify ADC auth
|
|
267
299
|
await driveADC.verifyDriveAuth();
|
|
268
|
-
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
300
|
+
|
|
301
|
+
// Resolve the KB Drive folder: direct override or navigate via base_folder_id
|
|
302
|
+
let kbDriveFolderId;
|
|
303
|
+
if (directFolderId) {
|
|
304
|
+
// --folder override: use the provided folder ID directly (arbitrary Drive folder)
|
|
305
|
+
kbDriveFolderId = directFolderId;
|
|
306
|
+
if (verbose) console.log(` Using override folder: ${directFolderId}`);
|
|
307
|
+
} else {
|
|
308
|
+
// Standard path: navigate to KB folder using template path
|
|
309
|
+
const kbPath = `${communityId}/${appId}/kb/${kbId}`;
|
|
310
|
+
kbDriveFolderId = await driveADC.findFolderByPath(baseFolderId, kbPath);
|
|
311
|
+
|
|
312
|
+
if (!kbDriveFolderId) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`KB folder not found at path: ${kbPath}\n` +
|
|
315
|
+
'Ensure the app was created from template and the KB folder exists in Drive.'
|
|
316
|
+
);
|
|
317
|
+
}
|
|
278
318
|
}
|
|
279
319
|
|
|
280
320
|
// Prepare local directories with new structure
|
|
@@ -692,15 +732,13 @@ export async function checkStagingFiles(stagingDir) {
|
|
|
692
732
|
export async function copyScaffold(scaffoldType, appPath, options = {}) {
|
|
693
733
|
const { verbose = false, force = false } = options;
|
|
694
734
|
|
|
695
|
-
const
|
|
696
|
-
if (!
|
|
697
|
-
throw new Error(
|
|
735
|
+
const scaffoldDir = SCAFFOLD_DIRS[scaffoldType];
|
|
736
|
+
if (!scaffoldDir) {
|
|
737
|
+
throw new Error(
|
|
738
|
+
`Invalid scaffold type: ${scaffoldType}. Valid types: ${Object.keys(SCAFFOLD_DIRS).join(', ')}`
|
|
739
|
+
);
|
|
698
740
|
}
|
|
699
741
|
|
|
700
|
-
// Find scaffold template directory (templates live inside CLI package)
|
|
701
|
-
const cliRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
|
702
|
-
const scaffoldDir = path.join(cliRoot, 'templates', 'scaffolds', scaffoldType);
|
|
703
|
-
|
|
704
742
|
// Check if scaffold exists
|
|
705
743
|
try {
|
|
706
744
|
await fs.access(scaffoldDir);
|
|
@@ -773,15 +811,23 @@ async function copyDir(src, dest, stats, verbose) {
|
|
|
773
811
|
* @returns {Promise<string[]>}
|
|
774
812
|
*/
|
|
775
813
|
export async function getAvailableScaffolds() {
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
814
|
+
// FAIL LOUD per entry: an unreadable scaffold directory in a shipped CLI is a packaging
|
|
815
|
+
// failure, not "no scaffolds". Swallowing it to [] is what hid the original path bug — the
|
|
816
|
+
// caller could not tell a broken install from an empty one. Name the type, the owning
|
|
817
|
+
// package and the underlying error, because `site` now ships from a DIFFERENT package than
|
|
818
|
+
// `microservice` and "which package is broken" is the first thing you need to know.
|
|
819
|
+
for (const [type, dir] of Object.entries(SCAFFOLD_DIRS)) {
|
|
820
|
+
try {
|
|
821
|
+
await fs.access(dir);
|
|
822
|
+
} catch (err) {
|
|
823
|
+
throw new Error(
|
|
824
|
+
`Scaffold templates for "${type}" are unreadable at ${dir} (${err.code || err.message}). ` +
|
|
825
|
+
`This is a broken installation of ` +
|
|
826
|
+
`${type === 'site' ? '@descix/app-sdk' : '@descix/cli'}, not an empty template set.`
|
|
827
|
+
);
|
|
828
|
+
}
|
|
784
829
|
}
|
|
830
|
+
return Object.keys(SCAFFOLD_DIRS);
|
|
785
831
|
}
|
|
786
832
|
|
|
787
833
|
// Named exports for functions not already exported inline
|
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ManifestLoader - Load and validate corpus sync manifests
|
|
3
|
+
*
|
|
4
|
+
* Manifests declare a *view* over git-managed files for RAG indexing.
|
|
5
|
+
* Files stay in place — no staging. Git blob SHAs handle versioning.
|
|
6
|
+
* Pinecone metadata handles multi-tenancy (CEO-D12).
|
|
7
|
+
*
|
|
8
|
+
* Manifest location convention:
|
|
9
|
+
* {app_root}/.descix/manifests/{kb_name}.json
|
|
10
|
+
*
|
|
11
|
+
* workspace.json `localPath` provides the app root.
|
|
12
|
+
* The CLI discovers manifests at `{localPath}/.descix/manifests/`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as fs from 'fs/promises';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
import { execFileSync } from 'child_process';
|
|
18
|
+
// Canonical doc_class taxonomy lives in CorpusDenyLint — import it, never re-list.
|
|
19
|
+
import { PUBLISHABLE_DOC_CLASSES, DENY_DOC_CLASSES } from './CorpusDenyLint.js';
|
|
20
|
+
|
|
21
|
+
// Valid publish tiers (model §1). "I" internal (default), "P" public, "U" user.
|
|
22
|
+
const PUBLISH_TIERS = new Set(['P', 'I', 'U']);
|
|
23
|
+
|
|
24
|
+
// ============ Canonical Manifest Schema (WS-EVP-DESCIX-KB-CLEANUP item 5) ============
|
|
25
|
+
//
|
|
26
|
+
// ONE canonical corpus-manifest schema. Historically two families shared
|
|
27
|
+
// `.descix/manifests/*.json` with drifting per-source field names — the DeSciX-family
|
|
28
|
+
// (`tier`/`doc_type`/`syncignore`/`raw_path`) and the EGPT-research family (which also used
|
|
29
|
+
// `src_path`). Because every field except `path` was optional, the loader SILENTLY accepted both
|
|
30
|
+
// AND silently ignored any unrecognized/misspelled field (the same fail-loud gap as the KB command
|
|
31
|
+
// params — a typo'd `synchignore` produced an empty syncignore with no signal). This is the single
|
|
32
|
+
// source of truth for the recognized field set. Consumers (the walker, the deny-lint, a conformance
|
|
33
|
+
// test) key off these exports so the schema can never drift by hand again.
|
|
34
|
+
//
|
|
35
|
+
// Deprecation window: unrecognized fields and deprecated aliases WARN (they do not throw) so both
|
|
36
|
+
// legacy formats keep syncing. `src_path` is normalized to its canonical name `raw_path` at load.
|
|
37
|
+
// The schema doc lives at V2_docs/architecture/corpus-manifest-schema.md.
|
|
38
|
+
export const CANONICAL_SOURCE_FIELDS = new Set([
|
|
39
|
+
'path', 'repo', 'ref', 'tier', 'doc_type', 'syncignore',
|
|
40
|
+
'doc_class', 'license_basis', 'lint_exempt', 'exempt_reason', 'raw_path',
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The agreed shape of a manifest source (interface-ws-c4-I2-manifest-source), stated ONCE.
|
|
45
|
+
* Refusal messages quote from it, so the contract and its enforcement cannot drift apart.
|
|
46
|
+
*/
|
|
47
|
+
export const SOURCE_SHAPE_CONTRACT =
|
|
48
|
+
"A manifest source names WHERE its content comes from, and is synced by fetching that ref - " +
|
|
49
|
+
"never by reading an adjacent working tree. A source is exactly one of two shapes. IN-REPO: " +
|
|
50
|
+
"omit `repo`; `path` is repo-root-relative within the manifest's OWN repository, fetched at " +
|
|
51
|
+
"`ref`. CROSS-REPO: `repo` is REQUIRED and is an owner/name slug (e.g. 'eabadir/unk'), never a " +
|
|
52
|
+
"URL; `path` is repo-root-relative within that repository, fetched at `ref`. Naming your OWN " +
|
|
53
|
+
"repository explicitly in `repo` is REFUSED - omission is the single spelling of 'here', an " +
|
|
54
|
+
"explicit slug the single spelling of 'elsewhere'. A `path` beginning with '../' is REFUSED " +
|
|
55
|
+
"naming the migration: adjacency reads a working tree, so the source's `ref` is not honored and " +
|
|
56
|
+
"the provenance it claims is false. `ref` may be a branch, tag or sha; a mutable branch ref is " +
|
|
57
|
+
"legal, and sync-state records the RESOLVED COMMIT SHA actually synced, so provenance is exact " +
|
|
58
|
+
"even when the ref moves. `path` is a literal - no globs; `syncignore` already owns exclusion.";
|
|
59
|
+
|
|
60
|
+
/** The ref a source is walked at when it names none. ONE owner — consumers import it. */
|
|
61
|
+
export const DEFAULT_SOURCE_REF = 'main';
|
|
62
|
+
|
|
63
|
+
// Glob metacharacters. `path` is a LITERAL — `syncignore` already owns exclusion, so a glob in
|
|
64
|
+
// `path` is a second, silently-diverging spelling of the same intent.
|
|
65
|
+
const GLOB_CHARS = /[*?[\]{}]/;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Normalize any git remote URL spelling to ONE canonical `owner/name` slug.
|
|
69
|
+
*
|
|
70
|
+
* The single owner of "which repository is this". Every caller derives from here — deriving the
|
|
71
|
+
* slug a second way is how the self-naming refusal would silently start disagreeing with itself.
|
|
72
|
+
*
|
|
73
|
+
* Handles: https://host/owner/name(.git), git@host:owner/name(.git), ssh://git@host/owner/name(.git)
|
|
74
|
+
*
|
|
75
|
+
* @param {string|null|undefined} url - a git remote URL
|
|
76
|
+
* @returns {string|null} `owner/name`, or null when no slug can be read
|
|
77
|
+
*/
|
|
78
|
+
export function repoSlugFromRemoteUrl(url) {
|
|
79
|
+
if (!url || typeof url !== 'string') return null;
|
|
80
|
+
let s = url.trim();
|
|
81
|
+
if (!s) return null;
|
|
82
|
+
s = s.replace(/\.git$/, '');
|
|
83
|
+
// scp-style: git@host:owner/name
|
|
84
|
+
const scp = s.match(/^[^/]+@[^:/]+:(.+)$/);
|
|
85
|
+
if (scp) s = scp[1];
|
|
86
|
+
else {
|
|
87
|
+
// URL-style: scheme://[user@]host/owner/name
|
|
88
|
+
const u = s.match(/^[a-z][a-z0-9+.-]*:\/\/[^/]*\/(.+)$/i);
|
|
89
|
+
if (u) s = u[1];
|
|
90
|
+
}
|
|
91
|
+
const parts = s.split('/').filter(Boolean);
|
|
92
|
+
if (parts.length < 2) return null;
|
|
93
|
+
return `${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Read the `owner/name` slug of the repository that physically CONTAINS a directory.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} dir - a directory inside the repository
|
|
100
|
+
* @returns {string|null} the slug, or null when there is no repo / no `origin` remote
|
|
101
|
+
*/
|
|
102
|
+
export function detectRepoSlug(dir) {
|
|
103
|
+
try {
|
|
104
|
+
const url = execFileSync('git', ['remote', 'get-url', 'origin'], {
|
|
105
|
+
cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
106
|
+
}).trim();
|
|
107
|
+
return repoSlugFromRemoteUrl(url);
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Enforce the two-shape source contract on ONE source entry.
|
|
115
|
+
*
|
|
116
|
+
* @param {Object} src - raw source entry
|
|
117
|
+
* @param {number} i - index (for message context)
|
|
118
|
+
* @param {string} filePath - manifest path (for message context)
|
|
119
|
+
* @param {string|null} ownRepoSlug - slug of the repository containing the manifest
|
|
120
|
+
* @throws {Error} naming the offending value and the canonical spelling
|
|
121
|
+
*/
|
|
122
|
+
function assertSourceShape(src, i, filePath, ownRepoSlug) {
|
|
123
|
+
const at = `Invalid manifest at ${filePath}: sources[${i}] (${src.path})`;
|
|
124
|
+
|
|
125
|
+
// ── `path` must be a literal, repo-root-relative path — never adjacency, never a glob.
|
|
126
|
+
const segments = String(src.path).split('/');
|
|
127
|
+
if (String(src.path).startsWith('../') || segments.includes('..')) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`${at}: a path beginning with '../' is REFUSED. Adjacency reads a working tree, so the ` +
|
|
130
|
+
`source's ref "${src.ref ?? DEFAULT_SOURCE_REF}" is NOT honored and the provenance it claims is false. ` +
|
|
131
|
+
`MIGRATE to the cross-repo shape: set \`repo\` to the owner/name slug of the repository that ` +
|
|
132
|
+
`owns this content and make \`path\` repo-root-relative within it ` +
|
|
133
|
+
`(e.g. { "repo": "eabadir/unk", "path": "unkamon-beast/README.md", "ref": "main" }).`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (GLOB_CHARS.test(String(src.path))) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`${at}: \`path\` is a literal — globs are REFUSED. \`syncignore\` already owns exclusion; ` +
|
|
139
|
+
`name the directory literally and express the filter in \`syncignore\`.`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (src.repo === undefined) return; // IN-REPO shape: omission is the single spelling of "here".
|
|
144
|
+
|
|
145
|
+
// ── CROSS-REPO shape.
|
|
146
|
+
if (typeof src.repo !== 'string' || !src.repo.trim()) {
|
|
147
|
+
throw new Error(`${at}: \`repo\` must be an owner/name slug string.`);
|
|
148
|
+
}
|
|
149
|
+
const repo = src.repo.trim();
|
|
150
|
+
const looksLikeUrl = /:\/\//.test(repo) || /^[^/]+@/.test(repo) || /\.git$/.test(repo);
|
|
151
|
+
if (looksLikeUrl || repo.split('/').filter(Boolean).length !== 2 || repo.startsWith('/') || repo.endsWith('/')) {
|
|
152
|
+
const suggestion = repoSlugFromRemoteUrl(repo);
|
|
153
|
+
throw new Error(
|
|
154
|
+
`${at}: \`repo\` must be an owner/name slug (e.g. 'eabadir/unk'), never a URL — got "${repo}"` +
|
|
155
|
+
`${suggestion ? `. Did you mean "${suggestion}"?` : '.'}`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (ownRepoSlug && repo.toLowerCase() === ownRepoSlug.toLowerCase()) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`${at}: \`repo\` names this manifest's OWN repository ("${repo}") — REFUSED. Omission is the ` +
|
|
161
|
+
`single spelling of 'here' and an explicit slug the single spelling of 'elsewhere'; two ` +
|
|
162
|
+
`spellings of 'here' is exactly the drift this field exists to prevent. Remove \`repo\` and ` +
|
|
163
|
+
`keep \`path\` relative to this repository.`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (!ownRepoSlug) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`${at}: \`repo\` is set ("${repo}") but this manifest's OWN repository slug could not be ` +
|
|
169
|
+
`determined (no git repository or no 'origin' remote at ${path.dirname(filePath)}), so the ` +
|
|
170
|
+
`"names its own repository" check CANNOT run. Refusing rather than accepting an unverifiable ` +
|
|
171
|
+
`cross-repo source.`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Deprecated per-source field -> canonical replacement. Normalized at load (canonical wins if both
|
|
177
|
+
// are present); a deprecation warning is emitted during the migration window.
|
|
178
|
+
export const DEPRECATED_SOURCE_ALIASES = Object.freeze({ src_path: 'raw_path' });
|
|
179
|
+
|
|
180
|
+
// Recognized manifest-level (top-level) keys. `_`-prefixed keys are annotation/comment keys and are
|
|
181
|
+
// always allowed (e.g. `_purpose`). Unknown top-level keys warn like unknown source fields.
|
|
182
|
+
export const CANONICAL_MANIFEST_FIELDS = new Set([
|
|
183
|
+
'kb_name', 'sync_mode', 'publish_tier', 'sources', 'github',
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Collect schema warnings for a manifest WITHOUT throwing — the validator half of the canonical
|
|
188
|
+
* schema. Flags unknown per-source/top-level fields (typo guard) and deprecated aliases. Exported
|
|
189
|
+
* so a conformance test drives off the SAME field set the loader enforces (no hand-mirrored list).
|
|
190
|
+
*
|
|
191
|
+
* @param {Object} manifest - parsed manifest object
|
|
192
|
+
* @param {string} filePath - manifest path (for message context)
|
|
193
|
+
* @returns {string[]} human-readable warning lines (empty when the manifest is fully canonical)
|
|
194
|
+
*/
|
|
195
|
+
export function collectManifestSchemaWarnings(manifest, filePath) {
|
|
196
|
+
const warnings = [];
|
|
197
|
+
const base = path.basename(filePath || 'manifest.json');
|
|
198
|
+
const recognized = [...CANONICAL_SOURCE_FIELDS].join(', ');
|
|
199
|
+
|
|
200
|
+
for (const key of Object.keys(manifest || {})) {
|
|
201
|
+
if (key.startsWith('_')) continue; // annotation/comment key
|
|
202
|
+
if (CANONICAL_MANIFEST_FIELDS.has(key)) continue;
|
|
203
|
+
warnings.push(` ⚠ ${base}: unknown top-level field "${key}" is ignored — check for a typo.`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const sources = Array.isArray(manifest?.sources) ? manifest.sources : [];
|
|
207
|
+
sources.forEach((src, i) => {
|
|
208
|
+
for (const key of Object.keys(src || {})) {
|
|
209
|
+
if (key.startsWith('_')) continue;
|
|
210
|
+
if (CANONICAL_SOURCE_FIELDS.has(key)) continue;
|
|
211
|
+
if (key in DEPRECATED_SOURCE_ALIASES) {
|
|
212
|
+
warnings.push(
|
|
213
|
+
` ⚠ ${base} sources[${i}] (${src.path}): field "${key}" is DEPRECATED — rename to ` +
|
|
214
|
+
`"${DEPRECATED_SOURCE_ALIASES[key]}" (normalized for now; support is removed after the ` +
|
|
215
|
+
`deprecation window).`
|
|
216
|
+
);
|
|
217
|
+
} else {
|
|
218
|
+
warnings.push(
|
|
219
|
+
` ⚠ ${base} sources[${i}] (${src.path}): unknown field "${key}" is ignored — check for a ` +
|
|
220
|
+
`typo (recognized: ${recognized}).`
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
return warnings;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Validate a parsed manifest object against the schema.
|
|
231
|
+
* Required: kb_name (string), sources (non-empty array).
|
|
232
|
+
* Each source: path (string) required; ref, tier, doc_type, syncignore optional.
|
|
233
|
+
* KB-curation (CEO-D-2026-07-11): manifest-level `publish_tier` ("P"|"I"|"U",
|
|
234
|
+
* default "I") + per-source `doc_class`, `license_basis`, `lint_exempt`,
|
|
235
|
+
* `exempt_reason`, `raw_path` (all optional). A `publish_tier:"P"` manifest hard-
|
|
236
|
+
* fails if any source lacks a publishable `doc_class` (no default is allowed).
|
|
237
|
+
* github block: optional, validated but not used for sync_mode: "local".
|
|
238
|
+
*
|
|
239
|
+
* @param {Object} manifest - Parsed JSON manifest
|
|
240
|
+
* @param {string} filePath - Path to manifest file (for error messages)
|
|
241
|
+
* @throws {Error} If validation fails
|
|
242
|
+
*/
|
|
243
|
+
function validateManifest(manifest, filePath, ownRepoSlug = null) {
|
|
244
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
245
|
+
throw new Error(`Invalid manifest at ${filePath}: not a JSON object`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!manifest.kb_name || typeof manifest.kb_name !== 'string') {
|
|
249
|
+
throw new Error(`Invalid manifest at ${filePath}: kb_name (string) is required`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (manifest.publish_tier !== undefined) {
|
|
253
|
+
if (typeof manifest.publish_tier !== 'string' || !PUBLISH_TIERS.has(manifest.publish_tier)) {
|
|
254
|
+
throw new Error(`Invalid manifest at ${filePath}: publish_tier must be one of "P", "I", "U"`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const isTierP = manifest.publish_tier === 'P';
|
|
258
|
+
|
|
259
|
+
if (!Array.isArray(manifest.sources) || manifest.sources.length === 0) {
|
|
260
|
+
throw new Error(`Invalid manifest at ${filePath}: sources (non-empty array) is required`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
for (let i = 0; i < manifest.sources.length; i++) {
|
|
264
|
+
const src = manifest.sources[i];
|
|
265
|
+
if (!src.path || typeof src.path !== 'string') {
|
|
266
|
+
throw new Error(`Invalid manifest at ${filePath}: sources[${i}].path (string) is required`);
|
|
267
|
+
}
|
|
268
|
+
// Optional fields: ref (string), tier (number), doc_type (string), syncignore (string[])
|
|
269
|
+
if (src.ref !== undefined && typeof src.ref !== 'string') {
|
|
270
|
+
throw new Error(`Invalid manifest at ${filePath}: sources[${i}].ref must be a string`);
|
|
271
|
+
}
|
|
272
|
+
if (src.tier !== undefined && typeof src.tier !== 'number') {
|
|
273
|
+
throw new Error(`Invalid manifest at ${filePath}: sources[${i}].tier must be a number`);
|
|
274
|
+
}
|
|
275
|
+
if (src.doc_type !== undefined && typeof src.doc_type !== 'string') {
|
|
276
|
+
throw new Error(`Invalid manifest at ${filePath}: sources[${i}].doc_type must be a string`);
|
|
277
|
+
}
|
|
278
|
+
if (src.syncignore !== undefined && !Array.isArray(src.syncignore)) {
|
|
279
|
+
throw new Error(`Invalid manifest at ${filePath}: sources[${i}].syncignore must be an array`);
|
|
280
|
+
}
|
|
281
|
+
// KB-curation optional per-source fields.
|
|
282
|
+
if (src.doc_class !== undefined && typeof src.doc_class !== 'string') {
|
|
283
|
+
throw new Error(`Invalid manifest at ${filePath}: sources[${i}].doc_class must be a string`);
|
|
284
|
+
}
|
|
285
|
+
if (src.license_basis !== undefined && typeof src.license_basis !== 'string') {
|
|
286
|
+
throw new Error(`Invalid manifest at ${filePath}: sources[${i}].license_basis must be a string`);
|
|
287
|
+
}
|
|
288
|
+
if (src.lint_exempt !== undefined && !Array.isArray(src.lint_exempt)) {
|
|
289
|
+
throw new Error(`Invalid manifest at ${filePath}: sources[${i}].lint_exempt must be an array`);
|
|
290
|
+
}
|
|
291
|
+
if (src.exempt_reason !== undefined && typeof src.exempt_reason !== 'string') {
|
|
292
|
+
throw new Error(`Invalid manifest at ${filePath}: sources[${i}].exempt_reason must be a string`);
|
|
293
|
+
}
|
|
294
|
+
if (src.raw_path !== undefined && typeof src.raw_path !== 'string') {
|
|
295
|
+
throw new Error(`Invalid manifest at ${filePath}: sources[${i}].raw_path must be a string`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Two-shape source contract (interface-ws-c4-I2-manifest-source): adjacency/glob paths and
|
|
299
|
+
// self-naming or URL-shaped `repo` values are REFUSED here, loudly, naming the migration.
|
|
300
|
+
assertSourceShape(src, i, filePath, ownRepoSlug);
|
|
301
|
+
|
|
302
|
+
// Tier-P: every source MUST carry a publishable doc_class (no default allowed).
|
|
303
|
+
if (isTierP) {
|
|
304
|
+
if (!src.doc_class) {
|
|
305
|
+
throw new Error(
|
|
306
|
+
`Invalid manifest at ${filePath}: sources[${i}] (${src.path}) requires a doc_class ` +
|
|
307
|
+
`in a Tier-P (publish_tier:"P") manifest — no default is allowed.`
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
if (DENY_DOC_CLASSES.has(src.doc_class)) {
|
|
311
|
+
throw new Error(
|
|
312
|
+
`Invalid manifest at ${filePath}: sources[${i}] (${src.path}) doc_class "${src.doc_class}" ` +
|
|
313
|
+
`is a deny-class {${[...DENY_DOC_CLASSES].join(', ')}} — structurally invalid in a Tier-P manifest.`
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
if (!PUBLISHABLE_DOC_CLASSES.has(src.doc_class)) {
|
|
317
|
+
throw new Error(
|
|
318
|
+
`Invalid manifest at ${filePath}: sources[${i}] (${src.path}) doc_class "${src.doc_class}" ` +
|
|
319
|
+
`is not a publishable class {${[...PUBLISHABLE_DOC_CLASSES].join(', ')}}.`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// github block: validate structure if present, but skip for local sync
|
|
326
|
+
if (manifest.github !== undefined) {
|
|
327
|
+
if (typeof manifest.github !== 'object') {
|
|
328
|
+
throw new Error(`Invalid manifest at ${filePath}: github must be an object`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Load a single manifest file.
|
|
335
|
+
*
|
|
336
|
+
* @param {string} manifestPath - Absolute path to manifest JSON file
|
|
337
|
+
* @param {string} workspaceRoot - Workspace root for resolving relative paths
|
|
338
|
+
* @returns {Promise<Object>} Validated manifest with resolved paths
|
|
339
|
+
*/
|
|
340
|
+
export async function loadManifest(manifestPath, workspaceRoot, options = {}) {
|
|
341
|
+
const raw = await fs.readFile(manifestPath, 'utf-8');
|
|
342
|
+
const manifest = JSON.parse(raw);
|
|
343
|
+
|
|
344
|
+
// The repository that physically contains this manifest — the thing `repo` may never name.
|
|
345
|
+
// Injectable so tests drive the check without depending on the ambient checkout.
|
|
346
|
+
const ownRepoSlug = options.ownRepoSlug !== undefined
|
|
347
|
+
? options.ownRepoSlug
|
|
348
|
+
: detectRepoSlug(path.dirname(manifestPath));
|
|
349
|
+
|
|
350
|
+
validateManifest(manifest, manifestPath, ownRepoSlug);
|
|
351
|
+
|
|
352
|
+
// Canonical-schema warnings (WS-EVP-DESCIX-KB-CLEANUP item 5): surface deprecated aliases and
|
|
353
|
+
// unknown/misspelled fields during the deprecation window — warn, never throw, so both legacy
|
|
354
|
+
// formats keep syncing.
|
|
355
|
+
for (const w of collectManifestSchemaWarnings(manifest, manifestPath)) {
|
|
356
|
+
console.warn(w);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Resolve source paths relative to workspace root
|
|
360
|
+
manifest._resolvedSources = manifest.sources.map(src => {
|
|
361
|
+
// Normalize deprecated field aliases (src_path -> raw_path). Canonical field wins if both set.
|
|
362
|
+
const s = { ...src };
|
|
363
|
+
for (const [alias, canonical] of Object.entries(DEPRECATED_SOURCE_ALIASES)) {
|
|
364
|
+
if (s[alias] !== undefined && s[canonical] === undefined) {
|
|
365
|
+
s[canonical] = s[alias];
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
...s,
|
|
370
|
+
// `repo` stays UNDEFINED when omitted — omission is the single spelling of "here" and must
|
|
371
|
+
// survive the load, so an in-repo source is never silently rewritten into a cross-repo one.
|
|
372
|
+
repo: s.repo === undefined ? undefined : String(s.repo).trim(),
|
|
373
|
+
absolutePath: path.resolve(workspaceRoot, s.path),
|
|
374
|
+
ref: s.ref || DEFAULT_SOURCE_REF,
|
|
375
|
+
tier: s.tier || 3,
|
|
376
|
+
doc_type: s.doc_type || 'generic',
|
|
377
|
+
syncignore: s.syncignore || [],
|
|
378
|
+
// KB-curation carry-through. doc_class is as-is (may be undefined for tier I).
|
|
379
|
+
doc_class: s.doc_class,
|
|
380
|
+
license_basis: s.license_basis ?? null,
|
|
381
|
+
lint_exempt: s.lint_exempt || [],
|
|
382
|
+
exempt_reason: s.exempt_reason ?? null,
|
|
383
|
+
raw_path: s.raw_path ?? null
|
|
384
|
+
};
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// Surface publish_tier on the manifest object (default "I") so corpus.js can
|
|
388
|
+
// read manifest.publish_tier to decide whether to run the Tier-P deny lint.
|
|
389
|
+
manifest.publish_tier = manifest.publish_tier || 'I';
|
|
390
|
+
|
|
391
|
+
manifest._manifestPath = manifestPath;
|
|
392
|
+
return manifest;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Discover and load all manifests for an app.
|
|
397
|
+
* Looks in {appRoot}/.descix/manifests/*.json
|
|
398
|
+
*
|
|
399
|
+
* @param {string} appRoot - Absolute path to app root directory
|
|
400
|
+
* @param {string} workspaceRoot - Workspace root for resolving relative paths
|
|
401
|
+
* @param {string} [filterKbName] - If provided, only load manifest with this kb_name
|
|
402
|
+
* @returns {Promise<Array<Object>>} Array of validated manifest objects
|
|
403
|
+
*/
|
|
404
|
+
export async function loadManifests(appRoot, workspaceRoot, filterKbName = null) {
|
|
405
|
+
const manifestsDir = path.join(appRoot, '.descix', 'manifests');
|
|
406
|
+
|
|
407
|
+
let files;
|
|
408
|
+
try {
|
|
409
|
+
files = await fs.readdir(manifestsDir);
|
|
410
|
+
} catch {
|
|
411
|
+
return []; // No manifests directory — not an error
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// M2 (2026-04-20): skip non-KB manifests.
|
|
415
|
+
// {app}/.descix/manifests/ can hold schemas other than KB manifests
|
|
416
|
+
// (notably site.json, which has its own schema — see loadSiteManifest).
|
|
417
|
+
// The legacy loader validated every .json as a KB manifest and blocked
|
|
418
|
+
// 'descix kb corpus sync' with `kb_name required` whenever a site.json
|
|
419
|
+
// was present. We now:
|
|
420
|
+
// (a) explicitly skip known non-KB filenames (site.json)
|
|
421
|
+
// (b) try-catch each load and treat validation failures as "not a KB
|
|
422
|
+
// manifest" when the cause is a missing kb_name, rather than
|
|
423
|
+
// aborting the whole operation.
|
|
424
|
+
// Any other error (I/O, malformed JSON) is still propagated with context.
|
|
425
|
+
const NON_KB_MANIFESTS = new Set(['site.json']);
|
|
426
|
+
const jsonFiles = files.filter(f => f.endsWith('.json') && !NON_KB_MANIFESTS.has(f));
|
|
427
|
+
const manifests = [];
|
|
428
|
+
|
|
429
|
+
for (const file of jsonFiles) {
|
|
430
|
+
const manifestPath = path.join(manifestsDir, file);
|
|
431
|
+
let manifest;
|
|
432
|
+
try {
|
|
433
|
+
manifest = await loadManifest(manifestPath, workspaceRoot);
|
|
434
|
+
} catch (err) {
|
|
435
|
+
// If the file is missing kb_name, it's not a KB manifest — skip it
|
|
436
|
+
// with a warning rather than aborting the whole operation. This
|
|
437
|
+
// lets future non-KB schemas coexist without editing this allowlist.
|
|
438
|
+
if (/kb_name \(string\) is required/.test(err.message)) {
|
|
439
|
+
console.warn(` ⚠ Skipping non-KB manifest: ${file} (${err.message})`);
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
throw err;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (filterKbName && manifest.kb_name !== filterKbName) {
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
manifests.push(manifest);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return manifests;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ============ Site Manifest Support ============
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Validate a site manifest against its schema.
|
|
459
|
+
* Required: sources (non-empty array).
|
|
460
|
+
* Each source: path (string) required; include, exclude optional string arrays.
|
|
461
|
+
* buildCommand: optional string.
|
|
462
|
+
*
|
|
463
|
+
* @param {Object} manifest - Parsed JSON manifest
|
|
464
|
+
* @param {string} filePath - Path to manifest file (for error messages)
|
|
465
|
+
* @throws {Error} If validation fails
|
|
466
|
+
*/
|
|
467
|
+
function validateSiteManifest(manifest, filePath) {
|
|
468
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
469
|
+
throw new Error(`Invalid site manifest at ${filePath}: not a JSON object`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (!Array.isArray(manifest.sources) || manifest.sources.length === 0) {
|
|
473
|
+
throw new Error(`Invalid site manifest at ${filePath}: sources (non-empty array) is required`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
for (let i = 0; i < manifest.sources.length; i++) {
|
|
477
|
+
const src = manifest.sources[i];
|
|
478
|
+
if (!src.path || typeof src.path !== 'string') {
|
|
479
|
+
throw new Error(`Invalid site manifest at ${filePath}: sources[${i}].path (string) is required`);
|
|
480
|
+
}
|
|
481
|
+
if (src.include !== undefined && !Array.isArray(src.include)) {
|
|
482
|
+
throw new Error(`Invalid site manifest at ${filePath}: sources[${i}].include must be a string array`);
|
|
483
|
+
}
|
|
484
|
+
if (src.exclude !== undefined && !Array.isArray(src.exclude)) {
|
|
485
|
+
throw new Error(`Invalid site manifest at ${filePath}: sources[${i}].exclude must be a string array`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (manifest.buildCommand !== undefined && manifest.buildCommand !== null && typeof manifest.buildCommand !== 'string') {
|
|
490
|
+
throw new Error(`Invalid site manifest at ${filePath}: buildCommand must be a string or null`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Load a site manifest from {appRoot}/.descix/manifests/site.json.
|
|
496
|
+
*
|
|
497
|
+
* Resolves source paths relative to the app's localPath in workspace.json.
|
|
498
|
+
* Each source entry gets include/exclude defaults applied.
|
|
499
|
+
*
|
|
500
|
+
* @param {string} appRoot - Absolute path to the app root directory
|
|
501
|
+
* @returns {Promise<Object|null>} Validated site manifest with _resolvedSources, or null if not found
|
|
502
|
+
*/
|
|
503
|
+
export async function loadSiteManifest(appRoot) {
|
|
504
|
+
const manifestPath = path.join(appRoot, '.descix', 'manifests', 'site.json');
|
|
505
|
+
|
|
506
|
+
let raw;
|
|
507
|
+
try {
|
|
508
|
+
raw = await fs.readFile(manifestPath, 'utf-8');
|
|
509
|
+
} catch (err) {
|
|
510
|
+
if (err.code === 'ENOENT') {
|
|
511
|
+
return null; // No site manifest — not an error
|
|
512
|
+
}
|
|
513
|
+
throw err;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const manifest = JSON.parse(raw);
|
|
517
|
+
validateSiteManifest(manifest, manifestPath);
|
|
518
|
+
|
|
519
|
+
// Resolve source paths relative to app root
|
|
520
|
+
manifest._resolvedSources = manifest.sources.map(src => ({
|
|
521
|
+
sourcePath: src.path,
|
|
522
|
+
absolutePath: path.resolve(appRoot, src.path),
|
|
523
|
+
include: src.include || [], // empty = include everything
|
|
524
|
+
exclude: src.exclude || [],
|
|
525
|
+
}));
|
|
526
|
+
|
|
527
|
+
manifest._manifestPath = manifestPath;
|
|
528
|
+
manifest.buildCommand = manifest.buildCommand || null;
|
|
529
|
+
|
|
530
|
+
return manifest;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export default { loadManifest, loadManifests, loadSiteManifest };
|