@notis_ai/cli 0.2.0-beta.191.1 → 0.2.0-beta.197.1
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 +485 -86
- package/config/portal-route-roots.json +47 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +32663 -6357
- package/dist/base-skills/notis-apps/SKILL.md +88 -67
- package/dist/base-skills/notis-apps/references/architecture.md +114 -167
- package/dist/base-skills/notis-apps/references/design.md +26 -107
- package/dist/base-skills/notis-apps/references/memory.md +58 -0
- package/dist/base-skills/notis-apps/references/reading.md +52 -74
- package/dist/base-skills/notis-apps/references/release.md +104 -142
- package/dist/base-skills/notis-apps/references/sdk.md +113 -60
- package/dist/base-skills/notis-apps/references/troubleshooting.md +22 -21
- package/dist/base-skills/notis-apps/references/views.md +142 -0
- package/dist/base-skills/notis-cli/SKILL.md +4 -4
- package/dist/base-skills/notis-cli/references/app-delivery.md +12 -11
- package/dist/base-skills/notis-cli/references/native-databases.md +6 -6
- package/dist/base-skills/notis-cli/references/tool-examples.md +4 -4
- package/dist/base-skills/notis-query/SKILL.md +34 -60
- package/dist/base-skills/notis-query/references/database-discovery.md +59 -69
- package/dist/base-skills/notis-query/references/documents.md +63 -50
- package/dist/base-skills/notis-query/references/query.md +57 -524
- package/dist/sdk/src/bundleModule.ts +79 -0
- package/dist/sdk/src/components/DocumentEditor.tsx +6 -3
- package/dist/sdk/src/components/DocumentPage.tsx +141 -0
- package/dist/sdk/src/components/RecordComponents.tsx +33 -0
- package/dist/sdk/src/components/RenderChartData.tsx +19 -0
- package/dist/sdk/src/config.ts +4 -26
- package/dist/sdk/src/documents.ts +2 -0
- package/dist/sdk/src/hooks/useAction.ts +28 -0
- package/dist/sdk/src/hooks/useCloudComputer.ts +35 -4
- package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
- package/dist/sdk/src/hooks/useHandover.ts +5 -4
- package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
- package/dist/sdk/src/hooks/useNativeDocuments.ts +83 -0
- package/dist/sdk/src/hooks/useNotis.ts +3 -0
- package/dist/sdk/src/hooks/useNotisNavigation.ts +26 -11
- package/dist/sdk/src/hooks/useQuery.ts +7 -4
- package/dist/sdk/src/hooks/useViewParams.ts +121 -0
- package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
- package/dist/sdk/src/index.ts +36 -0
- package/dist/sdk/src/interactions/shortcuts.tsx +17 -3
- package/dist/sdk/src/nativeData.ts +39 -0
- package/dist/sdk/src/presentation.tsx +82 -0
- package/dist/sdk/src/provider.tsx +9 -4
- package/dist/sdk/src/queryCache.ts +5 -3
- package/dist/sdk/src/queryErrors.ts +51 -0
- package/dist/sdk/src/runtime.ts +146 -8
- package/dist/sdk/src/space.ts +425 -0
- package/dist/sdk/src/styles.css +96 -72
- package/dist/sdk/src/vite.ts +11 -1
- package/dist/skill-sync/index.js +458 -68
- package/dist/skill-sync/index.js.map +4 -4
- package/dist/skill-sync-worker.mjs +451 -77
- package/dist/space-harness/host.js +66 -0
- package/dist/view-renderer/dist/host.css +1 -0
- package/dist/view-renderer/dist/host.js +1042 -0
- package/dist/view-renderer/dist/theme.css +73 -0
- package/dist/view-renderer/src/dom.js +180 -0
- package/dist/view-renderer/src/errors.js +8 -0
- package/dist/view-renderer/src/host.tsx +173 -0
- package/dist/view-renderer/src/index.js +4 -0
- package/dist/view-renderer/src/pool.js +76 -0
- package/dist/view-renderer/src/remote-broker.js +59 -0
- package/dist/view-renderer/src/renderer.js +201 -0
- package/package.json +11 -5
- package/skills/notis-apps/cli.md +270 -163
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +3 -3
- package/skills/notis-onboarding/BRIEF.md +7 -3
- package/src/command-specs/apps.js +1 -8
- package/src/command-specs/files.js +29 -0
- package/src/command-specs/handover.js +45 -16
- package/src/command-specs/helpers.js +1 -0
- package/src/command-specs/index.js +10 -4
- package/src/command-specs/skills.js +229 -0
- package/src/command-specs/spaces.js +572 -0
- package/src/command-specs/views.js +69 -0
- package/src/runtime/agent-browser.js +30 -7
- package/src/runtime/app-boundary-validator.js +7 -4
- package/src/runtime/app-data-literals.js +82 -0
- package/src/runtime/app-platform.js +321 -253
- package/src/runtime/confined-space-build.js +74 -0
- package/src/runtime/confined-space-verify.js +49 -0
- package/src/runtime/native-record-files.js +47 -0
- package/src/runtime/retired-sdk-files.js +20 -0
- package/src/runtime/skill-sync/cloud-client.ts +26 -4
- package/src/runtime/skill-sync/index.ts +154 -35
- package/src/runtime/skill-sync/local-scanner.ts +125 -17
- package/src/runtime/skill-sync/native-identity.ts +102 -0
- package/src/runtime/skill-sync/native-plan.ts +59 -0
- package/src/runtime/skill-sync/sync-plan.ts +16 -9
- package/src/runtime/skill-sync/types.ts +32 -6
- package/src/runtime/source-root.js +32 -0
- package/src/runtime/space-collection-scaffold.js +66 -0
- package/src/runtime/space-database-structures.js +229 -0
- package/src/runtime/space-fixture-broker.js +50 -0
- package/src/runtime/space-lock.js +337 -0
- package/src/runtime/space-platform.js +105 -0
- package/src/runtime/space-source.js +281 -0
- package/src/runtime/space-test-server.js +327 -0
- package/src/runtime/space-verification.js +89 -0
- package/src/runtime/space-verify-browser-worker.js +51 -0
- package/src/runtime/space-view-manifest.js +254 -0
- package/src/runtime/sync-skills.js +1 -1
- package/src/runtime/transport.js +8 -1
- package/src/runtime/view-render.js +49 -0
- package/src/space-harness-host.ts +103 -0
- package/template/app/globals.css +1 -0
- package/src/command-specs/reports.js +0 -97
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { scaffoldProject } from './app-platform.js';
|
|
4
|
+
import { validateViewPath } from './space-view-manifest.js';
|
|
5
|
+
import { usageError } from './errors.js';
|
|
6
|
+
|
|
7
|
+
/** A local, editable collection layout. No remote database or Space mutation. */
|
|
8
|
+
export async function scaffoldSpaceCollection({ projectDir, name, databaseKey, path = 'records', titleProperty = 'title' }) {
|
|
9
|
+
if (typeof databaseKey !== 'string' || !databaseKey.trim() || databaseKey.length > 200) throw usageError('Pass the portable --database-key for the database this Space links.');
|
|
10
|
+
if (typeof name !== 'string' || !name.trim() || typeof titleProperty !== 'string' || !titleProperty.trim()) throw usageError('Choose a Space name and title property.');
|
|
11
|
+
validateViewPath(path);
|
|
12
|
+
await scaffoldProject({ projectDir, appName: name });
|
|
13
|
+
mkdirSync(join(projectDir, 'spaces', 'collection'), { recursive: true });
|
|
14
|
+
writeFileSync(join(projectDir, 'notis.config.ts'), `import { defineSpaces } from '@notis/sdk/config';
|
|
15
|
+
export default defineSpaces({ spaces: { collection: { definition: 'spaces/collection/space.config.ts' } } });\n`);
|
|
16
|
+
const definition = { specVersion: 2, name, description: `Browse and edit ${name} records.`, path, entry: './view.tsx', layout: '../../app/layout.tsx',
|
|
17
|
+
readableContext: `Browse ${name} records. The item parameter opens one record with its properties, collaborative body and sharing controls.`,
|
|
18
|
+
verificationFixtures: './verification.json',
|
|
19
|
+
resources: { items: { kind: 'database', key: databaseKey } },
|
|
20
|
+
collection: { database: 'items', titleProperty },
|
|
21
|
+
params: { item: { type: 'record', database: 'items', main: true, description: 'The record opened beside the list.' } },
|
|
22
|
+
shows: { items: { database: 'items', where: {}, open: 'item' } },
|
|
23
|
+
memory: { markdown: true, attachments: true, screenshot: false, snapshots: [] },
|
|
24
|
+
actions: { read: { tool: 'LOCAL_NOTIS_DATABASE_QUERY', inputs: { type: 'object', additionalProperties: false,
|
|
25
|
+
required: ['request'], properties: { request: { type: 'object' } } }, arguments: { database_id: { $asset: 'items' }, request: { $input: 'request' } } } },
|
|
26
|
+
};
|
|
27
|
+
writeFileSync(join(projectDir, 'spaces/collection/space.config.ts'), `import { defineSpace } from '@notis/sdk/config';\nexport default defineSpace(${JSON.stringify(definition, null, 2)});\n`);
|
|
28
|
+
writeFileSync(join(projectDir, 'spaces/collection/view.tsx'), COLLECTION_VIEW);
|
|
29
|
+
writeFileSync(join(projectDir, 'spaces/collection/verification.json'), JSON.stringify({
|
|
30
|
+
actions: {}, shown: { items: [{ params: {}, result: { rows: [], has_more: false, schema_revision: 1 } }] },
|
|
31
|
+
}, null, 2) + '\n');
|
|
32
|
+
return { projectDir, key: 'collection', databaseKey };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const COLLECTION_VIEW = `'use client';
|
|
36
|
+
import { DocumentPage, useNotis, useNotisNavigation, useNotisRuntime, usePrefetchRecord, useShown, useViewParams, ViewSkeleton } from '@notis/sdk';
|
|
37
|
+
import definition from './space.config';
|
|
38
|
+
import { Button } from '@/components/ui/button';
|
|
39
|
+
|
|
40
|
+
const copy = {
|
|
41
|
+
en: { empty: 'No records yet.', all: 'All records', untitled: 'Untitled', retry: 'Retry' },
|
|
42
|
+
fr: { empty: 'Aucun enregistrement pour le moment.', all: 'Tous les enregistrements', untitled: 'Sans titre', retry: 'Réessayer' },
|
|
43
|
+
};
|
|
44
|
+
export default function CollectionView() {
|
|
45
|
+
const runtime = useNotisRuntime();
|
|
46
|
+
const t = copy[runtime?.context.locale === 'fr' ? 'fr' : 'en'];
|
|
47
|
+
const { resource } = useNotis();
|
|
48
|
+
const navigation = useNotisNavigation();
|
|
49
|
+
const { params } = useViewParams<typeof definition>();
|
|
50
|
+
const { data, loading, error, refetch } = useShown<{ record_key: string; title: string | null }>('items');
|
|
51
|
+
const prefetch = usePrefetchRecord();
|
|
52
|
+
// A record is the main content: the host's document page (breadcrumb back here, title, properties, body).
|
|
53
|
+
if (params.item) return <DocumentPage recordKey={params.item} onSaved={refetch}
|
|
54
|
+
breadcrumb={[{ label: t.all, onSelect: () => resource && navigation.toSpace(resource.id) }]} />;
|
|
55
|
+
return <main className="notis-app-shell space-y-2">
|
|
56
|
+
{error ? <div role="alert">{error.message}<Button onClick={refetch}>{t.retry}</Button></div> : null}
|
|
57
|
+
{loading ? <ViewSkeleton variant="table" rows={5} /> : null}
|
|
58
|
+
{data?.rows.length === 0 ? <p className="text-sm text-muted-foreground">{t.empty}</p> : null}
|
|
59
|
+
{data?.rows.map(record => <Button key={record.record_key} variant="ghost" className="w-full justify-start truncate"
|
|
60
|
+
onPointerEnter={() => prefetch(record.record_key)} onFocus={() => prefetch(record.record_key)}
|
|
61
|
+
onClick={() => resource && navigation.toSpace(resource.id, { recordKey: record.record_key })}>
|
|
62
|
+
{record.title || t.untitled}
|
|
63
|
+
</Button>)}
|
|
64
|
+
</main>;
|
|
65
|
+
}
|
|
66
|
+
`;
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/** Refresh only pulled database creation declarations; never execute authored code. */
|
|
2
|
+
import { parse } from '@babel/parser';
|
|
3
|
+
import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { usageError } from './errors.js';
|
|
6
|
+
|
|
7
|
+
const nameOf = node => node?.type === 'Identifier' ? node.name : node?.type === 'StringLiteral' ? node.value : null;
|
|
8
|
+
|
|
9
|
+
export function refreshDatabaseStructures(projectDir, localKey, structures) {
|
|
10
|
+
return refreshSpaceAuthoring(projectDir, localKey, { databaseStructures: structures });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Exact current-host overrides for copied source. Archives remain byte-preserved. */
|
|
14
|
+
export function refreshSpaceAuthoring(projectDir, localKey, { databaseStructures, authoringOverrides } = {}) {
|
|
15
|
+
let rewritten = new Map();
|
|
16
|
+
if (Object.keys(databaseStructures || {}).length) rewritten = planSpaceAuthoring(projectDir, localKey, { databaseStructures }, rewritten);
|
|
17
|
+
if (authoringOverrides) rewritten = planSpaceAuthoring(projectDir, localKey, { authoringOverrides }, rewritten);
|
|
18
|
+
// Both stages must succeed before mutating even one downloaded file.
|
|
19
|
+
for (const [file, source] of rewritten) writeFileSync(file, source);
|
|
20
|
+
return [...rewritten.keys()].map(file => relative(realpathSync(projectDir), file).split(sep).join('/'));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function planSpaceAuthoring(projectDir, localKey, { databaseStructures: structures, authoringOverrides: overrides }, staged) {
|
|
24
|
+
const root = realpathSync(projectDir), modules = new Map();
|
|
25
|
+
const fail = () => usageError('The pulled database declaration is not statically addressable. Keep resources and create declarations as objects, local constants or relative imports; the downloaded source has not been rewritten.');
|
|
26
|
+
function load(file) {
|
|
27
|
+
file = realpathSync(file);
|
|
28
|
+
if (!file.startsWith(root + sep) || file.includes(`${sep}node_modules${sep}`)) throw fail();
|
|
29
|
+
if (modules.has(file)) return modules.get(file);
|
|
30
|
+
const source = staged.get(file) ?? readFileSync(file, 'utf8');
|
|
31
|
+
const ast = parse(source, { sourceType: 'module', plugins: ['typescript', 'jsx'] });
|
|
32
|
+
const unit = { file, source, ast, bindings: new Map(), exports: new Map() };
|
|
33
|
+
modules.set(file, unit);
|
|
34
|
+
for (const statement of ast.program.body) {
|
|
35
|
+
const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement;
|
|
36
|
+
if (declaration?.type === 'VariableDeclaration') {
|
|
37
|
+
for (const item of declaration.declarations) if (item.id.type === 'Identifier' && item.init) {
|
|
38
|
+
unit.bindings.set(item.id.name, { node: item.init, unit });
|
|
39
|
+
if (statement.type === 'ExportNamedDeclaration') unit.exports.set(item.id.name, { node: item.init, unit });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (statement.type === 'ImportDeclaration') {
|
|
43
|
+
for (const item of statement.specifiers) unit.bindings.set(item.local.name, {
|
|
44
|
+
imported: item.type === 'ImportDefaultSpecifier' ? 'default' : item.type === 'ImportNamespaceSpecifier' ? '*' : nameOf(item.imported),
|
|
45
|
+
from: statement.source.value, unit,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (statement.type === 'ExportDefaultDeclaration') unit.exports.set('default', { node: statement.declaration, unit });
|
|
49
|
+
if (statement.type === 'ExportNamedDeclaration') for (const item of statement.specifiers || []) {
|
|
50
|
+
unit.exports.set(nameOf(item.exported), statement.source
|
|
51
|
+
? { imported: nameOf(item.local), from: statement.source.value, unit }
|
|
52
|
+
: { node: item.local, unit });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return unit;
|
|
56
|
+
}
|
|
57
|
+
function imported(reference) {
|
|
58
|
+
if (!reference.from?.startsWith('.')) throw fail();
|
|
59
|
+
const base = resolve(dirname(reference.unit.file), reference.from);
|
|
60
|
+
const file = ['', '.ts', '.tsx', '.js', '.mjs', '/index.ts', '/index.js'].map(suffix => base + suffix).find(existsSync);
|
|
61
|
+
if (!file) throw fail();
|
|
62
|
+
return load(file);
|
|
63
|
+
}
|
|
64
|
+
function unwrap(reference, depth = 0) {
|
|
65
|
+
if (!reference || depth > 50) throw fail();
|
|
66
|
+
if (reference.from) {
|
|
67
|
+
if (reference.imported === '*') return { namespace: imported(reference) };
|
|
68
|
+
return unwrap(imported(reference).exports.get(reference.imported), depth + 1);
|
|
69
|
+
}
|
|
70
|
+
const { node, unit } = reference;
|
|
71
|
+
if (node.type === 'Identifier') return unwrap(unit.bindings.get(node.name), depth + 1);
|
|
72
|
+
if (['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'ParenthesizedExpression', 'TSTypeAssertion'].includes(node.type)) {
|
|
73
|
+
return unwrap({ node: node.expression, unit }, depth + 1);
|
|
74
|
+
}
|
|
75
|
+
if (node.type === 'CallExpression' && node.arguments.length === 1 && node.callee.type === 'Identifier') {
|
|
76
|
+
const helper = unit.bindings.get(node.callee.name);
|
|
77
|
+
if (helper?.from === '@notis/sdk/config' && ['defineSpace', 'defineSpaces'].includes(helper.imported)) {
|
|
78
|
+
return unwrap({ node: node.arguments[0], unit }, depth + 1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (node.type === 'MemberExpression' && (!node.computed || node.property.type === 'StringLiteral')) {
|
|
82
|
+
const object = unwrap({ node: node.object, unit }, depth + 1);
|
|
83
|
+
return object.namespace ? unwrap(object.namespace.exports.get(nameOf(node.property)), depth + 1)
|
|
84
|
+
: unwrap(property(object, nameOf(node.property), depth + 1), depth + 1);
|
|
85
|
+
}
|
|
86
|
+
return reference;
|
|
87
|
+
}
|
|
88
|
+
function property(reference, name, depth = 0) {
|
|
89
|
+
const object = unwrap(reference, depth + 1);
|
|
90
|
+
if (object.node?.type !== 'ObjectExpression') throw fail();
|
|
91
|
+
// Later properties/spreads win, exactly as the authored object does.
|
|
92
|
+
for (const entry of [...object.node.properties].reverse()) {
|
|
93
|
+
if (entry.type === 'SpreadElement') {
|
|
94
|
+
const found = property({ node: entry.argument, unit: object.unit }, name, depth + 1);
|
|
95
|
+
if (found) return found;
|
|
96
|
+
} else if (entry.type === 'ObjectProperty' && !entry.computed && nameOf(entry.key) === name) {
|
|
97
|
+
return { node: entry.value, unit: object.unit };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
function scalar(reference) {
|
|
103
|
+
const value = unwrap(reference);
|
|
104
|
+
if (value.node?.type !== 'StringLiteral') throw fail();
|
|
105
|
+
return value.node.value;
|
|
106
|
+
}
|
|
107
|
+
const indexFile = ['notis.config.ts', 'notis.config.js', 'notis.config.mjs'].map(file => join(root, file)).find(existsSync);
|
|
108
|
+
if (!indexFile) throw fail();
|
|
109
|
+
const index = load(indexFile);
|
|
110
|
+
const workspace = index.exports.get('default');
|
|
111
|
+
const definition = scalar(property(property(property(workspace, 'spaces'), localKey), 'definition'));
|
|
112
|
+
const unit = load(resolve(root, definition));
|
|
113
|
+
const resources = property(unit.exports.get('default'), 'resources');
|
|
114
|
+
const edits = new Map();
|
|
115
|
+
for (const [alias, structure] of Object.entries(structures || {})) {
|
|
116
|
+
if (!structure || typeof structure.name !== 'string' || !structure.schema || typeof structure.schema !== 'object' || Array.isArray(structure.schema)) throw usageError('The server returned an incomplete live database structure.');
|
|
117
|
+
const resource = property(resources, alias);
|
|
118
|
+
if (scalar(property(resource, 'kind')) !== 'database') throw fail();
|
|
119
|
+
const create = property(resource, 'create');
|
|
120
|
+
if (!create || !Number.isInteger(create.node.start) || !Number.isInteger(create.node.end)) throw fail();
|
|
121
|
+
const list = edits.get(create.unit.file) || [];
|
|
122
|
+
// Inline declarations are edited in place. Constants retain their shared
|
|
123
|
+
// helper and get one explicit local override, not another wrapper per pull.
|
|
124
|
+
let node = create.node;
|
|
125
|
+
while (['TSAsExpression', 'TSSatisfiesExpression', 'ParenthesizedExpression'].includes(node.type)) node = node.expression;
|
|
126
|
+
if (node.type === 'ObjectExpression') {
|
|
127
|
+
const additions = [];
|
|
128
|
+
for (const [name, value] of [['name', structure.name], ['schema', structure.schema]]) {
|
|
129
|
+
const field = [...node.properties].reverse().find(item => item.type === 'ObjectProperty' && !item.computed && nameOf(item.key) === name);
|
|
130
|
+
const replacement = JSON.stringify(value, null, 2);
|
|
131
|
+
if (field && !field.shorthand) list.push({ start: field.value.start, end: field.value.end, replacement });
|
|
132
|
+
else if (field) list.push({ start: field.start, end: field.end, replacement: `${name}: ${replacement}` });
|
|
133
|
+
else additions.push(`${name}: ${replacement}`);
|
|
134
|
+
}
|
|
135
|
+
if (additions.length) list.push({ start: node.end - 1, end: node.end - 1,
|
|
136
|
+
replacement: `${node.properties.length && !create.unit.source.slice(node.properties.at(-1).end, node.end - 1).includes(',') ? ',' : ''} ${additions.join(', ')} ` });
|
|
137
|
+
} else {
|
|
138
|
+
const original = create.unit.source.slice(create.node.start, create.node.end);
|
|
139
|
+
list.push({ start: create.node.start, end: create.node.end,
|
|
140
|
+
replacement: `({ ...(${original}), name: ${JSON.stringify(structure.name)}, schema: ${JSON.stringify(structure.schema, null, 2)} })` });
|
|
141
|
+
}
|
|
142
|
+
edits.set(create.unit.file, list);
|
|
143
|
+
}
|
|
144
|
+
if (overrides) {
|
|
145
|
+
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)
|
|
146
|
+
|| Object.keys(overrides).some(key => !['record_params', 'resource_keys'].includes(key))) throw fail();
|
|
147
|
+
const recordParams = overrides.record_params || {}, resourceKeys = overrides.resource_keys || {};
|
|
148
|
+
if ([recordParams, resourceKeys].some(value => !value || typeof value !== 'object' || Array.isArray(value))) throw fail();
|
|
149
|
+
// Pin authoring to this selected definition. Shared imports/constants are
|
|
150
|
+
// overridden at their local use site, never rewritten for sibling Spaces.
|
|
151
|
+
const rootDefinition = unwrap(unit.exports.get('default'));
|
|
152
|
+
if (rootDefinition.unit !== unit || rootDefinition.node.type !== 'ObjectExpression') throw fail();
|
|
153
|
+
const rawField = name => {
|
|
154
|
+
const field = [...rootDefinition.node.properties].reverse().find(item => item.type === 'ObjectProperty'
|
|
155
|
+
&& !item.computed && nameOf(item.key) === name);
|
|
156
|
+
if (!field || field.shorthand) throw fail();
|
|
157
|
+
return { node: field.value, unit };
|
|
158
|
+
};
|
|
159
|
+
const text = reference => reference.unit.source.slice(reference.node.start, reference.node.end);
|
|
160
|
+
const patchObject = (reference, fields) => {
|
|
161
|
+
let node = reference.node;
|
|
162
|
+
while (['TSAsExpression', 'TSSatisfiesExpression', 'ParenthesizedExpression'].includes(node.type)) node = node.expression;
|
|
163
|
+
if (node.type !== 'ObjectExpression') {
|
|
164
|
+
return `({ ...(${text(reference)}), ${Object.entries(fields).map(([key, value]) => `${JSON.stringify(key)}: ${value}`).join(', ')} })`;
|
|
165
|
+
}
|
|
166
|
+
let source = text(reference), changes = [], additions = [];
|
|
167
|
+
for (const [key, replacement] of Object.entries(fields)) {
|
|
168
|
+
const field = [...node.properties].reverse().find(item => item.type === 'ObjectProperty' && !item.computed && nameOf(item.key) === key);
|
|
169
|
+
if (field) changes.push({ start: (field.shorthand ? field.start : field.value.start) - reference.node.start,
|
|
170
|
+
end: (field.shorthand ? field.end : field.value.end) - reference.node.start,
|
|
171
|
+
replacement: field.shorthand ? `${JSON.stringify(key)}: ${replacement}` : replacement });
|
|
172
|
+
else additions.push(`${JSON.stringify(key)}: ${replacement}`);
|
|
173
|
+
}
|
|
174
|
+
if (additions.length) changes.push({ start: node.end - 1 - reference.node.start, end: node.end - 1 - reference.node.start,
|
|
175
|
+
replacement: `${node.properties.length && !reference.unit.source.slice(node.properties.at(-1).end, node.end - 1).includes(',') ? ',' : ''} ${additions.join(', ')} ` });
|
|
176
|
+
for (const change of changes.sort((a, b) => b.start - a.start)) source = source.slice(0, change.start) + change.replacement + source.slice(change.end);
|
|
177
|
+
return source;
|
|
178
|
+
};
|
|
179
|
+
const queue = (reference, replacement) => {
|
|
180
|
+
const list = edits.get(reference.unit.file) || [];
|
|
181
|
+
list.push({ start: reference.node.start, end: reference.node.end, replacement }); edits.set(reference.unit.file, list);
|
|
182
|
+
};
|
|
183
|
+
for (const [fieldName, requests] of [['params', recordParams], ['resources', resourceKeys]]) {
|
|
184
|
+
if (!Object.keys(requests).length) continue;
|
|
185
|
+
const reference = rawField(fieldName), object = unwrap(reference), replacements = {};
|
|
186
|
+
for (const [name, expected] of Object.entries(requests)) {
|
|
187
|
+
if (!expected || typeof expected !== 'object' || Array.isArray(expected)) throw fail();
|
|
188
|
+
const current = property(reference, name);
|
|
189
|
+
if (!current) throw fail();
|
|
190
|
+
const source = object.node.type === 'ObjectExpression' && object.unit === unit && current.unit === unit ? text(current)
|
|
191
|
+
: `(${text(reference)})[${JSON.stringify(name)}]`;
|
|
192
|
+
if (fieldName === 'params') {
|
|
193
|
+
if (Object.keys(expected).some(key => !['database', 'main'].includes(key)) || typeof expected.main !== 'boolean'
|
|
194
|
+
|| scalar(property(current, 'type')) !== 'record' || scalar(property(current, 'database')) !== expected.database) throw fail();
|
|
195
|
+
replacements[name] = current.unit === unit && object.node.type === 'ObjectExpression'
|
|
196
|
+
? patchObject(current, { main: JSON.stringify(expected.main) }) : `({ ...(${source}), main: ${JSON.stringify(expected.main)} })`;
|
|
197
|
+
} else {
|
|
198
|
+
if (Object.keys(expected).some(key => !['kind', 'key'].includes(key)) || typeof expected.key !== 'string' || !expected.key
|
|
199
|
+
|| scalar(property(current, 'kind')) !== expected.kind) throw fail();
|
|
200
|
+
const create = property(current, 'create'), key = property(current, 'key');
|
|
201
|
+
if (create) {
|
|
202
|
+
// Copied source archives retain their original create declaration.
|
|
203
|
+
// A reused database (or one whose main moved) is now an existing
|
|
204
|
+
// binding: replace only this definition's resource, never a shared
|
|
205
|
+
// imported creation or a sibling view's source.
|
|
206
|
+
if (expected.kind !== 'database' || key) throw fail();
|
|
207
|
+
replacements[name] = JSON.stringify(expected);
|
|
208
|
+
} else {
|
|
209
|
+
if (!key) throw fail();
|
|
210
|
+
replacements[name] = current.unit === unit && object.node.type === 'ObjectExpression'
|
|
211
|
+
? patchObject(current, { key: JSON.stringify(expected.key) }) : `({ ...(${source}), key: ${JSON.stringify(expected.key)} })`;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
queue(reference, patchObject(reference, replacements));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const rewritten = new Map(staged);
|
|
219
|
+
for (const [file, list] of edits) {
|
|
220
|
+
let source = modules.get(file).source, previous = source.length;
|
|
221
|
+
for (const edit of list.sort((a, b) => b.start - a.start)) {
|
|
222
|
+
if (edit.end > previous) throw fail();
|
|
223
|
+
source = source.slice(0, edit.start) + edit.replacement + source.slice(edit.end); previous = edit.start;
|
|
224
|
+
}
|
|
225
|
+
parse(source, { sourceType: 'module', plugins: ['typescript', 'jsx'] });
|
|
226
|
+
rewritten.set(file, source);
|
|
227
|
+
}
|
|
228
|
+
return rewritten;
|
|
229
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Pure fixture adapter for the exact CLI/service renderer; no network client. */
|
|
2
|
+
import { createSpaceFixtureEngine, spaceHarnessSnapshot } from './space-test-server.js';
|
|
3
|
+
import { usageError } from './errors.js';
|
|
4
|
+
|
|
5
|
+
const object = value => value && typeof value === 'object' && !Array.isArray(value);
|
|
6
|
+
const exact = (value, keys) => object(value) && Object.keys(value).sort().join(',') === [...keys].sort().join(',');
|
|
7
|
+
|
|
8
|
+
export function createSpaceFixtureBroker({ release, capabilities, width, outputs = ['markdown', 'screenshot'],
|
|
9
|
+
authorizationMode = 'authorize_missing', unavailableActions = [] }) {
|
|
10
|
+
const engine = createSpaceFixtureEngine(release);
|
|
11
|
+
const frozen = spaceHarnessSnapshot(release, capabilities, engine, { authorizationMode, unavailableActions });
|
|
12
|
+
const calls = [];
|
|
13
|
+
const params = structuredClone(engine.context.params || {});
|
|
14
|
+
const value = { ...frozen, url: `https://app.notis.ai/verification/${encodeURIComponent(release.manifest.local_key)}`,
|
|
15
|
+
view: { space_id: frozen.descriptor.resource.id, name: release.manifest.name, revision: 1 },
|
|
16
|
+
locale: engine.context.locale === 'fr' ? 'fr' : 'en', params,
|
|
17
|
+
markdownExport: release.manifest.markdown?.export_name || null, render_options: { outputs, width } };
|
|
18
|
+
return {
|
|
19
|
+
fixtureDigest: engine.digest,
|
|
20
|
+
calls,
|
|
21
|
+
async snapshot() { return structuredClone(value); },
|
|
22
|
+
async read(request) {
|
|
23
|
+
let result;
|
|
24
|
+
const log = { operation: request?.operation, ...(request?.action_id ? { actionId: request.action_id } : {}), ok: false };
|
|
25
|
+
calls.push(log);
|
|
26
|
+
if (calls.length > 256) throw usageError('The offline fixture read limit was reached.');
|
|
27
|
+
if (request?.operation === 'action' && exact(request, ['operation', 'action_id', 'inputs', 'request_id'])) {
|
|
28
|
+
if (frozen.descriptor.space.actions[request.action_id]?.readOnly !== true) throw usageError('Offline verification never runs write actions.');
|
|
29
|
+
result = engine.execute(request.action_id, request.inputs);
|
|
30
|
+
} else if (request?.operation === 'shown' && exact(request, ['operation', 'name', 'params', 'options'])) {
|
|
31
|
+
result = engine.executeShown(request.name, request.params, request.options);
|
|
32
|
+
} else if (request?.operation === 'viewer_read' && exact(request, ['operation', 'name', 'input'])) {
|
|
33
|
+
result = engine.executeViewerRead(request.name, request.input);
|
|
34
|
+
} else if (request?.operation === 'document_body' && exact(request, ['operation', 'record_key', 'binding', 'read_action'])) {
|
|
35
|
+
if (frozen.descriptor.space.actions[request.read_action]?.readOnly !== true) throw usageError('This document read action is unavailable.');
|
|
36
|
+
result = engine.executeBody({ operation: 'read', recordKey: request.record_key, binding: request.binding, readAction: request.read_action });
|
|
37
|
+
} else if (['record', 'html', 'report'].includes(request?.operation) && exact(request, ['operation', 'record_key'])) {
|
|
38
|
+
result = engine.executeRecordView(request.operation, request.record_key);
|
|
39
|
+
} else {
|
|
40
|
+
throw usageError('Only declared read-only synthetic fixtures are available.');
|
|
41
|
+
}
|
|
42
|
+
log.ok = true;
|
|
43
|
+
return result;
|
|
44
|
+
},
|
|
45
|
+
async finish() {
|
|
46
|
+
if (calls.some(value => !value.ok)) throw usageError('A synthetic fixture read failed.');
|
|
47
|
+
return { valid: true };
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|