@notis_ai/cli 0.2.0-beta.16.1 → 0.2.0-beta.160.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 +433 -133
- package/config/notis_app_boundary_rules.json +50 -0
- package/config/notis_app_design_rules.json +135 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +18672 -0
- package/dist/base-skills/notis-apps/SKILL.md +70 -0
- package/dist/base-skills/notis-apps/references/architecture.md +164 -0
- package/dist/base-skills/notis-apps/references/context.md +81 -0
- package/dist/base-skills/notis-apps/references/design.md +165 -0
- package/dist/base-skills/notis-apps/references/reading.md +89 -0
- package/dist/base-skills/notis-apps/references/release.md +99 -0
- package/dist/base-skills/notis-apps/references/sdk.md +62 -0
- package/dist/base-skills/notis-apps/references/troubleshooting.md +23 -0
- package/dist/base-skills/notis-cli/SKILL.md +140 -0
- package/dist/base-skills/notis-cli/references/app-delivery.md +18 -0
- package/dist/base-skills/notis-cli/references/native-databases.md +20 -0
- package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
- package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
- package/dist/base-skills/notis-query/SKILL.md +67 -0
- package/dist/base-skills/notis-query/references/database-discovery.md +59 -0
- package/dist/base-skills/notis-query/references/documents.md +50 -0
- package/dist/base-skills/notis-query/references/query.md +543 -0
- package/dist/skill-sync/index.js +1626 -0
- package/dist/skill-sync/index.js.map +7 -0
- package/dist/skill-sync-worker.mjs +2990 -0
- package/package.json +16 -6
- package/skills/notis-apps/cli.md +313 -0
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
- package/skills/notis-onboarding/BRIEF.md +129 -0
- package/skills/notis-query/cli.md +39 -0
- package/src/agent-hook-entry.js +5 -0
- package/src/cli.js +294 -25
- package/src/command-specs/agents.js +392 -0
- package/src/command-specs/apps.js +1470 -202
- package/src/command-specs/auth.js +114 -137
- package/src/command-specs/diagnostics.js +716 -0
- package/src/command-specs/handover.js +374 -0
- package/src/command-specs/helpers.js +84 -82
- package/src/command-specs/index.js +25 -6
- package/src/command-specs/meta.js +150 -18
- package/src/command-specs/onboarding.js +290 -0
- package/src/command-specs/profile.js +358 -0
- package/src/command-specs/reports.js +86 -0
- package/src/command-specs/skills.js +75 -0
- package/src/command-specs/smoke.js +386 -0
- package/src/command-specs/tools.js +455 -139
- package/src/runtime/agent-browser.js +632 -0
- package/src/runtime/agent-memory-state.js +126 -0
- package/src/runtime/agent-setup.js +383 -0
- package/src/runtime/app-boundary-validator.js +404 -0
- package/src/runtime/app-changelog.js +79 -0
- package/src/runtime/app-platform.js +2633 -210
- package/src/runtime/app-registry-scaffolds.js +367 -0
- package/src/runtime/app-test-server.js +292 -0
- package/src/runtime/assets/store-screenshot-dark.png +0 -0
- package/src/runtime/auth-recovery.js +110 -0
- package/src/runtime/base-skills.d.ts +20 -0
- package/src/runtime/base-skills.js +167 -0
- package/src/runtime/channel.js +133 -0
- package/src/runtime/delegated-context.js +68 -0
- package/src/runtime/errors.js +1 -0
- package/src/runtime/git.js +233 -0
- package/src/runtime/login-listener.js +15 -0
- package/src/runtime/oauth.js +2622 -0
- package/src/runtime/output.js +37 -5
- package/src/runtime/ports.js +31 -0
- package/src/runtime/profiles.js +906 -55
- package/src/runtime/skill-sync/cloud-client.ts +99 -0
- package/src/runtime/skill-sync/index.ts +697 -0
- package/src/runtime/skill-sync/local-scanner.ts +1046 -0
- package/src/runtime/skill-sync/symlink-manager.ts +433 -0
- package/src/runtime/skill-sync/sync-plan.ts +22 -0
- package/src/runtime/skill-sync/types.ts +110 -0
- package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
- package/src/runtime/skill-sync-service.js +109 -0
- package/src/runtime/store-screenshot.js +143 -0
- package/src/runtime/sync-skills.d.ts +37 -0
- package/src/runtime/sync-skills.js +231 -0
- package/src/runtime/telemetry.js +92 -0
- package/src/runtime/transport.js +324 -45
- package/src/skill-sync-worker-entry.js +2 -0
- package/src/skill-sync-worker.js +50 -0
- package/template/.harness/index.html.tmpl +430 -0
- package/template/CHANGELOG.md +5 -0
- package/template/app/layout.tsx +5 -2
- package/template/app/page.tsx +49 -42
- package/template/components/page-heading.tsx +23 -0
- package/template/components/ui/badge.tsx +7 -4
- package/template/components/ui/card.tsx +24 -11
- package/template/components/ui/native-select.tsx +24 -0
- package/template/notis.config.ts +24 -6
- package/template/package-lock.json +4137 -0
- package/template/package.json +5 -5
- package/template/packages/{notis-sdk → sdk}/package.json +13 -3
- package/template/packages/sdk/src/agentContext.ts +36 -0
- package/template/packages/sdk/src/components/DocumentEditor.tsx +103 -0
- package/template/packages/sdk/src/components/Markdown.tsx +60 -0
- package/template/packages/sdk/src/components/MarkdownEditor.tsx +121 -0
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +285 -0
- package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +97 -0
- package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/template/packages/sdk/src/components/NotisCommentBoundary.tsx +172 -0
- package/template/packages/sdk/src/components/NotisSelectionBoundary.tsx +59 -0
- package/template/packages/sdk/src/components/ShortcutHints.tsx +56 -0
- package/template/packages/sdk/src/components/Skeleton.tsx +24 -0
- package/template/packages/sdk/src/config.ts +257 -0
- package/template/packages/sdk/src/documents.ts +256 -0
- package/template/packages/sdk/src/hooks/useActiveResource.ts +19 -0
- package/template/packages/sdk/src/hooks/useAgentContext.ts +23 -0
- package/template/packages/sdk/src/hooks/useCloudComputer.ts +64 -0
- package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +836 -0
- package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +49 -0
- package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/template/packages/sdk/src/hooks/useDocument.ts +43 -0
- package/template/packages/sdk/src/hooks/useDocuments.ts +84 -0
- package/template/packages/sdk/src/hooks/useHandover.ts +78 -0
- package/template/packages/sdk/src/hooks/useLongPressSelection.ts +79 -0
- package/template/packages/sdk/src/hooks/useMultiSelect.ts +95 -0
- package/template/packages/{notis-sdk → sdk}/src/hooks/useNotis.ts +10 -4
- package/template/packages/{notis-sdk → sdk}/src/hooks/useNotisNavigation.ts +11 -8
- package/template/packages/sdk/src/hooks/useQuery.ts +71 -0
- package/template/packages/sdk/src/hooks/useTool.ts +65 -0
- package/template/packages/sdk/src/hooks/useToolQuery.ts +12 -0
- package/template/packages/sdk/src/hooks/useTopBarSearch.ts +81 -0
- package/template/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
- package/template/packages/sdk/src/index.ts +161 -0
- package/template/packages/sdk/src/interactions/actions.ts +59 -0
- package/template/packages/sdk/src/interactions/shortcuts.tsx +694 -0
- package/template/packages/sdk/src/interactions/visibility.ts +13 -0
- package/template/packages/sdk/src/interactions.ts +45 -0
- package/template/packages/sdk/src/provider.tsx +44 -0
- package/template/packages/sdk/src/queryCache.ts +170 -0
- package/template/packages/sdk/src/runtime.ts +451 -0
- package/template/packages/sdk/src/styles.css +213 -0
- package/template/packages/sdk/src/tailwind.ts +56 -0
- package/template/packages/{notis-sdk → sdk}/src/vite.ts +5 -1
- package/template/tailwind.config.ts +1 -0
- package/src/command-specs/db.js +0 -163
- package/src/runtime/app-preview-server.js +0 -312
- package/template/packages/notis-sdk/src/config.ts +0 -48
- package/template/packages/notis-sdk/src/helpers.ts +0 -131
- package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
- package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
- package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
- package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
- package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
- package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
- package/template/packages/notis-sdk/src/index.ts +0 -47
- package/template/packages/notis-sdk/src/provider.tsx +0 -44
- package/template/packages/notis-sdk/src/runtime.ts +0 -159
- package/template/packages/notis-sdk/src/styles.css +0 -123
- /package/template/packages/{notis-sdk → sdk}/src/hooks/useBackend.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/hooks/useTools.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/src/ui.ts +0 -0
- /package/template/packages/{notis-sdk → sdk}/tsconfig.json +0 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store-backed scaffold catalog for the Notis CLI.
|
|
3
|
+
*
|
|
4
|
+
* Every app published to the public Store lives with its full source in the
|
|
5
|
+
* public registry repository (github.com/mindtheflo/notis-apps, one app per
|
|
6
|
+
* `apps/<slug>/` directory). That repository IS the scaffold catalog:
|
|
7
|
+
* `apps scaffolds list` reads the published listings and `apps init --from
|
|
8
|
+
* <slug>` downloads the listed app's source. Publishing an app automatically
|
|
9
|
+
* makes it a scaffold — the CLI bundles nothing besides the bare template.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { usageError } from './errors.js';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_REGISTRY_REPO = 'mindtheflo/notis-apps';
|
|
19
|
+
const DEFAULT_REGISTRY_REF = 'main';
|
|
20
|
+
// How many registry files to download at once when materializing a scaffold.
|
|
21
|
+
const DOWNLOAD_CONCURRENCY = 4;
|
|
22
|
+
const MAX_SCAFFOLD_FILES = 2_000;
|
|
23
|
+
const MAX_SCAFFOLD_FILE_BYTES = 5 * 1024 * 1024;
|
|
24
|
+
const MAX_SCAFFOLD_TOTAL_BYTES = 50 * 1024 * 1024;
|
|
25
|
+
const MAX_CATALOG_ENTRIES = 1_000;
|
|
26
|
+
const MAX_REGISTRY_COMMIT_BYTES = 2 * 1024 * 1024;
|
|
27
|
+
const MAX_REGISTRY_API_BYTES = 10 * 1024 * 1024;
|
|
28
|
+
const MAX_CATALOG_METADATA_BYTES = 1024 * 1024;
|
|
29
|
+
const MAX_CATALOG_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
30
|
+
// Registry bookkeeping that must never enter a new project: the listing
|
|
31
|
+
// descriptor and the rendered Store gallery describe the published app, and a
|
|
32
|
+
// scaffold copy starts a new identity.
|
|
33
|
+
const REGISTRY_ARTIFACT = /^(notis-listing\.json$|screenshots(\/|$))/;
|
|
34
|
+
// Files the scaffold copy step would drop anyway — skip the download.
|
|
35
|
+
const SKIPPED_SOURCE = /(^|\/)(node_modules|\.notis|\.git|dist|coverage|\.next|\.turbo)(\/|$)|(^|\/)\.env|\.(test|spec)\.[cm]?[jt]sx?$/;
|
|
36
|
+
|
|
37
|
+
function registryRepo() {
|
|
38
|
+
return process.env.NOTIS_APP_REGISTRY_REPO || DEFAULT_REGISTRY_REPO;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function registryRef() {
|
|
42
|
+
return process.env.NOTIS_APP_REGISTRY_REF || DEFAULT_REGISTRY_REF;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Local registry checkout override (`<dir>/apps/<slug>/…`). Used by tests and
|
|
47
|
+
* by offline development against a clone of the registry repository.
|
|
48
|
+
*/
|
|
49
|
+
function localRegistryDir() {
|
|
50
|
+
return process.env.NOTIS_APP_REGISTRY_DIR || null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function scaffoldRegistryLabel() {
|
|
54
|
+
return localRegistryDir() || `github.com/${registryRepo()}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function fetchRegistry(url, { binary = false, maxBytes = null } = {}) {
|
|
58
|
+
let response;
|
|
59
|
+
try {
|
|
60
|
+
response = await fetch(url, { headers: { 'user-agent': 'notis-cli' } });
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw usageError(
|
|
63
|
+
`Could not reach the Notis app registry (${scaffoldRegistryLabel()}): ${error.message}. ` +
|
|
64
|
+
'Scaffolds are downloaded from published Store apps and need network access.',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
throw usageError(`Notis app registry request failed (${response.status}) for ${url}.`);
|
|
69
|
+
}
|
|
70
|
+
const contentLength = Number(response.headers?.get?.('content-length'));
|
|
71
|
+
if (maxBytes && Number.isFinite(contentLength) && contentLength > maxBytes) {
|
|
72
|
+
throw usageError(`Notis app registry response exceeds the ${maxBytes}-byte limit.`);
|
|
73
|
+
}
|
|
74
|
+
let data;
|
|
75
|
+
if (maxBytes && response.body?.getReader) {
|
|
76
|
+
const reader = response.body.getReader();
|
|
77
|
+
const chunks = [];
|
|
78
|
+
let total = 0;
|
|
79
|
+
while (true) {
|
|
80
|
+
const { done, value } = await reader.read();
|
|
81
|
+
if (done) break;
|
|
82
|
+
const chunk = Buffer.from(value);
|
|
83
|
+
total += chunk.length;
|
|
84
|
+
if (total > maxBytes) {
|
|
85
|
+
await reader.cancel();
|
|
86
|
+
throw usageError(`Notis app registry response exceeds the ${maxBytes}-byte limit.`);
|
|
87
|
+
}
|
|
88
|
+
chunks.push(chunk);
|
|
89
|
+
}
|
|
90
|
+
data = Buffer.concat(chunks, total);
|
|
91
|
+
} else {
|
|
92
|
+
data = Buffer.from(await response.arrayBuffer());
|
|
93
|
+
}
|
|
94
|
+
if (maxBytes && data.length > maxBytes) {
|
|
95
|
+
throw usageError(`Notis app registry response exceeds the ${maxBytes}-byte limit.`);
|
|
96
|
+
}
|
|
97
|
+
return binary ? data : data.toString('utf8');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function encodedRegistryRepoPath() {
|
|
101
|
+
const parts = registryRepo().split('/');
|
|
102
|
+
if (
|
|
103
|
+
parts.length !== 2
|
|
104
|
+
|| parts.some((part) => !part || !/^[A-Za-z0-9_.-]+$/.test(part))
|
|
105
|
+
) {
|
|
106
|
+
throw usageError('Notis app registry repository must use the owner/repository form.');
|
|
107
|
+
}
|
|
108
|
+
return parts.map((part) => encodeURIComponent(part)).join('/');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function resolveRegistryCommit() {
|
|
112
|
+
const url = `https://api.github.com/repos/${encodedRegistryRepoPath()}/commits/${encodeURIComponent(registryRef())}`;
|
|
113
|
+
// GitHub's commit-detail response includes changed-file metadata and can be
|
|
114
|
+
// substantially larger than the SHA we read from it. Keep a bounded limit,
|
|
115
|
+
// but allow normal registry commits with a long file list.
|
|
116
|
+
const payload = JSON.parse(await fetchRegistry(url, { maxBytes: MAX_REGISTRY_COMMIT_BYTES }));
|
|
117
|
+
const commitSha = typeof payload?.sha === 'string' ? payload.sha.trim() : '';
|
|
118
|
+
if (!/^[0-9a-f]{40}$/i.test(commitSha)) {
|
|
119
|
+
throw usageError(
|
|
120
|
+
`Notis app registry returned no immutable commit for ${registryRepo()}@${registryRef()}.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return commitSha;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function fetchRegistryTree(commitSha) {
|
|
127
|
+
const url = `https://api.github.com/repos/${encodedRegistryRepoPath()}/git/trees/${commitSha}?recursive=1`;
|
|
128
|
+
const payload = JSON.parse(await fetchRegistry(url, { maxBytes: MAX_REGISTRY_API_BYTES }));
|
|
129
|
+
if (!Array.isArray(payload?.tree)) {
|
|
130
|
+
throw usageError(`Notis app registry returned no file tree for ${registryRepo()}@${commitSha}.`);
|
|
131
|
+
}
|
|
132
|
+
if (payload.truncated === true) {
|
|
133
|
+
throw usageError(
|
|
134
|
+
`Notis app registry tree was truncated for ${registryRepo()}@${commitSha}; refusing an incomplete scaffold catalog.`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return payload.tree
|
|
138
|
+
.filter((entry) => entry?.type === 'blob' && typeof entry.path === 'string')
|
|
139
|
+
.map((entry) => ({ path: entry.path, size: Number(entry.size) }));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function fetchRegistryFile(path, commitSha, options) {
|
|
143
|
+
const encodedPath = String(path).split('/').map((part) => encodeURIComponent(part)).join('/');
|
|
144
|
+
return fetchRegistry(
|
|
145
|
+
`https://raw.githubusercontent.com/${encodedRegistryRepoPath()}/${commitSha}/${encodedPath}`,
|
|
146
|
+
options,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function mapWithConcurrency(values, concurrency, mapper) {
|
|
151
|
+
const results = new Array(values.length);
|
|
152
|
+
let cursor = 0;
|
|
153
|
+
async function worker() {
|
|
154
|
+
while (cursor < values.length) {
|
|
155
|
+
const index = cursor;
|
|
156
|
+
cursor += 1;
|
|
157
|
+
results[index] = await mapper(values[index], index);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, worker));
|
|
161
|
+
return results;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function assertSafeScaffoldSlug(value) {
|
|
165
|
+
const slug = String(value || '');
|
|
166
|
+
if (!slug || slug === '.' || slug === '..' || /[\\/\0]/.test(slug)) {
|
|
167
|
+
throw usageError(`Unsafe scaffold slug: ${value}`);
|
|
168
|
+
}
|
|
169
|
+
return slug;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Resolve a registry-owned relative path without allowing either slash style,
|
|
174
|
+
* drive-letter paths, or dot segments to escape the temporary scaffold root.
|
|
175
|
+
* Exported so the platform-independent boundary contract can be unit tested on
|
|
176
|
+
* every host, including macOS CI for the Windows backslash case.
|
|
177
|
+
*/
|
|
178
|
+
export function resolveScaffoldTargetPath(tempRoot, registryRelativePath) {
|
|
179
|
+
const raw = String(registryRelativePath || '');
|
|
180
|
+
const normalized = raw.replace(/\\/g, '/');
|
|
181
|
+
const parts = normalized.split('/');
|
|
182
|
+
if (
|
|
183
|
+
!normalized
|
|
184
|
+
|| normalized.includes('\0')
|
|
185
|
+
|| normalized.startsWith('/')
|
|
186
|
+
|| /^[A-Za-z]:\//.test(normalized)
|
|
187
|
+
|| parts.some((part) => !part || part === '.' || part === '..')
|
|
188
|
+
) {
|
|
189
|
+
throw usageError(`Refusing unsafe path from Notis app registry: ${registryRelativePath}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const root = resolve(tempRoot);
|
|
193
|
+
const target = resolve(root, ...parts);
|
|
194
|
+
const fromRoot = relative(root, target);
|
|
195
|
+
if (!fromRoot || fromRoot === '..' || fromRoot.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || isAbsolute(fromRoot)) {
|
|
196
|
+
throw usageError(`Refusing path outside scaffold directory: ${registryRelativePath}`);
|
|
197
|
+
}
|
|
198
|
+
return target;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function readTsStringProperty(source, propertyName) {
|
|
202
|
+
const match = source.match(new RegExp(`\\b${propertyName}\\s*:\\s*(['"\`])([\\s\\S]*?)\\1`));
|
|
203
|
+
return match ? match[2] : null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function catalogEntry(slug, listing, configSource) {
|
|
207
|
+
const description = listing?.description || readTsStringProperty(configSource, 'description') || '';
|
|
208
|
+
return {
|
|
209
|
+
slug,
|
|
210
|
+
name: listing?.name || readTsStringProperty(configSource, 'title') || slug,
|
|
211
|
+
description,
|
|
212
|
+
icon: readTsStringProperty(configSource, 'icon') || 'phosphor:squares-four',
|
|
213
|
+
categories: Array.isArray(listing?.categories) ? listing.categories.filter(Boolean) : [],
|
|
214
|
+
tagline: listing?.tagline || readTsStringProperty(configSource, 'tagline') || description,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function readOptionalFile(path) {
|
|
219
|
+
return existsSync(path) ? readFileSync(path, 'utf-8') : '';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function loadLocalCatalog(registryDir) {
|
|
223
|
+
const appsDir = join(registryDir, 'apps');
|
|
224
|
+
if (!existsSync(appsDir)) {
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
const scaffolds = [];
|
|
228
|
+
for (const entry of readdirSync(appsDir, { withFileTypes: true })) {
|
|
229
|
+
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name.startsWith('_')) {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const appDir = join(appsDir, entry.name);
|
|
233
|
+
if (!existsSync(join(appDir, 'notis.config.ts'))) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const listingPath = join(appDir, 'notis-listing.json');
|
|
237
|
+
const listing = existsSync(listingPath) ? JSON.parse(readFileSync(listingPath, 'utf-8')) : null;
|
|
238
|
+
scaffolds.push(catalogEntry(entry.name, listing, readOptionalFile(join(appDir, 'notis.config.ts'))));
|
|
239
|
+
}
|
|
240
|
+
return scaffolds.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* List every published Store app as a scaffold.
|
|
245
|
+
*/
|
|
246
|
+
export async function loadScaffoldCatalog() {
|
|
247
|
+
const registryDir = localRegistryDir();
|
|
248
|
+
if (registryDir) {
|
|
249
|
+
return loadLocalCatalog(registryDir);
|
|
250
|
+
}
|
|
251
|
+
const commitSha = await resolveRegistryCommit();
|
|
252
|
+
const tree = (await fetchRegistryTree(commitSha)).map((entry) => entry.path);
|
|
253
|
+
const slugs = [...new Set(
|
|
254
|
+
tree
|
|
255
|
+
.filter((path) => /^apps\/[^/]+\//.test(path))
|
|
256
|
+
.map((path) => path.split('/')[1]),
|
|
257
|
+
)].filter((slug) => tree.includes(`apps/${slug}/notis.config.ts`));
|
|
258
|
+
if (slugs.length > MAX_CATALOG_ENTRIES) {
|
|
259
|
+
throw usageError(`Notis app registry contains too many scaffold entries (${slugs.length}; maximum ${MAX_CATALOG_ENTRIES}).`);
|
|
260
|
+
}
|
|
261
|
+
let catalogBytes = 0;
|
|
262
|
+
const scaffolds = await mapWithConcurrency(slugs, DOWNLOAD_CONCURRENCY, async (slug) => {
|
|
263
|
+
const [listingText, configSource] = await Promise.all([
|
|
264
|
+
tree.includes(`apps/${slug}/notis-listing.json`)
|
|
265
|
+
? fetchRegistryFile(`apps/${slug}/notis-listing.json`, commitSha, { maxBytes: MAX_CATALOG_METADATA_BYTES })
|
|
266
|
+
: Promise.resolve(''),
|
|
267
|
+
fetchRegistryFile(`apps/${slug}/notis.config.ts`, commitSha, { maxBytes: MAX_CATALOG_METADATA_BYTES }),
|
|
268
|
+
]);
|
|
269
|
+
catalogBytes += Buffer.byteLength(listingText) + Buffer.byteLength(configSource);
|
|
270
|
+
if (catalogBytes > MAX_CATALOG_TOTAL_BYTES) {
|
|
271
|
+
throw usageError(
|
|
272
|
+
`Notis app scaffold catalog exceeds the ${MAX_CATALOG_TOTAL_BYTES}-byte aggregate metadata limit.`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
const listing = listingText ? JSON.parse(listingText) : null;
|
|
276
|
+
return catalogEntry(slug, listing, configSource);
|
|
277
|
+
});
|
|
278
|
+
return scaffolds.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Case-insensitive match over slug, name, tagline, description, and categories.
|
|
283
|
+
*/
|
|
284
|
+
export function filterScaffoldCatalog(catalog, searchTerm) {
|
|
285
|
+
const term = String(searchTerm || '').trim().toLowerCase();
|
|
286
|
+
if (!term) {
|
|
287
|
+
return catalog;
|
|
288
|
+
}
|
|
289
|
+
const words = term.split(/\s+/);
|
|
290
|
+
return catalog.filter((entry) => {
|
|
291
|
+
const haystack = [entry.slug, entry.name, entry.tagline, entry.description, ...(entry.categories || [])]
|
|
292
|
+
.join(' ')
|
|
293
|
+
.toLowerCase();
|
|
294
|
+
return words.every((word) => haystack.includes(word));
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Materialize one published app's source for `apps init --from`.
|
|
300
|
+
*
|
|
301
|
+
* Returns `{ dir, cleanup }` or null when the slug is not in the registry.
|
|
302
|
+
* The caller copies out of `dir` and must call `cleanup()` afterwards.
|
|
303
|
+
*/
|
|
304
|
+
export async function acquireScaffoldSource(fromSlug) {
|
|
305
|
+
const safeSlug = assertSafeScaffoldSlug(fromSlug);
|
|
306
|
+
const registryDir = localRegistryDir();
|
|
307
|
+
if (registryDir) {
|
|
308
|
+
const appDir = join(registryDir, 'apps', safeSlug);
|
|
309
|
+
if (!existsSync(join(appDir, 'notis.config.ts'))) {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
return { dir: appDir, cleanup: () => {} };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const commitSha = await resolveRegistryCommit();
|
|
316
|
+
const tree = await fetchRegistryTree(commitSha);
|
|
317
|
+
const prefix = `apps/${safeSlug}/`;
|
|
318
|
+
const files = tree
|
|
319
|
+
.filter((entry) => entry.path.startsWith(prefix))
|
|
320
|
+
.map((entry) => ({ ...entry, rel: entry.path.slice(prefix.length) }))
|
|
321
|
+
.filter((entry) => entry.rel && !REGISTRY_ARTIFACT.test(entry.rel) && !SKIPPED_SOURCE.test(entry.rel));
|
|
322
|
+
if (!files.some((entry) => entry.rel === 'notis.config.ts')) {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
if (files.length > MAX_SCAFFOLD_FILES) {
|
|
326
|
+
throw usageError(`Published scaffold contains too many files (${files.length}; maximum ${MAX_SCAFFOLD_FILES}).`);
|
|
327
|
+
}
|
|
328
|
+
const declaredBytes = files.reduce((total, entry) => (
|
|
329
|
+
Number.isFinite(entry.size) && entry.size >= 0 ? total + entry.size : total
|
|
330
|
+
), 0);
|
|
331
|
+
if (files.some((entry) => Number.isFinite(entry.size) && entry.size > MAX_SCAFFOLD_FILE_BYTES)) {
|
|
332
|
+
throw usageError(`Published scaffold contains a file larger than ${MAX_SCAFFOLD_FILE_BYTES} bytes.`);
|
|
333
|
+
}
|
|
334
|
+
if (declaredBytes > MAX_SCAFFOLD_TOTAL_BYTES) {
|
|
335
|
+
throw usageError(`Published scaffold source exceeds ${MAX_SCAFFOLD_TOTAL_BYTES} bytes.`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const tempRoot = mkdtempSync(join(tmpdir(), 'notis-scaffold-'));
|
|
339
|
+
let downloadedBytes = 0;
|
|
340
|
+
try {
|
|
341
|
+
for (let index = 0; index < files.length; index += DOWNLOAD_CONCURRENCY) {
|
|
342
|
+
const settled = await Promise.allSettled(
|
|
343
|
+
files.slice(index, index + DOWNLOAD_CONCURRENCY).map(async (entry) => {
|
|
344
|
+
const target = resolveScaffoldTargetPath(tempRoot, entry.rel);
|
|
345
|
+
const data = await fetchRegistryFile(prefix + entry.rel, commitSha, {
|
|
346
|
+
binary: true,
|
|
347
|
+
maxBytes: MAX_SCAFFOLD_FILE_BYTES,
|
|
348
|
+
});
|
|
349
|
+
downloadedBytes += data.length;
|
|
350
|
+
if (downloadedBytes > MAX_SCAFFOLD_TOTAL_BYTES) {
|
|
351
|
+
throw usageError(`Published scaffold source exceeds ${MAX_SCAFFOLD_TOTAL_BYTES} bytes.`);
|
|
352
|
+
}
|
|
353
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
354
|
+
writeFileSync(target, data);
|
|
355
|
+
}),
|
|
356
|
+
);
|
|
357
|
+
const failed = settled.find((result) => result.status === 'rejected');
|
|
358
|
+
if (failed) {
|
|
359
|
+
throw failed.reason;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
} catch (error) {
|
|
363
|
+
rmSync(tempRoot, { recursive: true, force: true });
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
return { dir: tempRoot, cleanup: () => rmSync(tempRoot, { recursive: true, force: true }) };
|
|
367
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/** Temporary, explicit app verification/screenshot server. Never mounts a Workspace app. */
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { exportNameFromPath, getBundleDir, loadAppConfig, readManifest } from './app-platform.js';
|
|
7
|
+
|
|
8
|
+
const CONTENT_TYPES = { '.js': 'application/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.map': 'application/json; charset=utf-8' };
|
|
9
|
+
const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
10
|
+
const REPO_ROOT = resolve(CLI_ROOT, '../..');
|
|
11
|
+
const HARNESS_TEMPLATE_PATH = join(CLI_ROOT, 'template', '.harness', 'index.html.tmpl');
|
|
12
|
+
const FALLBACK_REACT_VERSION = '19.0.0';
|
|
13
|
+
|
|
14
|
+
function extFor(pathname) {
|
|
15
|
+
const idx = pathname.lastIndexOf('.');
|
|
16
|
+
return idx === -1 ? '' : pathname.slice(idx);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isAllowedOrigin(origin) {
|
|
20
|
+
if (!origin) return true;
|
|
21
|
+
try {
|
|
22
|
+
const parsed = new URL(origin);
|
|
23
|
+
if (parsed.protocol === 'notis-app:') return true;
|
|
24
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
|
|
25
|
+
return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(parsed.hostname);
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function corsHeaders(origin) {
|
|
32
|
+
const allowOrigin = origin && isAllowedOrigin(origin) ? origin : '*';
|
|
33
|
+
return {
|
|
34
|
+
'Access-Control-Allow-Origin': allowOrigin,
|
|
35
|
+
'Access-Control-Allow-Methods': 'GET, HEAD, POST, OPTIONS',
|
|
36
|
+
'Access-Control-Allow-Headers': 'Content-Type, Cache-Control',
|
|
37
|
+
'Cache-Control': 'no-store',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function safeJoin(baseDir, relPath) {
|
|
42
|
+
const normalized = relPath.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
43
|
+
if (normalized.includes('..')) return null;
|
|
44
|
+
return join(baseDir, normalized);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function scriptJson(value) {
|
|
48
|
+
return JSON.stringify(value)
|
|
49
|
+
.replace(/</g, '\\u003c')
|
|
50
|
+
.replace(/\u2028/g, '\\u2028')
|
|
51
|
+
.replace(/\u2029/g, '\\u2029');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readJsonFile(path) {
|
|
55
|
+
if (!existsSync(path)) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function reactVersionFromPeer(peerRange) {
|
|
66
|
+
if (typeof peerRange !== 'string' || !peerRange) {
|
|
67
|
+
return FALLBACK_REACT_VERSION;
|
|
68
|
+
}
|
|
69
|
+
const exact = peerRange.match(/\d+\.\d+\.\d+/);
|
|
70
|
+
if (exact && !/[<>=~^*x]/i.test(peerRange.replace(exact[0], ''))) {
|
|
71
|
+
return exact[0];
|
|
72
|
+
}
|
|
73
|
+
if (peerRange.includes('19') || peerRange.includes('18')) {
|
|
74
|
+
return FALLBACK_REACT_VERSION;
|
|
75
|
+
}
|
|
76
|
+
return FALLBACK_REACT_VERSION;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function resolveHarnessReactVersion(projectDir) {
|
|
80
|
+
const candidates = [
|
|
81
|
+
join(projectDir, 'node_modules', '@notis', 'sdk', 'package.json'),
|
|
82
|
+
join(REPO_ROOT, 'packages', 'sdk', 'package.json'),
|
|
83
|
+
join(CLI_ROOT, 'template', 'packages', 'sdk', 'package.json'),
|
|
84
|
+
];
|
|
85
|
+
for (const candidate of candidates) {
|
|
86
|
+
const pkg = readJsonFile(candidate);
|
|
87
|
+
const peer = pkg?.peerDependencies?.react;
|
|
88
|
+
if (peer) {
|
|
89
|
+
return reactVersionFromPeer(peer);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return FALLBACK_REACT_VERSION;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function titleFromSlug(slug) {
|
|
96
|
+
return String(slug || '')
|
|
97
|
+
.replace(/[-_]+/g, ' ')
|
|
98
|
+
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function normalizeDatabaseDescriptors(databases) {
|
|
102
|
+
return (Array.isArray(databases) ? databases : [])
|
|
103
|
+
.map((entry) => {
|
|
104
|
+
if (typeof entry === 'string') {
|
|
105
|
+
return {
|
|
106
|
+
slug: entry,
|
|
107
|
+
title: titleFromSlug(entry),
|
|
108
|
+
description: null,
|
|
109
|
+
icon: null,
|
|
110
|
+
properties: [],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (entry && typeof entry === 'object' && typeof entry.slug === 'string') {
|
|
114
|
+
return {
|
|
115
|
+
slug: entry.slug,
|
|
116
|
+
title: entry.title || titleFromSlug(entry.slug),
|
|
117
|
+
description: entry.description || null,
|
|
118
|
+
icon: entry.icon || null,
|
|
119
|
+
properties: Array.isArray(entry.properties) ? entry.properties : [],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
})
|
|
124
|
+
.filter(Boolean);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeToolDescriptors(tools) {
|
|
128
|
+
return (Array.isArray(tools) ? tools : [])
|
|
129
|
+
.map((entry) => {
|
|
130
|
+
if (typeof entry === 'string') {
|
|
131
|
+
return { name: entry };
|
|
132
|
+
}
|
|
133
|
+
if (entry && typeof entry === 'object' && typeof entry.name === 'string') {
|
|
134
|
+
return entry;
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
})
|
|
138
|
+
.filter(Boolean);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function defaultRouteForManifest(manifest) {
|
|
142
|
+
const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
|
|
143
|
+
return routes.find((route) => route?.default) || routes[0] || null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function findHarnessRoute(manifest, routeSlug) {
|
|
147
|
+
const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
|
|
148
|
+
if (!routeSlug) {
|
|
149
|
+
return defaultRouteForManifest(manifest);
|
|
150
|
+
}
|
|
151
|
+
return routes.find((route) => route?.slug === routeSlug) || null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function buildHarnessDescriptor({ state, manifest, appConfig, route, scenario = null }) {
|
|
155
|
+
const databases = normalizeDatabaseDescriptors(
|
|
156
|
+
Array.isArray(appConfig?.databases) && appConfig.databases.length
|
|
157
|
+
? appConfig.databases
|
|
158
|
+
: manifest.databases,
|
|
159
|
+
);
|
|
160
|
+
const tools = normalizeToolDescriptors(
|
|
161
|
+
Array.isArray(appConfig?.tools) && appConfig.tools.length
|
|
162
|
+
? appConfig.tools
|
|
163
|
+
: manifest.tools,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
app: {
|
|
168
|
+
id: state.appId || 'harness-app',
|
|
169
|
+
slug: state.slug,
|
|
170
|
+
name: manifest.app?.name || appConfig?.name || state.slug,
|
|
171
|
+
icon: manifest.app?.icon || appConfig?.icon || null,
|
|
172
|
+
description: manifest.app?.description || appConfig?.description || null,
|
|
173
|
+
},
|
|
174
|
+
route: {
|
|
175
|
+
slug: route.slug,
|
|
176
|
+
path: route.path || '/',
|
|
177
|
+
name: route.name || titleFromSlug(route.slug),
|
|
178
|
+
icon: route.icon || null,
|
|
179
|
+
parentSlug: route.parentSlug || null,
|
|
180
|
+
default: Boolean(route.default),
|
|
181
|
+
resourceDeepLinks: route.resourceDeepLinks === true,
|
|
182
|
+
collection: route.collection || null,
|
|
183
|
+
},
|
|
184
|
+
databases,
|
|
185
|
+
context: { collectionItem: null, resourceId: null, screenshotScenario: scenario },
|
|
186
|
+
tools,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function plainObject(value) {
|
|
191
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Resolve the fixture payload injected into one harness page load.
|
|
196
|
+
*
|
|
197
|
+
* A scenario may override individual `tools` / `requests` keys on top of the
|
|
198
|
+
* file-level defaults, which is how one route renders both its populated and
|
|
199
|
+
* its empty state. Each capture is its own page load, so a shallow per-key
|
|
200
|
+
* merge is all the isolation a scenario needs.
|
|
201
|
+
*/
|
|
202
|
+
function harnessFixtures(projectDir, scenario) {
|
|
203
|
+
const fixtureConfig = readJsonFile(join(projectDir, 'metadata', 'screenshot-fixtures.json')) || {};
|
|
204
|
+
const scenarios = plainObject(fixtureConfig.scenarios);
|
|
205
|
+
const selected = scenario ? plainObject(scenarios[scenario]) : null;
|
|
206
|
+
return {
|
|
207
|
+
tools: { ...plainObject(fixtureConfig.tools), ...plainObject(selected?.tools) },
|
|
208
|
+
requests: { ...plainObject(fixtureConfig.requests), ...plainObject(selected?.requests) },
|
|
209
|
+
scenario: selected && Object.keys(selected).length > 0 ? selected : null,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function renderHarnessHtml({ state, manifest, appConfig, route, harnessOptions, scenario = null }) {
|
|
214
|
+
const template = readFileSync(HARNESS_TEMPLATE_PATH, 'utf-8');
|
|
215
|
+
const descriptor = buildHarnessDescriptor({ state, manifest, appConfig, route, scenario });
|
|
216
|
+
const routeExport = route.export_name || route.exportName || exportNameFromPath(route.path || '/');
|
|
217
|
+
const replacements = {
|
|
218
|
+
'{{REACT_VERSION}}': resolveHarnessReactVersion(state.projectDir),
|
|
219
|
+
'{{ROUTE_EXPORT}}': scriptJson(routeExport),
|
|
220
|
+
'{{RUNTIME_DESCRIPTOR}}': scriptJson(descriptor),
|
|
221
|
+
'{{MODE}}': scriptJson(harnessOptions.mode || 'stub'),
|
|
222
|
+
'{{API_BASE}}': scriptJson(harnessOptions.apiBase || null),
|
|
223
|
+
'{{JWT}}': scriptJson(harnessOptions.jwt || null),
|
|
224
|
+
'{{FIXTURES}}': scriptJson(harnessFixtures(state.projectDir, scenario)),
|
|
225
|
+
};
|
|
226
|
+
let html = template;
|
|
227
|
+
for (const [token, value] of Object.entries(replacements)) {
|
|
228
|
+
html = html.replaceAll(token, value);
|
|
229
|
+
}
|
|
230
|
+
return html;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
/** Serve only explicitly supplied built projects, without discovery or filesystem watchers. */
|
|
235
|
+
export async function startAppTestServer({ apps, port, harness = {} }) {
|
|
236
|
+
if (!Array.isArray(apps) || !apps.length) throw new Error('At least one built app is required.');
|
|
237
|
+
const states = new Map();
|
|
238
|
+
for (const app of apps) {
|
|
239
|
+
states.set(app.slug, {
|
|
240
|
+
...app,
|
|
241
|
+
manifest: readManifest(app.projectDir),
|
|
242
|
+
appConfig: await loadAppConfig(app.projectDir),
|
|
243
|
+
bundleDir: getBundleDir(app.projectDir),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
const server = createServer((req, res) => {
|
|
247
|
+
const headers = corsHeaders(req.headers.origin || '');
|
|
248
|
+
const respond = (status, content, contentType = 'text/plain; charset=utf-8') => {
|
|
249
|
+
res.writeHead(status, { ...headers, 'Content-Type': contentType });
|
|
250
|
+
res.end(req.method === 'HEAD' ? undefined : content);
|
|
251
|
+
};
|
|
252
|
+
try {
|
|
253
|
+
if (req.headers.origin && !isAllowedOrigin(req.headers.origin)) return respond(403, 'origin not allowed');
|
|
254
|
+
if (req.method === 'OPTIONS') return respond(204, '');
|
|
255
|
+
if (!['GET', 'HEAD'].includes(req.method)) return respond(405, 'method not allowed');
|
|
256
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
257
|
+
if (url.pathname === '/healthz') return respond(200, JSON.stringify({ ok: true }), 'application/json');
|
|
258
|
+
const match = url.pathname.match(/^\/a\/([^/]+)\/(.*)$/);
|
|
259
|
+
const state = match && states.get(decodeURIComponent(match[1]));
|
|
260
|
+
if (!state) return respond(404, 'not found');
|
|
261
|
+
if (match[2] === 'harness') {
|
|
262
|
+
const route = findHarnessRoute(state.manifest, url.searchParams.get('route') || '');
|
|
263
|
+
if (!route) return respond(404, 'unknown route');
|
|
264
|
+
return respond(200, renderHarnessHtml({ state, manifest: state.manifest, appConfig: state.appConfig, route, harnessOptions: harness, scenario: url.searchParams.get('scenario') }), 'text/html; charset=utf-8');
|
|
265
|
+
}
|
|
266
|
+
if (match[2].startsWith('bundle/')) {
|
|
267
|
+
const relativePath = decodeURIComponent(match[2].slice('bundle/'.length));
|
|
268
|
+
const file = safeJoin(state.bundleDir, relativePath);
|
|
269
|
+
if (!file || !existsSync(file) || !statSync(file).isFile()) return respond(404, 'not found');
|
|
270
|
+
return respond(200, readFileSync(file), CONTENT_TYPES[extFor(file)] || 'application/octet-stream');
|
|
271
|
+
}
|
|
272
|
+
return respond(404, 'not found');
|
|
273
|
+
} catch (error) {
|
|
274
|
+
return respond(500, error instanceof Error ? error.message : String(error));
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
await new Promise((accept, reject) => {
|
|
278
|
+
server.once('error', reject);
|
|
279
|
+
server.listen(port, '127.0.0.1', () => { server.off('error', reject); accept(); });
|
|
280
|
+
});
|
|
281
|
+
let closing;
|
|
282
|
+
return {
|
|
283
|
+
port: server.address().port,
|
|
284
|
+
close() {
|
|
285
|
+
closing ||= new Promise((accept) => {
|
|
286
|
+
server.close(accept);
|
|
287
|
+
server.closeAllConnections?.();
|
|
288
|
+
});
|
|
289
|
+
return closing;
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
Binary file
|