@shoalrun/cli 0.1.0-alpha.13 → 0.1.0-alpha.15

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.
Files changed (90) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/DEPLOYMENT-PROFILES.md +287 -0
  3. package/README.md +5 -2
  4. package/agent-docs/.well-known/shoalrun/navigation/project-updates-50f26fe22670.txt +5 -1
  5. package/agent-docs/SNAPSHOT.json +5 -5
  6. package/agent-docs/changelog/2026-10-30.md +43 -0
  7. package/agent-docs/changelog/2026-11-03.md +56 -0
  8. package/agent-docs/changelog.md +17 -1
  9. package/agent-docs/documents.json +419 -61
  10. package/agent-docs/index.md +73 -103
  11. package/agent-docs/provenance.json +4 -4
  12. package/agent-docs/publish/content-export.md +1 -1
  13. package/agent-docs/publish/content-export.sections/prerequisites.md +11 -0
  14. package/agent-docs/publish/deployment-profiles.md +273 -0
  15. package/agent-docs/publish/deployment-profiles.sections/before-you-begin.md +44 -0
  16. package/agent-docs/publish/deployment-profiles.sections/build-and-inspect-the-edition.md +69 -0
  17. package/agent-docs/publish/deployment-profiles.sections/check-the-result.md +46 -0
  18. package/agent-docs/publish/deployment-profiles.sections/choose-navigation-labels.md +53 -0
  19. package/agent-docs/publish/deployment-profiles.sections/current-limits.md +46 -0
  20. package/agent-docs/publish/deployment-profiles.sections/define-the-shared-sku-manifest.md +70 -0
  21. package/agent-docs/publish/deployment-profiles.sections/html-markdown-and-agents.md +44 -0
  22. package/agent-docs/publish/deployment-profiles.sections/mark-pages-and-shared-sections.md +135 -0
  23. package/agent-docs/publish/deployment-profiles.sections/related-reading.md +42 -0
  24. package/agent-docs/publish/deployment-profiles.sections/supply-the-profile-from-the-deployment.md +68 -0
  25. package/agent-docs/search-index.json +1 -1
  26. package/agent-docs/start.md +2 -2
  27. package/agent-docs/start.sections/add-the-page-to-navigation.md +1 -1
  28. package/agent-docs/start.sections/build-and-check-both-outputs.md +1 -1
  29. package/agent-docs/start.sections/build-this-site.md +1 -1
  30. package/agent-docs/start.sections/check-writing-style.md +1 -1
  31. package/agent-docs/start.sections/create-a-page.md +1 -1
  32. package/agent-docs/start.sections/make-it-yours.md +1 -1
  33. package/agent-docs/start.sections/migrating-from-github-packages.md +1 -1
  34. package/agent-docs/start.sections/prerequisites.md +1 -1
  35. package/agent-docs/start.sections/related-reading.md +1 -1
  36. package/agent-docs/start.sections/start-your-own-project.md +2 -2
  37. package/dist/api-publication-plan.d.ts +3 -0
  38. package/dist/api-publication-plan.js +30 -3
  39. package/dist/artifact-bundle.d.ts +1 -0
  40. package/dist/artifact-bundle.js +1 -1
  41. package/dist/authoring-api.d.ts +1 -1
  42. package/dist/authoring-api.js +1 -1
  43. package/dist/authoring-project-command.js +4 -2
  44. package/dist/authoring-project.d.ts +2 -0
  45. package/dist/authoring-project.js +4 -4
  46. package/dist/authoring-snapshot.d.ts +23 -4
  47. package/dist/authoring-snapshot.js +84 -12
  48. package/dist/authoring-validation-command.js +5 -3
  49. package/dist/authoring-validation.d.ts +2 -0
  50. package/dist/authoring-validation.js +38 -11
  51. package/dist/cli-discovery.d.ts +2 -2
  52. package/dist/cli-discovery.js +9 -9
  53. package/dist/cli.js +8 -6
  54. package/dist/compiler.d.ts +13 -0
  55. package/dist/compiler.js +25 -74
  56. package/dist/component-names.js +1 -1
  57. package/dist/component-transform.js +7 -1
  58. package/dist/config.d.ts +4 -1
  59. package/dist/config.js +19 -5
  60. package/dist/content-export-command.d.ts +13 -1
  61. package/dist/content-export-command.js +3 -2
  62. package/dist/content-export.d.ts +13 -0
  63. package/dist/content-export.js +5 -4
  64. package/dist/content-source.d.ts +1 -0
  65. package/dist/content-workspace.d.ts +13 -0
  66. package/dist/content-workspace.js +18 -1
  67. package/dist/deployment-profile.d.ts +52 -0
  68. package/dist/deployment-profile.js +277 -0
  69. package/dist/frontmatter.js +11 -4
  70. package/dist/page-materialization.js +2 -0
  71. package/dist/page-renderer.d.ts +1 -0
  72. package/dist/page-renderer.js +3 -1
  73. package/dist/palettes.d.ts +1 -1
  74. package/dist/project-navigation.d.ts +5 -0
  75. package/dist/project-navigation.js +19 -0
  76. package/dist/publication-plan.d.ts +3 -1
  77. package/dist/publication-plan.js +23 -4
  78. package/dist/publication-render.d.ts +2 -1
  79. package/dist/publication-render.js +2 -2
  80. package/dist/site-identity.d.ts +3 -0
  81. package/dist/site-identity.js +69 -0
  82. package/dist/vercel.d.ts +1 -0
  83. package/dist/vercel.js +3 -1
  84. package/package.json +2 -1
  85. package/skills/shoalrun-author/SKILL.md +5 -1
  86. package/skills/shoalrun-author/references/customer-editions.md +164 -0
  87. package/skills/shoalrun-recipes/SKILL.md +2 -0
  88. package/skills/shoalrun-setup/SKILL.md +2 -0
  89. package/styles/site.css +7 -3
  90. package/agent-docs/publish/content-export.sections/upgrade-an-existing-integration.md +0 -34
@@ -16,7 +16,7 @@ Navigation section: [Docs](https://www.shoalrun.com/.well-known/shoalrun/navigat
16
16
 
17
17
  Breadcrumbs: [Docs](https://www.shoalrun.com/what-is-shoalrun.md) > Get started > Getting started
18
18
 
19
- **Updated: 2026-10-27.**
19
+ **Updated: 2026-10-30.**
20
20
 
21
21
  This quickstart takes you from installing Shoalrun to your own working documentation site. You will scaffold a page from a template, complete its draft, put it in the sidebar, and read its HTML and Markdown output.
22
22
 
@@ -36,7 +36,7 @@ Install an exact version of the alpha and initialize the minimal starter:
36
36
  mkdir my-docs
37
37
  cd my-docs
38
38
  npm init -y
39
- npm install --save-exact @shoalrun/cli@0.1.0-alpha.11
39
+ npm install --save-exact @shoalrun/cli@0.1.0-alpha.14
40
40
  npx --no-install shoalrun init .
41
41
  ```
42
42
 
@@ -16,7 +16,7 @@ Navigation section: [Docs](https://www.shoalrun.com/.well-known/shoalrun/navigat
16
16
 
17
17
  Breadcrumbs: [Docs](https://www.shoalrun.com/what-is-shoalrun.md) > Get started > Getting started
18
18
 
19
- **Updated: 2026-10-27.**
19
+ **Updated: 2026-10-30.**
20
20
 
21
21
  Parent page: [Full Markdown page](/start.md)
22
22
 
@@ -16,7 +16,7 @@ Navigation section: [Docs](https://www.shoalrun.com/.well-known/shoalrun/navigat
16
16
 
17
17
  Breadcrumbs: [Docs](https://www.shoalrun.com/what-is-shoalrun.md) > Get started > Getting started
18
18
 
19
- **Updated: 2026-10-27.**
19
+ **Updated: 2026-10-30.**
20
20
 
21
21
  Parent page: [Full Markdown page](/start.md)
22
22
 
@@ -16,7 +16,7 @@ Navigation section: [Docs](https://www.shoalrun.com/.well-known/shoalrun/navigat
16
16
 
17
17
  Breadcrumbs: [Docs](https://www.shoalrun.com/what-is-shoalrun.md) > Get started > Getting started
18
18
 
19
- **Updated: 2026-10-27.**
19
+ **Updated: 2026-10-30.**
20
20
 
21
21
  Parent page: [Full Markdown page](/start.md)
22
22
 
@@ -16,7 +16,7 @@ Navigation section: [Docs](https://www.shoalrun.com/.well-known/shoalrun/navigat
16
16
 
17
17
  Breadcrumbs: [Docs](https://www.shoalrun.com/what-is-shoalrun.md) > Get started > Getting started
18
18
 
19
- **Updated: 2026-10-27.**
19
+ **Updated: 2026-10-30.**
20
20
 
21
21
  Parent page: [Full Markdown page](/start.md)
22
22
 
@@ -16,7 +16,7 @@ Navigation section: [Docs](https://www.shoalrun.com/.well-known/shoalrun/navigat
16
16
 
17
17
  Breadcrumbs: [Docs](https://www.shoalrun.com/what-is-shoalrun.md) > Get started > Getting started
18
18
 
19
- **Updated: 2026-10-27.**
19
+ **Updated: 2026-10-30.**
20
20
 
21
21
  Parent page: [Full Markdown page](/start.md)
22
22
 
@@ -16,7 +16,7 @@ Navigation section: [Docs](https://www.shoalrun.com/.well-known/shoalrun/navigat
16
16
 
17
17
  Breadcrumbs: [Docs](https://www.shoalrun.com/what-is-shoalrun.md) > Get started > Getting started
18
18
 
19
- **Updated: 2026-10-27.**
19
+ **Updated: 2026-10-30.**
20
20
 
21
21
  Parent page: [Full Markdown page](/start.md)
22
22
 
@@ -16,7 +16,7 @@ Navigation section: [Docs](https://www.shoalrun.com/.well-known/shoalrun/navigat
16
16
 
17
17
  Breadcrumbs: [Docs](https://www.shoalrun.com/what-is-shoalrun.md) > Get started > Getting started
18
18
 
19
- **Updated: 2026-10-27.**
19
+ **Updated: 2026-10-30.**
20
20
 
21
21
  Parent page: [Full Markdown page](/start.md)
22
22
 
@@ -16,7 +16,7 @@ Navigation section: [Docs](https://www.shoalrun.com/.well-known/shoalrun/navigat
16
16
 
17
17
  Breadcrumbs: [Docs](https://www.shoalrun.com/what-is-shoalrun.md) > Get started > Getting started
18
18
 
19
- **Updated: 2026-10-27.**
19
+ **Updated: 2026-10-30.**
20
20
 
21
21
  Parent page: [Full Markdown page](/start.md)
22
22
 
@@ -16,7 +16,7 @@ Navigation section: [Docs](https://www.shoalrun.com/.well-known/shoalrun/navigat
16
16
 
17
17
  Breadcrumbs: [Docs](https://www.shoalrun.com/what-is-shoalrun.md) > Get started > Getting started
18
18
 
19
- **Updated: 2026-10-27.**
19
+ **Updated: 2026-10-30.**
20
20
 
21
21
  Parent page: [Full Markdown page](/start.md)
22
22
 
@@ -16,7 +16,7 @@ Navigation section: [Docs](https://www.shoalrun.com/.well-known/shoalrun/navigat
16
16
 
17
17
  Breadcrumbs: [Docs](https://www.shoalrun.com/what-is-shoalrun.md) > Get started > Getting started
18
18
 
19
- **Updated: 2026-10-27.**
19
+ **Updated: 2026-10-30.**
20
20
 
21
21
  Parent page: [Full Markdown page](/start.md)
22
22
 
@@ -38,7 +38,7 @@ Install an exact version of the alpha and initialize the minimal starter:
38
38
  mkdir my-docs
39
39
  cd my-docs
40
40
  npm init -y
41
- npm install --save-exact @shoalrun/cli@0.1.0-alpha.11
41
+ npm install --save-exact @shoalrun/cli@0.1.0-alpha.14
42
42
  npx --no-install shoalrun init .
43
43
  ```
44
44
 
@@ -30,6 +30,8 @@ type ApiDefinition = {
30
30
  id: string;
31
31
  reference: string;
32
32
  sourceOperationId?: string;
33
+ shared?: unknown;
34
+ requires?: unknown;
33
35
  }>;
34
36
  generateReferences?: boolean | {
35
37
  pathPrefix?: string;
@@ -60,6 +62,7 @@ export declare function materializeApiReferences({ config, loaded, variants, doc
60
62
  allowRouteCollisions?: boolean;
61
63
  }): Promise<{
62
64
  api: ApiReader;
65
+ operationRequirements: Map<string, unknown>;
63
66
  queryRules: Map<string, import("./api.js").QuerySyntaxRule[]>;
64
67
  lifecycleMappings: Map<string, {
65
68
  from: string;
@@ -1,3 +1,4 @@
1
+ import { matchesFeatures, validateFeatureClassification } from './deployment-profile.js';
1
2
  import { matchesContentFilter } from './content-filter.js';
2
3
  import path from 'node:path';
3
4
  import { ApiReader, querySyntaxRules } from './api.js';
@@ -37,6 +38,14 @@ function validatedApiDefinitions(value) {
37
38
  }
38
39
  export async function materializeApiReferences({ config, loaded, variants, docs, knownDocumentIds, knownDocumentTypes, authoredOperationIds, routeOwners, basePath, readerFilters, contentFilter, allowRouteCollisions }) {
39
40
  const definitions = validatedApiDefinitions(config.apis);
41
+ for (const [id, definition] of Object.entries(definitions)) {
42
+ validateFeatureClassification(definition, loaded.deployment ? { ...loaded.deployment.context, requireClassification: false } : undefined, `API ${id}`);
43
+ for (const operation of definition.operations ?? [])
44
+ validateFeatureClassification(operation, loaded.deployment ? { ...loaded.deployment.context, requireClassification: false } : undefined, `Operation ${operation.id}`);
45
+ if (loaded.deployment?.context.grants && [definition.source, ...(definition.publications ?? []).map(entry => entry.source)].some(source => source?.publish === true))
46
+ throw new Error(`API ${id}: profiled builds cannot publish a full OpenAPI source; use focused contracts (source.publish: false, source.focused: true)`);
47
+ }
48
+ const operationRequirements = new Map();
40
49
  const api = new ApiReader();
41
50
  const queryRules = new Map(Object.entries(definitions).map(([id, definition]) => [id, querySyntaxRules(definition.querySyntax)]));
42
51
  const lifecycleMappings = new Map();
@@ -108,7 +117,7 @@ export async function materializeApiReferences({ config, loaded, variants, docs,
108
117
  explicit.add(operation.sourceOperationId);
109
118
  }
110
119
  definition.operations ??= [];
111
- for (const variant of variants) {
120
+ for (const variant of loaded.deployment?.context.classificationPublications ?? variants) {
112
121
  const binding = sourceForVariant(apiId, definition, variant);
113
122
  if (!binding)
114
123
  continue;
@@ -147,7 +156,21 @@ export async function materializeApiReferences({ config, loaded, variants, docs,
147
156
  knownOperations.set(id, { apiId, reference });
148
157
  definition.operations.push({ id, sourceOperationId, reference });
149
158
  }
150
- if (!selected)
159
+ if (selected) {
160
+ knownDocumentIds.add(reference);
161
+ knownDocumentTypes.set(reference, 'api-reference');
162
+ }
163
+ const requirements = isConfigMapping(operation) ? operation['x-shoalrun-requires'] : undefined;
164
+ const shared = isConfigMapping(operation) ? operation['x-shoalrun-shared'] : undefined;
165
+ if (selected) {
166
+ const classification = requirements !== undefined || shared !== undefined ? { requires: requirements, shared } : definition;
167
+ validateFeatureClassification(classification, loaded.deployment?.context, `${binding.file}: ${sourceOperationId} (generated reference)`);
168
+ }
169
+ if (!variants.some(item => item.channel === variant.channel && item.version === variant.version && item.language === variant.language))
170
+ continue;
171
+ operationRequirements.set(keyFor(id, variant), requirements);
172
+ const featureSelected = matchesFeatures(requirements, loaded.deployment?.context, `${binding.file}: ${sourceOperationId}`) && matchesFeatures(definition.requires, loaded.deployment?.context, `API ${apiId}`);
173
+ if (!selected || !featureSelected)
151
174
  continue;
152
175
  const page = resolveFrontmatter({
153
176
  id: reference, title: String(isConfigMapping(operation) && operation.summary || `${webhook ? 'Webhook' : method.toUpperCase()} ${route}`),
@@ -158,6 +181,8 @@ export async function materializeApiReferences({ config, loaded, variants, docs,
158
181
  validateFrontmatter(page, config.frontmatter, binding.file);
159
182
  knownDocumentIds.add(reference);
160
183
  knownDocumentTypes.set(reference, page.type);
184
+ if (!matchesFeatures(page.requires, loaded.deployment?.context, `${binding.file}: ${reference}`))
185
+ continue;
161
186
  if (page.access?.policy !== 'public' || page.channel !== variant.channel || page.version !== variant.version || page.language !== variant.language)
162
187
  continue;
163
188
  const key = keyFor(reference, variant);
@@ -167,6 +192,8 @@ export async function materializeApiReferences({ config, loaded, variants, docs,
167
192
  throw new Error(`Generated API reference ${reference} conflicts with an authored page`);
168
193
  continue;
169
194
  }
195
+ if (loaded.deployment?.context.grants && authoredOperationIds.has(id))
196
+ continue;
170
197
  const marks = pageReaderFilters(page, readerFilters, binding.file);
171
198
  if (!matchesContentFilter(marks, contentFilter))
172
199
  continue;
@@ -183,5 +210,5 @@ export async function materializeApiReferences({ config, loaded, variants, docs,
183
210
  if (!available.has(id))
184
211
  throw new Error(`apis.${apiId}.generateReferences names unknown source operationId ${id}`);
185
212
  }
186
- return { api, queryRules, lifecycleMappings, extensionValue, sourceForVariant, knownOperations, apiDefinitions: definitions };
213
+ return { api, operationRequirements, queryRules, lifecycleMappings, extensionValue, sourceForVariant, knownOperations, apiDefinitions: definitions };
187
214
  }
@@ -51,6 +51,7 @@ export type ArtifactAssemblyInput = {
51
51
  mcpAnalyticsModule?: string;
52
52
  previewFiles: string[];
53
53
  assetDirectory: string;
54
+ privateDependencies?: ReadonlySet<string>;
54
55
  themeSwitcher: boolean;
55
56
  themeDirectory: string;
56
57
  palette: Palette;
@@ -98,7 +98,7 @@ export async function assembleArtifacts(input) {
98
98
  return `shoalrun:${framework}`;
99
99
  return `external:${path.basename(resolved)}`;
100
100
  };
101
- addMetadataArtifact('/documents.json', JSON.stringify({ ...manifest, dependencies: [...new Set(manifest.dependencies.map(publicDependency))].sort() }, null, 2) + '\n');
101
+ addMetadataArtifact('/documents.json', JSON.stringify({ ...manifest, dependencies: [...new Set(manifest.dependencies.filter(file => !input.privateDependencies?.has(file)).map(publicDependency))].sort() }, null, 2) + '\n');
102
102
  addMetadataArtifact('/build-info.json', JSON.stringify(provenanceRecord, null, 2) + '\n');
103
103
  for (const styleFile of styleFiles)
104
104
  addPublicArtifact(`/assets/${path.basename(styleFile)}`, await readFile(styleFile, 'utf8'));
@@ -15,4 +15,4 @@ export { authoringReview, authoringReviewMarkdown } from './authoring-review.js'
15
15
  export type { AuthoringReview } from './authoring-review.js';
16
16
  export { startAuthoringPreview } from './authoring-preview.js';
17
17
  export type { AuthoringPreview, AuthoringPreviewOptions } from './authoring-preview.js';
18
- export declare const authoringCapabilities: readonly ["create-page", "complete-bundle-validation", "project-discovery", "project-source-read", "documentation-search-read", "working-tree-validation", "task-baseline", "validation-freshness", "snapshot-preview", "authoring-review-report"];
18
+ export declare const authoringCapabilities: readonly ["create-page", "complete-bundle-validation", "project-discovery", "project-source-read", "documentation-search-read", "working-tree-validation", "task-baseline", "validation-freshness", "snapshot-preview", "authoring-review-report", "deployment-profiles"];
@@ -12,4 +12,4 @@ export { authoringReview, authoringReviewMarkdown } from './authoring-review.js'
12
12
  export { startAuthoringPreview } from './authoring-preview.js';
13
13
  // Optional capabilities let private authoring clients reject an older package
14
14
  // before spending provider budget. Existing version-1 update clients still work.
15
- export const authoringCapabilities = ['create-page', 'complete-bundle-validation', 'project-discovery', 'project-source-read', 'documentation-search-read', 'working-tree-validation', 'task-baseline', 'validation-freshness', 'snapshot-preview', 'authoring-review-report'];
15
+ export const authoringCapabilities = ['create-page', 'complete-bundle-validation', 'project-discovery', 'project-source-read', 'documentation-search-read', 'working-tree-validation', 'task-baseline', 'validation-freshness', 'snapshot-preview', 'authoring-review-report', 'deployment-profiles'];
@@ -15,11 +15,13 @@ export function parseProjectOptions(command, args) {
15
15
  seen.add(flag);
16
16
  if (flag === '--json')
17
17
  continue;
18
- const allowed = command === 'project' ? ['--config', '--section', '--id', '--file', '--query', '--offset', '--limit', '--max-tokens'] : ['--config', '--offset', '--max-tokens'];
18
+ const allowed = command === 'project' ? ['--profile', '--config', '--section', '--id', '--file', '--query', '--offset', '--limit', '--max-tokens'] : ['--profile', '--config', '--offset', '--max-tokens'];
19
19
  const value = args[++i];
20
20
  if (!allowed.includes(flag) || !value || value.startsWith('--'))
21
21
  throw new ProjectToolError('invalid_input', `Unknown option or missing value for ${flag}`);
22
- if (flag === '--config')
22
+ if (flag === '--profile')
23
+ options.profile = value;
24
+ else if (flag === '--config')
23
25
  config = value;
24
26
  else if (['--offset', '--limit', '--max-tokens'].includes(flag)) {
25
27
  if (!/^\d+$/.test(value))
@@ -2,6 +2,7 @@ export declare const projectToolSchemaVersion = 1;
2
2
  export declare const projectSections: readonly ["pages", "guidance", "configuration", "navigation", "relationships", "templates", "findings"];
3
3
  export type ProjectSection = typeof projectSections[number];
4
4
  export type ProjectOptions = {
5
+ profile?: string;
5
6
  section?: ProjectSection;
6
7
  id?: string;
7
8
  file?: string;
@@ -78,6 +79,7 @@ export declare function inspectAuthoringProject(configFile: string, options?: Pr
78
79
  }>;
79
80
  /** Exact project-relative passage; role is context, not evidence approval. */
80
81
  export declare function readAuthoringSource(configFile: string, file: string, options?: {
82
+ profile?: string;
81
83
  offset?: number;
82
84
  maxTokens?: number;
83
85
  }): Promise<{
@@ -61,11 +61,11 @@ async function safeFile(root, file) {
61
61
  throw new ProjectToolError('unsafe_path', `Source escapes project: ${file}`);
62
62
  return actual;
63
63
  }
64
- async function openProject(configFile) {
64
+ async function openProject(configFile, profile) {
65
65
  try {
66
66
  const config = await realpath(path.resolve(configFile));
67
67
  const root = path.dirname(config);
68
- const loaded = await loadConfig(config);
68
+ const loaded = await loadConfig(config, { profile });
69
69
  const guidance = validateAuthoringGuidance(loaded.value.authoring);
70
70
  return { config, root, loaded, guidance };
71
71
  }
@@ -88,7 +88,7 @@ export async function inspectAuthoringProject(configFile, options = {}) {
88
88
  const offset = integer(options.offset, 0, 0, Number.MAX_SAFE_INTEGER, 'offset');
89
89
  const limit = integer(options.limit, 20, 1, 100, 'limit');
90
90
  const maxTokens = integer(options.maxTokens, 8192, 1024, 100000, 'maxTokens');
91
- const { root, config, loaded, guidance } = await openProject(configFile);
91
+ const { root, config, loaded, guidance } = await openProject(configFile, options.profile);
92
92
  const value = loaded.value;
93
93
  const relative = (file) => path.relative(root, file);
94
94
  const digests = new Map();
@@ -263,7 +263,7 @@ export async function inspectAuthoringProject(configFile, options = {}) {
263
263
  export async function readAuthoringSource(configFile, file, options = {}) {
264
264
  const offset = integer(options.offset, 0, 0, Number.MAX_SAFE_INTEGER, 'offset');
265
265
  const maxTokens = integer(options.maxTokens, 4096, 1024, 100000, 'maxTokens');
266
- const { root, loaded, guidance } = await openProject(configFile);
266
+ const { root, loaded, guidance } = await openProject(configFile, options.profile);
267
267
  const actual = await safeFile(root, file);
268
268
  const raw = await readFile(actual, 'utf8');
269
269
  const chars = [...raw];
@@ -11,7 +11,12 @@ export type SnapshotInput = {
11
11
  digest: string;
12
12
  mode: number;
13
13
  };
14
+ export type ProfileCapture = {
15
+ file: string;
16
+ sources: Record<string, string>;
17
+ };
14
18
  export type SnapshotCapture = {
19
+ profile?: ProfileCapture;
15
20
  root: string;
16
21
  config: string;
17
22
  inputs: SnapshotInput[];
@@ -24,6 +29,7 @@ export type SnapshotCapture = {
24
29
  configDigest?: string;
25
30
  };
26
31
  export type AuthoringBaseline = {
32
+ profile?: ProfileCapture;
27
33
  schemaVersion: 1;
28
34
  kind: 'shoalrun-authoring-baseline';
29
35
  project: string;
@@ -33,7 +39,14 @@ export type AuthoringBaseline = {
33
39
  externalDirectories: string[];
34
40
  excluded: SnapshotCapture['excluded'];
35
41
  };
42
+ export declare function validateProfileCapture(root: string, profile?: ProfileCapture): void;
36
43
  export declare function relativeInput(root: string, file: string): string;
44
+ /** Read only the named mounted file, including assets retained by a baseline. */
45
+ export declare function readProfileInput(source: string): Promise<{
46
+ bytes: NonSharedBuffer;
47
+ digest: string;
48
+ mode: number;
49
+ }>;
37
50
  export declare function collectSnapshotInputs(root: string, externalFiles?: string[], externalDirectories?: string[]): Promise<{
38
51
  inputs: SnapshotInput[];
39
52
  buffers: Map<string, Buffer<ArrayBufferLike>>;
@@ -43,9 +56,13 @@ export declare function collectSnapshotInputs(root: string, externalFiles?: stri
43
56
  }[];
44
57
  }>;
45
58
  /** Capture static, project-owned inputs without running compiler/plugins/checks. */
46
- export declare function captureSnapshotInputs(configFile: string): Promise<SnapshotCapture>;
47
- export declare function confirmSnapshotInputs(capture: Pick<SnapshotCapture, 'root' | 'inputs' | 'externalDirectories'>): Promise<boolean>;
48
- export declare function captureAuthoringBaseline(configFile: string): Promise<AuthoringBaseline>;
59
+ export declare function captureSnapshotInputs(configFile: string, options?: {
60
+ profile?: string;
61
+ }): Promise<SnapshotCapture>;
62
+ export declare function confirmSnapshotInputs(capture: Pick<SnapshotCapture, 'root' | 'inputs' | 'externalDirectories' | 'profile'>): Promise<boolean>;
63
+ export declare function captureAuthoringBaseline(configFile: string, options?: {
64
+ profile?: string;
65
+ }): Promise<AuthoringBaseline>;
49
66
  export declare function parseAuthoringBaseline(value: unknown, root: string, config: string): AuthoringBaseline;
50
67
  export declare function validatorInputs(): Promise<{
51
68
  digest: string;
@@ -53,7 +70,9 @@ export declare function validatorInputs(): Promise<{
53
70
  }>;
54
71
  /** Artifact destinations are exclusive and cannot collide with source inputs. */
55
72
  export declare function authoringArtifactPath(root: string, destination: string): Promise<string>;
56
- export declare function writeAuthoringBaseline(configFile: string, output: string): Promise<{
73
+ export declare function writeAuthoringBaseline(configFile: string, output: string, options?: {
74
+ profile?: string;
75
+ }): Promise<{
57
76
  schemaVersion: number;
58
77
  status: string;
59
78
  baseline: string;
@@ -1,6 +1,7 @@
1
1
  import { componentPluginFiles } from './component-manifest.js';
2
2
  import { createHash } from 'node:crypto';
3
- import { lstat, mkdir, readFile, readdir, realpath, stat, writeFile } from 'node:fs/promises';
3
+ import { lstat, mkdir, open, readFile, readdir, realpath, stat, writeFile } from 'node:fs/promises';
4
+ import { constants } from 'node:fs';
4
5
  import path from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { explainSegments, loadConfig } from './config.js';
@@ -16,10 +17,28 @@ export class SnapshotError extends Error {
16
17
  }
17
18
  }
18
19
  export const sha256 = (value) => createHash('sha256').update(value).digest('hex');
19
- export const isWithin = (root, file) => file === root || file.startsWith(root + path.sep);
20
+ export const isWithin = (root, file) => file === root || file.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
20
21
  export const excludedNames = new Set(['.git', 'node_modules', 'dist', 'site-dist', '.vercel', '.shoalrun', '.private-archives', '.private-evals', '.aws', '.ssh', '.codex', '.npmrc']);
21
22
  const excluded = (name) => excludedNames.has(name) || name === '.env' || name.startsWith('.env.');
22
23
  const framework = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
24
+ export function validateProfileCapture(root, profile) {
25
+ if (profile === undefined)
26
+ return;
27
+ if (!profile || typeof profile.file !== 'string' || !profile.sources || typeof profile.sources !== 'object' || Array.isArray(profile.sources))
28
+ throw new SnapshotError('invalid_input', 'Invalid captured deployment profile');
29
+ const source = profile.sources[profile.file];
30
+ if (typeof source !== 'string' || !path.isAbsolute(source))
31
+ throw new SnapshotError('invalid_input', 'Captured profile needs its explicit source file');
32
+ const directory = `../.shoalrun-profile-${sha256(source).slice(0, 16)}`;
33
+ if (profile.file !== `${directory}/${path.basename(source)}`)
34
+ throw new SnapshotError('unsafe_path', 'Captured profile identity does not match its source');
35
+ for (const [file, original] of Object.entries(profile.sources)) {
36
+ relativeInput(root, file);
37
+ const relative = path.relative(directory, file);
38
+ if (!relative || path.isAbsolute(relative) || relative.startsWith('..') || typeof original !== 'string' || original !== path.resolve(path.dirname(source), relative))
39
+ throw new SnapshotError('unsafe_path', 'Captured profile asset escapes its declared directory');
40
+ }
41
+ }
23
42
  export function relativeInput(root, file) {
24
43
  if (typeof file !== 'string' || !file || path.isAbsolute(file) || /[\\\0\r\n]/.test(file))
25
44
  throw new SnapshotError('unsafe_path', 'Input paths must be project-relative');
@@ -40,6 +59,29 @@ async function noSymlinks(file, boundary) {
40
59
  current = path.dirname(current);
41
60
  }
42
61
  }
62
+ /** Read only the named mounted file, including assets retained by a baseline. */
63
+ export async function readProfileInput(source) {
64
+ const boundary = path.parse(source).root;
65
+ await noSymlinks(source, boundary);
66
+ const handle = await open(source, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
67
+ try {
68
+ const before = await handle.stat();
69
+ if (!before.isFile())
70
+ throw new SnapshotError('unsafe_path', 'Profile inputs must be regular files');
71
+ if (before.size > 16 * 1024 * 1024)
72
+ throw new SnapshotError('capture_limit', 'Profile input exceeds 16 MiB');
73
+ const bytes = await handle.readFile();
74
+ const after = await handle.stat();
75
+ await noSymlinks(source, boundary);
76
+ const current = await lstat(source);
77
+ if (bytes.length > 16 * 1024 * 1024 || before.ino !== current.ino || before.dev !== current.dev || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.mode !== after.mode)
78
+ throw new SnapshotError('stale_input', 'Profile input changed while reading; retry validation');
79
+ return { bytes, digest: sha256(bytes), mode: before.mode & 0o777 };
80
+ }
81
+ finally {
82
+ await handle.close();
83
+ }
84
+ }
43
85
  export async function collectSnapshotInputs(root, externalFiles = [], externalDirectories = []) {
44
86
  const buffers = new Map();
45
87
  const inputs = [];
@@ -89,18 +131,31 @@ export async function collectSnapshotInputs(root, externalFiles = [], externalDi
89
131
  return { inputs, buffers, excluded: exclusions };
90
132
  }
91
133
  /** Capture static, project-owned inputs without running compiler/plugins/checks. */
92
- export async function captureSnapshotInputs(configFile) {
134
+ export async function captureSnapshotInputs(configFile, options = {}) {
93
135
  const config = await realpath(path.resolve(configFile));
94
136
  const root = path.dirname(config);
95
137
  const externalFiles = new Set(), externalDirectories = new Set();
138
+ const profileFile = options.profile ? await realpath(path.resolve(options.profile)) : undefined;
139
+ let profile;
140
+ const profileDependencies = new Set();
96
141
  let configDigest;
97
142
  let loaded;
98
143
  try {
99
- loaded = await loadConfig(config);
144
+ loaded = await loadConfig(config, { profile: profileFile });
100
145
  configDigest = loaded.digest;
101
146
  }
102
147
  catch { /* Invalid configuration must still be captured and diagnosed in isolation. */ }
148
+ if (profileFile) {
149
+ const directory = `../.shoalrun-profile-${sha256(profileFile).slice(0, 16)}`;
150
+ profile = { file: `${directory}/${path.basename(profileFile)}`, sources: {} };
151
+ for (const source of [profileFile, ...(loaded?.deployment?.assetFiles ?? [])]) {
152
+ profileDependencies.add(source);
153
+ profile.sources[`${directory}/${path.relative(path.dirname(profileFile), source)}`] = source;
154
+ }
155
+ }
103
156
  const addExternal = async (absolute) => {
157
+ if (profileDependencies.has(absolute))
158
+ return;
104
159
  if (isWithin(root, absolute))
105
160
  return;
106
161
  const name = path.relative(root, absolute);
@@ -138,7 +193,7 @@ export async function captureSnapshotInputs(configFile) {
138
193
  for (const key of Object.keys(value))
139
194
  await visit(value[key], [...segments, key]);
140
195
  }
141
- else if (typeof value === 'string' && (['file', 'path', 'module', 'manifest', 'customCss', 'customReaderModule', 'body', 'template', 'favicon'].includes(segments.at(-1)) || segments.at(-2) === 'files') && !/^(?:[a-z][a-z0-9+.-]*:|#)/i.test(value)) {
196
+ else if (typeof value === 'string' && (['file', 'path', 'module', 'manifest', 'customCss', 'customReaderModule', 'body', 'template', 'favicon', 'logo'].includes(segments.at(-1)) || segments.at(-2) === 'files') && !/^(?:[a-z][a-z0-9+.-]*:|#)/i.test(value)) {
142
197
  if (path.isAbsolute(value))
143
198
  throw new SnapshotError('unsupported_dependency', `Absolute dependency at ${segments.join('.')} cannot be isolated; use a relative path.`);
144
199
  if (segments[0] === 'components' && segments.at(-1) === 'module' && !value.startsWith('.'))
@@ -199,28 +254,45 @@ export async function captureSnapshotInputs(configFile) {
199
254
  for (const input of captured.inputs)
200
255
  if (complete.inputs.find(item => item.file === input.file)?.digest !== input.digest)
201
256
  throw new SnapshotError('stale_input', `Input changed during dependency discovery: ${input.file}`);
202
- return { root, config: path.basename(config), ...complete, externalDirectories: [...externalDirectories].sort(), configDigest };
257
+ for (const [file, source] of Object.entries(profile?.sources ?? {})) {
258
+ relativeInput(root, file);
259
+ const input = await readProfileInput(source);
260
+ complete.buffers.set(file, input.bytes);
261
+ complete.inputs.push({ file, digest: input.digest, mode: input.mode });
262
+ }
263
+ complete.inputs.sort((a, b) => a.file.localeCompare(b.file));
264
+ return { root, config: path.basename(config), ...complete, externalDirectories: [...externalDirectories].sort(), configDigest, ...(profile ? { profile } : {}) };
203
265
  }
204
266
  export async function confirmSnapshotInputs(capture) {
205
- const external = capture.inputs.filter(input => (!isWithin(capture.root, path.resolve(capture.root, input.file)) || input.file.split(path.sep).some(excluded))).map(input => input.file);
267
+ const sources = capture.profile?.sources ?? {};
268
+ const ordinary = capture.inputs.filter(input => !Object.hasOwn(sources, input.file));
269
+ const external = ordinary.filter(input => (!isWithin(capture.root, path.resolve(capture.root, input.file)) || input.file.split(path.sep).some(excluded))).map(input => input.file);
206
270
  try {
271
+ validateProfileCapture(capture.root, capture.profile);
207
272
  const now = await collectSnapshotInputs(capture.root, external, capture.externalDirectories);
273
+ for (const [file, source] of Object.entries(sources)) {
274
+ relativeInput(capture.root, file);
275
+ const input = await readProfileInput(source);
276
+ now.inputs.push({ file, digest: input.digest, mode: input.mode });
277
+ }
278
+ now.inputs.sort((a, b) => a.file.localeCompare(b.file));
208
279
  return JSON.stringify(now.inputs) === JSON.stringify(capture.inputs);
209
280
  }
210
281
  catch {
211
282
  return false;
212
283
  }
213
284
  }
214
- export async function captureAuthoringBaseline(configFile) {
215
- const capture = await captureSnapshotInputs(configFile);
285
+ export async function captureAuthoringBaseline(configFile, options = {}) {
286
+ const capture = await captureSnapshotInputs(configFile, options);
216
287
  if (!await confirmSnapshotInputs(capture))
217
288
  throw new SnapshotError('stale_input', 'Working tree changed while capturing the task baseline; retry.');
218
- return { schemaVersion: 1, kind: 'shoalrun-authoring-baseline', project: capture.root, config: capture.config, inputs: capture.inputs, externalDirectories: capture.externalDirectories, excluded: capture.excluded, digest: sha256(JSON.stringify(capture.inputs)) };
289
+ return { profile: capture.profile, schemaVersion: 1, kind: 'shoalrun-authoring-baseline', project: capture.root, config: capture.config, inputs: capture.inputs, externalDirectories: capture.externalDirectories, excluded: capture.excluded, digest: sha256(JSON.stringify(capture.inputs)) };
219
290
  }
220
291
  export function parseAuthoringBaseline(value, root, config) {
221
292
  const baseline = value;
222
293
  if (!baseline || baseline.schemaVersion !== 1 || baseline.kind !== 'shoalrun-authoring-baseline' || baseline.project !== root || baseline.config !== config || !Array.isArray(baseline.inputs) || !Array.isArray(baseline.externalDirectories) || !Array.isArray(baseline.excluded))
223
294
  throw new SnapshotError('invalid_input', 'Baseline must be a version-1 task baseline for this project/configuration.');
295
+ validateProfileCapture(root, baseline.profile);
224
296
  const seen = new Set();
225
297
  for (const directory of baseline.externalDirectories)
226
298
  relativeInput(root, directory);
@@ -284,8 +356,8 @@ export async function authoringArtifactPath(root, destination) {
284
356
  await noSymlinks(target, path.parse(target).root);
285
357
  return target;
286
358
  }
287
- export async function writeAuthoringBaseline(configFile, output) {
288
- const baseline = await captureAuthoringBaseline(configFile);
359
+ export async function writeAuthoringBaseline(configFile, output, options = {}) {
360
+ const baseline = await captureAuthoringBaseline(configFile, options);
289
361
  const target = await authoringArtifactPath(baseline.project, output);
290
362
  await mkdir(path.dirname(target), { recursive: true });
291
363
  await writeFile(target, JSON.stringify(baseline, null, 2) + '\n', { flag: 'wx' });
@@ -21,11 +21,13 @@ export async function runValidationCommand(command, args, discoverConfig) {
21
21
  options.runConfiguredChecks = true;
22
22
  continue;
23
23
  }
24
- const allowed = command === 'baseline' ? ['--config', '--out'] : command === 'validate' ? ['--config', '--file', '--id', '--baseline', '--artifacts'] : [];
24
+ const allowed = command === 'baseline' ? ['--config', '--out', '--profile'] : command === 'validate' ? ['--config', '--file', '--id', '--baseline', '--artifacts', '--profile'] : [];
25
25
  const value = args[++index];
26
26
  if (!allowed.includes(flag) || !value || value.startsWith('--'))
27
27
  throw new SnapshotError('invalid_input', `Unknown option or missing value for ${flag}`);
28
- if (flag === '--config')
28
+ if (flag === '--profile')
29
+ options.profile = value;
30
+ else if (flag === '--config')
29
31
  config = value;
30
32
  else if (flag === '--out')
31
33
  output = value;
@@ -42,7 +44,7 @@ export async function runValidationCommand(command, args, discoverConfig) {
42
44
  throw new SnapshotError('invalid_input', `Unexpected arguments for docs ${command}`);
43
45
  if (command === 'baseline' && !output)
44
46
  throw new SnapshotError('invalid_input', 'Usage: shoalrun docs baseline --out FILE [--config FILE]');
45
- const result = command === 'validation-status' ? await authoringValidationStatus(positional[0]) : command === 'baseline' ? await writeAuthoringBaseline(config ?? await discoverConfig(), output) : await validateAuthoringChanges(config ?? await discoverConfig(), options);
47
+ const result = command === 'validation-status' ? await authoringValidationStatus(positional[0]) : command === 'baseline' ? await writeAuthoringBaseline(config ?? await discoverConfig(), output, { profile: options.profile }) : await validateAuthoringChanges(config ?? await discoverConfig(), options);
46
48
  process.stdout.write(JSON.stringify(result) + '\n');
47
49
  if (result.status !== 'ok')
48
50
  process.exitCode = 1;
@@ -1,6 +1,7 @@
1
1
  import { type ValidationFinding } from './authoring-findings.js';
2
2
  import { type SnapshotCapture, type SnapshotInput } from './authoring-snapshot.js';
3
3
  export type ValidationOptions = {
4
+ profile?: string;
4
5
  files?: string[];
5
6
  ids?: string[];
6
7
  baselineFile?: string;
@@ -15,6 +16,7 @@ export type ValidationCheck = {
15
16
  findings?: number;
16
17
  };
17
18
  export type ValidationReport = {
19
+ profile?: SnapshotCapture['profile'];
18
20
  schemaVersion: 1;
19
21
  kind: 'shoalrun-authoring-validation';
20
22
  status: 'ok' | 'failed' | 'incomplete' | 'unavailable' | 'stale';