@notis_ai/cli 0.2.0-beta.191.1 → 0.2.0-beta.196.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 +479 -80
- package/config/portal-route-roots.json +48 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +29072 -2784
- 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 +2 -2
- 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/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
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { spawn } from 'node:child_process';
|
|
10
10
|
import { createHash, randomUUID } from 'node:crypto';
|
|
11
|
-
import { closeSync, constants as fsConstants, copyFileSync, cpSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
11
|
+
import { closeSync, constants as fsConstants, copyFileSync, cpSync, existsSync, fstatSync, linkSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
12
12
|
import { createRequire } from 'node:module';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
14
|
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
@@ -19,6 +19,8 @@ import { usageError } from './errors.js';
|
|
|
19
19
|
import { acquireScaffoldSource, loadScaffoldCatalog } from './app-registry-scaffolds.js';
|
|
20
20
|
import { validateArtifactBoundary, validateProjectBoundary, validateProjectDesign } from './app-boundary-validator.js';
|
|
21
21
|
import { CHANGELOG_MERGE_DATE, readAppChangelog } from './app-changelog.js';
|
|
22
|
+
import { HOST_ONLY_SDK_FILES, RETIRED_SDK_FILES } from './retired-sdk-files.js';
|
|
23
|
+
import { assertSourceRoot } from './source-root.js';
|
|
22
24
|
|
|
23
25
|
const NOTIS_DIR = '.notis';
|
|
24
26
|
const STATE_FILE = join(NOTIS_DIR, 'state.json');
|
|
@@ -40,6 +42,7 @@ const SOURCE_COPY_EXCLUDES = new Set([
|
|
|
40
42
|
'node_modules',
|
|
41
43
|
'.notis',
|
|
42
44
|
'.git',
|
|
45
|
+
'.context',
|
|
43
46
|
'dist',
|
|
44
47
|
'tsconfig.tsbuildinfo',
|
|
45
48
|
'.DS_Store',
|
|
@@ -67,11 +70,6 @@ const PULL_LOCK_POLL_MS = 25;
|
|
|
67
70
|
const STATE_WRITE_LOCK_TIMEOUT_MS = 5_000;
|
|
68
71
|
const STATE_WRITE_LOCK_STALE_MS = 2_000;
|
|
69
72
|
const stateWriteLockWait = new Int32Array(new SharedArrayBuffer(4));
|
|
70
|
-
// A directory-declared skill ships every supporting file to the sandbox, so it
|
|
71
|
-
// needs a ceiling of its own. Kept well above the 512 KB SKILL.md limit so a
|
|
72
|
-
// handful of scripts always fits, and far below the bundle machinery's own
|
|
73
|
-
// limits so an accidental asset dump fails on the client with a clear message.
|
|
74
|
-
export const MAX_APP_SKILL_BUNDLE_BYTES = 5 * 1024 * 1024;
|
|
75
73
|
let appConfigImportNonce = 0;
|
|
76
74
|
|
|
77
75
|
// ---------------------------------------------------------------------------
|
|
@@ -165,8 +163,16 @@ function normalizeBundleStylesheets(projectDir) {
|
|
|
165
163
|
function stripNotisSdkImports(source) {
|
|
166
164
|
return source
|
|
167
165
|
.replace(/import\s+type\s+[\s\S]*?from\s+['"][^'"]*['"]\s*;?/g, '')
|
|
168
|
-
.replace(/import\s*{[
|
|
169
|
-
|
|
166
|
+
.replace(/import\s*{([^}]+)}\s+from\s+['"]@notis\/sdk\/config['"]\s*;?/g, (_statement, names) => {
|
|
167
|
+
const declarations = [];
|
|
168
|
+
for (const imported of names.split(',').map(value => value.trim()).filter(Boolean)) {
|
|
169
|
+
if (imported.startsWith('type ')) continue;
|
|
170
|
+
const match = imported.match(/^(defineNotisApp|defineSpace|defineSpaces)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
|
|
171
|
+
if (!match) throw usageError(`Unsupported configuration helper: ${imported}`);
|
|
172
|
+
declarations.push(`const ${match[2] || match[1]} = (value) => value;`);
|
|
173
|
+
}
|
|
174
|
+
return declarations.join('\n');
|
|
175
|
+
});
|
|
170
176
|
}
|
|
171
177
|
|
|
172
178
|
function transpileTsConfigSource(source, configPath) {
|
|
@@ -176,24 +182,19 @@ function transpileTsConfigSource(source, configPath) {
|
|
|
176
182
|
// so removing the import and replacing the call with a parens-wrapped
|
|
177
183
|
// expression preserves the config value without ever resolving the SDK.
|
|
178
184
|
const stripped = stripNotisSdkImports(source);
|
|
179
|
-
const requireFromConfig = createRequire(`file://${configPath}`);
|
|
180
185
|
try {
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
target: ts.ScriptTarget.ES2020,
|
|
186
|
-
},
|
|
187
|
-
fileName: configPath,
|
|
188
|
-
});
|
|
189
|
-
return transpiled.outputText;
|
|
190
|
-
} catch {
|
|
191
|
-
return stripped;
|
|
186
|
+
const { transformSync } = createRequire(import.meta.url)('esbuild');
|
|
187
|
+
return transformSync(stripped, { loader: 'ts', format: 'esm', target: 'es2020', sourcefile: configPath }).code;
|
|
188
|
+
} catch (error) {
|
|
189
|
+
throw usageError(`Failed to parse ${configPath}: ${error.message}`);
|
|
192
190
|
}
|
|
193
191
|
}
|
|
194
192
|
|
|
195
|
-
export async function loadAppConfig(projectDir) {
|
|
196
|
-
const configPaths = ['notis.config.ts', 'notis.config.js', 'notis.config.mjs'];
|
|
193
|
+
export async function loadAppConfig(projectDir, { file = null } = {}) {
|
|
194
|
+
const configPaths = file ? [file] : ['notis.config.ts', 'notis.config.js', 'notis.config.mjs'];
|
|
195
|
+
if (file && (!Object.hasOwn(collectSourceFiles(projectDir), file) || !/\.(?:ts|js|mjs)$/.test(file))) {
|
|
196
|
+
throw usageError('Choose an existing configuration file inside this source project.');
|
|
197
|
+
}
|
|
197
198
|
let configPath = null;
|
|
198
199
|
|
|
199
200
|
for (const name of configPaths) {
|
|
@@ -208,12 +209,13 @@ export async function loadAppConfig(projectDir) {
|
|
|
208
209
|
throw usageError('No notis.config.ts found in project directory.');
|
|
209
210
|
}
|
|
210
211
|
|
|
211
|
-
//
|
|
212
|
-
|
|
212
|
+
// Identity config helpers work for every extension without asking Node to
|
|
213
|
+
// execute raw TypeScript from an embedded SDK package.
|
|
214
|
+
{
|
|
213
215
|
const source = readFileSync(configPath, 'utf-8');
|
|
214
|
-
const jsSource = transpileTsConfigSource(source, configPath);
|
|
216
|
+
const jsSource = configPath.endsWith('.ts') ? transpileTsConfigSource(source, configPath) : stripNotisSdkImports(source);
|
|
215
217
|
|
|
216
|
-
const tmpPath = join(dirname(configPath),
|
|
218
|
+
const tmpPath = join(dirname(configPath), `._notis_config_${randomUUID()}.mjs`);
|
|
217
219
|
mkdirSync(dirname(tmpPath), { recursive: true });
|
|
218
220
|
writeFileSync(tmpPath, jsSource);
|
|
219
221
|
try {
|
|
@@ -235,8 +237,6 @@ export async function loadAppConfig(projectDir) {
|
|
|
235
237
|
}
|
|
236
238
|
}
|
|
237
239
|
|
|
238
|
-
const mod = await import(`file://${configPath}`);
|
|
239
|
-
return mod.default || mod;
|
|
240
240
|
}
|
|
241
241
|
|
|
242
242
|
// ---------------------------------------------------------------------------
|
|
@@ -796,6 +796,13 @@ export async function prepareArtifactBuild(projectDir, { enforceDesign = true, l
|
|
|
796
796
|
* Generate the manifest from app config and build output.
|
|
797
797
|
*/
|
|
798
798
|
export function generateManifest(appConfig, projectDir) {
|
|
799
|
+
if (['skills', 'onboarding'].some(key => {
|
|
800
|
+
const value = appConfig[key];
|
|
801
|
+
return Array.isArray(value) ? value.length > 0 : Boolean(value);
|
|
802
|
+
})) {
|
|
803
|
+
throw usageError('Apps no longer package Skills or onboarding. Put each Skill in skills/<alias>/ of a '
|
|
804
|
+
+ 'Space source and deploy it with notis spaces deploy.');
|
|
805
|
+
}
|
|
799
806
|
const routes = resolveConfiguredRoutes(appConfig, projectDir).map((route) => {
|
|
800
807
|
const entry = {
|
|
801
808
|
path: route.path,
|
|
@@ -818,10 +825,10 @@ export function generateManifest(appConfig, projectDir) {
|
|
|
818
825
|
if (routes.length !== 1 || routes[0].collection || routes[0].resourceDeepLinks) {
|
|
819
826
|
throw usageError('Reports require one standalone route, without app collections or resource deep links.');
|
|
820
827
|
}
|
|
821
|
-
if (['databases', '
|
|
828
|
+
if (['databases', 'automations', 'tagline', 'categories', 'author', 'screenshots'].some(key => {
|
|
822
829
|
const value = appConfig[key];
|
|
823
830
|
return Array.isArray(value) ? value.length > 0 : Boolean(value);
|
|
824
|
-
})) throw usageError('Reports cannot own databases,
|
|
831
|
+
})) throw usageError('Reports cannot own databases, automations or Store metadata.');
|
|
825
832
|
if (Object.values(appConfig.capabilities || {}).some(value => value !== 'read')) {
|
|
826
833
|
throw usageError('Reports support read capabilities only; shell grants require an installed app.');
|
|
827
834
|
}
|
|
@@ -849,12 +856,6 @@ export function generateManifest(appConfig, projectDir) {
|
|
|
849
856
|
const metadata = { screenshots: resolveListingScreenshots(projectDir, appConfig) };
|
|
850
857
|
const appSlug = safeKebab(appConfig.name);
|
|
851
858
|
const displayTitle = appConfig.title || appConfig.displayName || appConfig.name;
|
|
852
|
-
const skills = (Array.isArray(appConfig.skills) ? appConfig.skills : []).map((skill) => ({
|
|
853
|
-
key: skill.key,
|
|
854
|
-
path: normalizeAppSkillManifestPath(skill.path),
|
|
855
|
-
name: skill.name,
|
|
856
|
-
description: skill.description || null,
|
|
857
|
-
}));
|
|
858
859
|
const listingMedia = {
|
|
859
860
|
screenshots: metadata.screenshots.map((screenshot) => ({
|
|
860
861
|
path: screenshot.path,
|
|
@@ -926,8 +927,6 @@ export function generateManifest(appConfig, projectDir) {
|
|
|
926
927
|
capabilities: normalizeAppCapabilities(appConfig.capabilities),
|
|
927
928
|
tools: appConfig.tools || [],
|
|
928
929
|
tool_bindings: normalizeAppToolBindings(appConfig.toolBindings),
|
|
929
|
-
skills,
|
|
930
|
-
onboarding: appConfig.onboarding || null,
|
|
931
930
|
};
|
|
932
931
|
}
|
|
933
932
|
|
|
@@ -981,136 +980,20 @@ export function normalizeAppCapabilities(capabilities) {
|
|
|
981
980
|
return normalized;
|
|
982
981
|
}
|
|
983
982
|
|
|
984
|
-
/**
|
|
985
|
-
* Manifest form of a declared skill path: no leading `./`, no trailing slash.
|
|
986
|
-
* A directory declaration is the same string as the source-tree prefix the
|
|
987
|
-
* server matches the uploaded source files against.
|
|
988
|
-
*/
|
|
989
|
-
export function normalizeAppSkillManifestPath(sourcePath) {
|
|
990
|
-
return String(sourcePath || '').replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/\/+$/, '');
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
/**
|
|
994
|
-
* Every packageable file under a declared skill directory, relative to that
|
|
995
|
-
* directory, in stable order. Excludes match `readSourceFiles` so the files a
|
|
996
|
-
* dev session sends inline are exactly the ones a deploy uploads as source.
|
|
997
|
-
*/
|
|
998
|
-
function readAppSkillDirectoryFiles(skillDir) {
|
|
999
|
-
const entries = [];
|
|
1000
|
-
|
|
1001
|
-
function walk(dir, prefix) {
|
|
1002
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
1003
|
-
if (shouldExcludeSourceEntry(entry.name) || entry.isSymbolicLink()) {
|
|
1004
|
-
continue;
|
|
1005
|
-
}
|
|
1006
|
-
const fullPath = join(dir, entry.name);
|
|
1007
|
-
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
1008
|
-
if (entry.isDirectory()) {
|
|
1009
|
-
walk(fullPath, relPath);
|
|
1010
|
-
} else if (entry.isFile()) {
|
|
1011
|
-
entries.push({ path: relPath, absolutePath: fullPath });
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
walk(skillDir, '');
|
|
1017
|
-
// Byte order, not locale order: the server sorts the same file set the same
|
|
1018
|
-
// way before hashing it, so a dev session and a deploy agree on the hash.
|
|
1019
|
-
return entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
export function resolveConfiguredAppSkills(appConfig, projectDir) {
|
|
1023
|
-
const configured = Array.isArray(appConfig.skills) ? appConfig.skills : [];
|
|
1024
|
-
const projectRoot = resolve(projectDir);
|
|
1025
|
-
const realProjectRoot = realpathSync(projectRoot);
|
|
1026
|
-
const seenKeys = new Set();
|
|
1027
|
-
|
|
1028
|
-
return configured.map((skill, index) => {
|
|
1029
|
-
const key = typeof skill?.key === 'string' ? skill.key.trim() : '';
|
|
1030
|
-
const sourcePath = typeof skill?.path === 'string' ? skill.path.trim() : '';
|
|
1031
|
-
const name = typeof skill?.name === 'string' ? skill.name.trim() : '';
|
|
1032
|
-
if (!key || !sourcePath || !name) {
|
|
1033
|
-
throw usageError(`skills[${index}] must define non-empty key, path, and name values.`);
|
|
1034
|
-
}
|
|
1035
|
-
if (seenKeys.has(key)) {
|
|
1036
|
-
throw usageError(`Duplicate app skill key: ${key}`);
|
|
1037
|
-
}
|
|
1038
|
-
seenKeys.add(key);
|
|
1039
|
-
|
|
1040
|
-
const absolutePath = resolve(projectRoot, sourcePath);
|
|
1041
|
-
const relativePath = relative(projectRoot, absolutePath).replace(/\\/g, '/');
|
|
1042
|
-
if (!relativePath || relativePath.startsWith('../') || relativePath === '..') {
|
|
1043
|
-
throw usageError(`App skill path must stay inside the project: ${sourcePath}`);
|
|
1044
|
-
}
|
|
1045
|
-
if (!existsSync(absolutePath)) {
|
|
1046
|
-
throw usageError(`App skill entrypoint not found: ${sourcePath}`);
|
|
1047
|
-
}
|
|
1048
|
-
if (lstatSync(absolutePath).isSymbolicLink()) {
|
|
1049
|
-
throw usageError(`App skill entrypoint cannot be a symbolic link: ${sourcePath}`);
|
|
1050
|
-
}
|
|
1051
|
-
const realAbsolutePath = realpathSync(absolutePath);
|
|
1052
|
-
const realRelativePath = relative(realProjectRoot, realAbsolutePath).replace(/\\/g, '/');
|
|
1053
|
-
if (!realRelativePath || realRelativePath.startsWith('../') || realRelativePath === '..') {
|
|
1054
|
-
throw usageError(`App skill path must stay inside the project after resolving links: ${sourcePath}`);
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
const description = typeof skill.description === 'string' ? skill.description.trim() : null;
|
|
1058
|
-
const stats = statSync(absolutePath);
|
|
1059
|
-
if (stats.isDirectory()) {
|
|
1060
|
-
const entries = readAppSkillDirectoryFiles(absolutePath);
|
|
1061
|
-
if (!entries.some((entry) => entry.path === 'SKILL.md')) {
|
|
1062
|
-
throw usageError(`App skill directory must contain SKILL.md: ${sourcePath}`);
|
|
1063
|
-
}
|
|
1064
|
-
const bundleFiles = entries.map((entry) => ({
|
|
1065
|
-
path: entry.path,
|
|
1066
|
-
content: readFileSync(entry.absolutePath),
|
|
1067
|
-
}));
|
|
1068
|
-
const totalBytes = bundleFiles.reduce((total, entry) => total + entry.content.length, 0);
|
|
1069
|
-
if (totalBytes > MAX_APP_SKILL_BUNDLE_BYTES) {
|
|
1070
|
-
throw usageError(
|
|
1071
|
-
`App skill "${key}" bundles ${totalBytes} bytes, above the ${MAX_APP_SKILL_BUNDLE_BYTES} byte limit.`,
|
|
1072
|
-
);
|
|
1073
|
-
}
|
|
1074
|
-
const skillMd = bundleFiles.find((entry) => entry.path === 'SKILL.md');
|
|
1075
|
-
return {
|
|
1076
|
-
key,
|
|
1077
|
-
path: relativePath,
|
|
1078
|
-
name,
|
|
1079
|
-
description,
|
|
1080
|
-
skill_md: skillMd.content.toString('utf8'),
|
|
1081
|
-
bundle_files: bundleFiles.map((entry) => ({
|
|
1082
|
-
path: entry.path,
|
|
1083
|
-
content_b64: entry.content.toString('base64'),
|
|
1084
|
-
})),
|
|
1085
|
-
};
|
|
1086
|
-
}
|
|
1087
|
-
if (!stats.isFile()) {
|
|
1088
|
-
throw usageError(`App skill entrypoint not found: ${sourcePath}`);
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
return {
|
|
1092
|
-
key,
|
|
1093
|
-
path: relativePath,
|
|
1094
|
-
name,
|
|
1095
|
-
description,
|
|
1096
|
-
skill_md: readFileSync(absolutePath, 'utf8'),
|
|
1097
|
-
};
|
|
1098
|
-
});
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
983
|
/**
|
|
1102
984
|
* Build the app bundle: generate entry file, run `vite build`, package into .notis/output/.
|
|
1103
985
|
*/
|
|
1104
|
-
export async function buildArtifact(projectDir, { stdio = 'inherit'
|
|
1105
|
-
|
|
986
|
+
export async function buildArtifact(projectDir, { stdio = 'inherit', prepare = prepareArtifactBuild,
|
|
987
|
+
finalize = null, executable = true, includeMetadata = true, refreshSdk = true, runBuild = runProjectScript } = {}) {
|
|
988
|
+
projectDir = realpathSync(assertSourceRoot(projectDir));
|
|
1106
989
|
const workspace = withAppReleaseWorkspace(projectDir, (identity) => {
|
|
1107
990
|
rmSync('build-receipt.json', { force: true });
|
|
1108
991
|
return identity;
|
|
1109
992
|
}, { create: true });
|
|
1110
993
|
// SDK refresh is intentional source preparation, before the immutable build receipt.
|
|
1111
|
-
syncEmbeddedSdk(projectDir);
|
|
994
|
+
if (refreshSdk) syncEmbeddedSdk(projectDir);
|
|
1112
995
|
const sourceHash = appFilesDigest(collectSourceFiles(projectDir));
|
|
1113
|
-
await
|
|
996
|
+
await prepare(projectDir);
|
|
1114
997
|
|
|
1115
998
|
// Historical source must round-trip byte-for-byte. Supply the compatible
|
|
1116
999
|
// config loader at execution for the old canonical script, without editing
|
|
@@ -1119,24 +1002,22 @@ export async function buildArtifact(projectDir, { stdio = 'inherit' } = {}) {
|
|
|
1119
1002
|
const args = String(pkg.scripts?.build || '').trim() === 'vite build'
|
|
1120
1003
|
? ['--configLoader', 'runner']
|
|
1121
1004
|
: [];
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
scriptName: 'build',
|
|
1125
|
-
args,
|
|
1126
|
-
stdio,
|
|
1127
|
-
});
|
|
1005
|
+
// runBuild defaults to the PATH npm script. A confined caller supplies a pinned runner instead.
|
|
1006
|
+
if (executable) await runBuild({ projectDir, scriptName: 'build', args, stdio });
|
|
1128
1007
|
|
|
1129
1008
|
withAppReleaseWorkspace(projectDir, () => {}, { expected: workspace });
|
|
1130
1009
|
|
|
1131
1010
|
// Verify the canonical `.notis/output/bundle` packaging contract.
|
|
1132
1011
|
const builtBundleDir = resolveBuiltBundleDir(projectDir);
|
|
1133
|
-
if (!builtBundleDir) {
|
|
1012
|
+
if (executable && !builtBundleDir) {
|
|
1134
1013
|
throw usageError(
|
|
1135
1014
|
'Vite build did not produce app.js in .notis/output/bundle. Check your vite.config.ts.',
|
|
1136
1015
|
);
|
|
1137
1016
|
}
|
|
1138
1017
|
normalizeBundleStylesheets(projectDir);
|
|
1139
|
-
copyMetadataAssets(projectDir);
|
|
1018
|
+
if (includeMetadata) copyMetadataAssets(projectDir);
|
|
1019
|
+
if (finalize) await finalize(projectDir);
|
|
1020
|
+
withAppReleaseWorkspace(projectDir, () => {}, { expected: workspace });
|
|
1140
1021
|
|
|
1141
1022
|
validateArtifactBoundary(readArtifactFiles(projectDir));
|
|
1142
1023
|
|
|
@@ -1844,6 +1725,9 @@ function ensureScaffoldLocalSdk(projectDir, pkg) {
|
|
|
1844
1725
|
|
|
1845
1726
|
const localSdkDir = join(projectDir, 'packages', 'sdk');
|
|
1846
1727
|
if (existsSync(join(localSdkDir, 'package.json'))) {
|
|
1728
|
+
// Registry scaffolds may already contain an older complete SDK mirror.
|
|
1729
|
+
// Apply the same exact-owned-file retirement as an ordinary build refresh.
|
|
1730
|
+
syncEmbeddedSdk(projectDir);
|
|
1847
1731
|
return;
|
|
1848
1732
|
}
|
|
1849
1733
|
if (!existsSync(join(TEMPLATE_SDK_DIR, 'package.json'))) {
|
|
@@ -1851,7 +1735,8 @@ function ensureScaffoldLocalSdk(projectDir, pkg) {
|
|
|
1851
1735
|
}
|
|
1852
1736
|
|
|
1853
1737
|
mkdirSync(dirname(localSdkDir), { recursive: true });
|
|
1854
|
-
cpSync(TEMPLATE_SDK_DIR, localSdkDir, { recursive: true, dereference: true
|
|
1738
|
+
cpSync(TEMPLATE_SDK_DIR, localSdkDir, { recursive: true, dereference: true,
|
|
1739
|
+
filter: (source) => !HOST_ONLY_SDK_FILES.includes(relative(TEMPLATE_SDK_DIR, source).split(sep).join('/')) });
|
|
1855
1740
|
}
|
|
1856
1741
|
|
|
1857
1742
|
function listFilesRecursive(dir, base = dir, results = []) {
|
|
@@ -1869,17 +1754,20 @@ function listFilesRecursive(dir, base = dir, results = []) {
|
|
|
1869
1754
|
}
|
|
1870
1755
|
|
|
1871
1756
|
/**
|
|
1872
|
-
* Keep an app's embedded
|
|
1873
|
-
*
|
|
1757
|
+
* Keep an app's embedded public SDK identical to the SDK this CLI ships,
|
|
1758
|
+
* excluding host-only mounting code. The copy is a mirror by contract (the parity test in the CLI keeps
|
|
1874
1759
|
* the bundled artifact generated from packages/sdk), so an app never edits it; before
|
|
1875
1760
|
* this sync existed every app silently ran whatever SDK snapshot it was
|
|
1876
1761
|
* scaffolded with and never received hook or style updates.
|
|
1877
1762
|
*
|
|
1878
1763
|
* Files that exist locally but not in the template are left alone with a
|
|
1879
|
-
* warning so a deliberate local addition is never destroyed.
|
|
1764
|
+
* warning so a deliberate local addition is never destroyed. The refresh owns
|
|
1765
|
+
* only packages/sdk/{package.json,tsconfig.json,src/<template files>} and the
|
|
1766
|
+
* exact retired copies of a real source; a folder without a source marker
|
|
1767
|
+
* (the Notis repository's own packages/sdk, for one) is refused untouched.
|
|
1880
1768
|
*/
|
|
1881
|
-
export function syncEmbeddedSdk(projectDir, { log = null, templateSdkDir = TEMPLATE_SDK_DIR } = {}) {
|
|
1882
|
-
const canonicalProjectDir = realpathSync(projectDir);
|
|
1769
|
+
export function syncEmbeddedSdk(projectDir, { log = null, templateSdkDir = TEMPLATE_SDK_DIR, retiredFiles = RETIRED_SDK_FILES } = {}) {
|
|
1770
|
+
const canonicalProjectDir = realpathSync(assertSourceRoot(projectDir));
|
|
1883
1771
|
const canonicalTemplateDir = resolve(templateSdkDir);
|
|
1884
1772
|
if (!existsSync(join(canonicalTemplateDir, 'package.json'))) {
|
|
1885
1773
|
return { updated: false, reason: 'no-template-sdk' };
|
|
@@ -1888,7 +1776,8 @@ export function syncEmbeddedSdk(projectDir, { log = null, templateSdkDir = TEMPL
|
|
|
1888
1776
|
const previousIdentity = capturePullTargetIdentity(previousCwd);
|
|
1889
1777
|
const projectIdentity = capturePullTargetIdentity(canonicalProjectDir);
|
|
1890
1778
|
const templateFiles = listFilesRecursive(canonicalTemplateDir).filter(
|
|
1891
|
-
(relPath) =>
|
|
1779
|
+
(relPath) => !HOST_ONLY_SDK_FILES.includes(relPath)
|
|
1780
|
+
&& (relPath === 'package.json' || relPath === 'tsconfig.json' || relPath.startsWith('src/')),
|
|
1892
1781
|
);
|
|
1893
1782
|
const changed = [];
|
|
1894
1783
|
let foreign = [];
|
|
@@ -1940,6 +1829,43 @@ export function syncEmbeddedSdk(projectDir, { log = null, templateSdkDir = TEMPL
|
|
|
1940
1829
|
return true;
|
|
1941
1830
|
}
|
|
1942
1831
|
|
|
1832
|
+
function retireFile(segments, digests) {
|
|
1833
|
+
if (segments.length > 1) return inDirectory(segments[0], () => retireFile(segments.slice(1), digests));
|
|
1834
|
+
const name = segments[0];
|
|
1835
|
+
let before;
|
|
1836
|
+
try { before = lstatSync(name, { bigint: true }); }
|
|
1837
|
+
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
1838
|
+
if (!before.isFile() || before.isSymbolicLink()) throw usageError(`Refusing an unsafe retired SDK target: ${name}`);
|
|
1839
|
+
const descriptor = openSync(name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
1840
|
+
try {
|
|
1841
|
+
const bytes = readStablePinnedFile(descriptor, before, `Retired SDK file ${name}`);
|
|
1842
|
+
if (!digests.includes(createHash('sha256').update(bytes).digest('hex'))) return false;
|
|
1843
|
+
// Capture the exact directory entry before removing anything. A newly
|
|
1844
|
+
// created replacement at the original path is never overwritten.
|
|
1845
|
+
const temporary = `.notis-sdk-retired-${randomUUID()}`;
|
|
1846
|
+
renameSync(name, temporary);
|
|
1847
|
+
try {
|
|
1848
|
+
const captured = lstatSync(temporary, { bigint: true });
|
|
1849
|
+
if (captured.dev !== before.dev || captured.ino !== before.ino) {
|
|
1850
|
+
throw usageError(`SDK file changed during retirement: ${name}`);
|
|
1851
|
+
}
|
|
1852
|
+
const capturedDescriptor = openSync(temporary, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
1853
|
+
try {
|
|
1854
|
+
if (!readStablePinnedFile(capturedDescriptor, captured, `Retired SDK file ${name}`).equals(bytes)) {
|
|
1855
|
+
throw usageError(`SDK file changed during retirement: ${name}`);
|
|
1856
|
+
}
|
|
1857
|
+
} finally { closeSync(capturedDescriptor); }
|
|
1858
|
+
unlinkSync(temporary);
|
|
1859
|
+
} catch (error) {
|
|
1860
|
+
// O_EXCL hard-link restoration preserves an independently-created
|
|
1861
|
+
// replacement as well as the captured local bytes.
|
|
1862
|
+
try { linkSync(temporary, name); unlinkSync(temporary); } catch { /* Retain the captured file for reconciliation. */ }
|
|
1863
|
+
throw error;
|
|
1864
|
+
}
|
|
1865
|
+
return true;
|
|
1866
|
+
} finally { closeSync(descriptor); }
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1943
1869
|
try {
|
|
1944
1870
|
process.chdir(canonicalProjectDir);
|
|
1945
1871
|
assertPullTargetIdentity('.', projectIdentity);
|
|
@@ -1958,6 +1884,9 @@ export function syncEmbeddedSdk(projectDir, { log = null, templateSdkDir = TEMPL
|
|
|
1958
1884
|
if (mirrorFile(relPath.split('/'), source)) changed.push(relPath);
|
|
1959
1885
|
}
|
|
1960
1886
|
const templateSet = new Set(templateFiles);
|
|
1887
|
+
for (const [relPath, digests] of Object.entries(retiredFiles)) {
|
|
1888
|
+
if (!templateSet.has(relPath) && retireFile(relPath.split('/'), digests) === true) changed.push(relPath);
|
|
1889
|
+
}
|
|
1961
1890
|
foreign = listFilesRecursive('.').filter((relPath) => relPath.startsWith('src/') && !templateSet.has(relPath));
|
|
1962
1891
|
return null;
|
|
1963
1892
|
}));
|
|
@@ -2205,6 +2134,7 @@ function shouldExcludeSourceEntry(name) {
|
|
|
2205
2134
|
SOURCE_COPY_EXCLUDES_CASEFOLDED.has(casefolded)
|
|
2206
2135
|
|| casefolded.startsWith('.notis-app-pull-')
|
|
2207
2136
|
|| casefolded.startsWith('.notis-app-scaffold-')
|
|
2137
|
+
|| casefolded.startsWith('._notis_config_')
|
|
2208
2138
|
|| casefolded.startsWith('.env')
|
|
2209
2139
|
|| casefolded.endsWith('.pyc')
|
|
2210
2140
|
|| casefolded.endsWith('.pyo')
|
|
@@ -2341,7 +2271,8 @@ function parsePaxPath(data) {
|
|
|
2341
2271
|
}
|
|
2342
2272
|
|
|
2343
2273
|
function extractTarGz(buffer, targetDir) {
|
|
2344
|
-
|
|
2274
|
+
// Source snapshots allow 256 MiB of files plus bounded tar/PAX metadata.
|
|
2275
|
+
const tar = gunzipSync(buffer, { maxOutputLength: 320 * 1024 * 1024 });
|
|
2345
2276
|
let offset = 0;
|
|
2346
2277
|
let pendingPaxPath = null;
|
|
2347
2278
|
const extractedPaths = new Map();
|
|
@@ -2619,6 +2550,9 @@ async function pullAppSourceUnlocked({
|
|
|
2619
2550
|
force = false,
|
|
2620
2551
|
profileKey = null,
|
|
2621
2552
|
expectedUpdatedAt = null,
|
|
2553
|
+
downloadSource = null,
|
|
2554
|
+
sourceKind = 'app',
|
|
2555
|
+
sourceLink = null,
|
|
2622
2556
|
}, assertLockOwnership, assertParentIdentity, initialTargetIdentity) {
|
|
2623
2557
|
assertLockOwnership();
|
|
2624
2558
|
assertParentIdentity();
|
|
@@ -2629,18 +2563,25 @@ async function pullAppSourceUnlocked({
|
|
|
2629
2563
|
}
|
|
2630
2564
|
}
|
|
2631
2565
|
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2566
|
+
let archiveBuffer, pulledVersion;
|
|
2567
|
+
if (downloadSource) {
|
|
2568
|
+
const source = await downloadSource();
|
|
2569
|
+
archiveBuffer = source.archiveBuffer;
|
|
2570
|
+
pulledVersion = source.revision;
|
|
2571
|
+
} else {
|
|
2572
|
+
const params = new URLSearchParams({ app_id: appId, version: String(version || 'latest') });
|
|
2573
|
+
const response = await fetch(`${apiBase.replace(/\/$/, '')}/portal_apps/source?${params.toString()}`, {
|
|
2574
|
+
headers: { Authorization: `Bearer ${jwt}` },
|
|
2575
|
+
});
|
|
2576
|
+
if (!response.ok) {
|
|
2577
|
+
const data = await response.json().catch(() => ({}));
|
|
2578
|
+
throw usageError(typeof data?.error === 'string' ? data.error : `Failed to pull app source (${response.status}).`);
|
|
2579
|
+
}
|
|
2580
|
+
const contentDisposition = response.headers.get('content-disposition') || '';
|
|
2581
|
+
const versionMatch = /-v(\d+)\.tar\.gz/i.exec(contentDisposition);
|
|
2582
|
+
pulledVersion = versionMatch ? Number.parseInt(versionMatch[1], 10) : null;
|
|
2583
|
+
archiveBuffer = Buffer.from(await response.arrayBuffer());
|
|
2639
2584
|
}
|
|
2640
|
-
|
|
2641
|
-
const contentDisposition = response.headers.get('content-disposition') || '';
|
|
2642
|
-
const versionMatch = /-v(\d+)\.tar\.gz/i.exec(contentDisposition);
|
|
2643
|
-
const pulledVersion = versionMatch ? Number.parseInt(versionMatch[1], 10) : null;
|
|
2644
2585
|
const requestedVersion = String(version || 'latest') === 'latest'
|
|
2645
2586
|
? null
|
|
2646
2587
|
: Number.parseInt(String(version), 10);
|
|
@@ -2659,7 +2600,6 @@ async function pullAppSourceUnlocked({
|
|
|
2659
2600
|
);
|
|
2660
2601
|
}
|
|
2661
2602
|
const linkedVersion = pulledVersion;
|
|
2662
|
-
const archiveBuffer = Buffer.from(await response.arrayBuffer());
|
|
2663
2603
|
assertParentIdentity();
|
|
2664
2604
|
assertLockOwnership();
|
|
2665
2605
|
|
|
@@ -2730,8 +2670,9 @@ async function pullAppSourceUnlocked({
|
|
|
2730
2670
|
throw usageError('The app source archive did not contain any editable source files.');
|
|
2731
2671
|
}
|
|
2732
2672
|
|
|
2733
|
-
const
|
|
2734
|
-
const
|
|
2673
|
+
const stateFile = sourceKind === 'space' ? join(NOTIS_DIR, 'space-source.json') : STATE_FILE;
|
|
2674
|
+
const previousStatePath = join(targetRoot, stateFile);
|
|
2675
|
+
const backupStatePath = join(backupDir, stateFile);
|
|
2735
2676
|
const hadPreviousState = existsSync(previousStatePath);
|
|
2736
2677
|
if (hadPreviousState) {
|
|
2737
2678
|
mkdirSync(dirname(backupStatePath), { recursive: true });
|
|
@@ -2781,12 +2722,22 @@ async function pullAppSourceUnlocked({
|
|
|
2781
2722
|
}
|
|
2782
2723
|
assertPullTargetIdentity(targetDir, activeTargetIdentity);
|
|
2783
2724
|
assertLockOwnership();
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2725
|
+
if (sourceKind === 'space') {
|
|
2726
|
+
withAppReleaseWorkspace(targetRoot, () => {
|
|
2727
|
+
const temporary = `.space-source-${randomUUID()}.json`;
|
|
2728
|
+
try {
|
|
2729
|
+
writeFileSync(temporary, JSON.stringify({ ...sourceLink, revision: linkedVersion }), { flag: 'wx', mode: 0o600 });
|
|
2730
|
+
renameSync(temporary, 'space-source.json');
|
|
2731
|
+
} finally { rmSync(temporary, { force: true }); }
|
|
2732
|
+
}, { create: true });
|
|
2733
|
+
} else {
|
|
2734
|
+
writeLinkedState(targetRoot, {
|
|
2735
|
+
app_id: appId,
|
|
2736
|
+
...(Number.isFinite(linkedVersion) ? { version: linkedVersion } : {}),
|
|
2737
|
+
...(expectedUpdatedAt ? { expected_updated_at: expectedUpdatedAt } : {}),
|
|
2738
|
+
linked_at: new Date().toISOString(),
|
|
2739
|
+
}, profileKey);
|
|
2740
|
+
}
|
|
2790
2741
|
assertPullTargetIdentity(targetDir, activeTargetIdentity);
|
|
2791
2742
|
assertLockOwnership();
|
|
2792
2743
|
} catch (error) {
|
|
@@ -2928,6 +2879,180 @@ function withAppReleaseWorkspace(projectDir, operation, { create = false, expect
|
|
|
2928
2879
|
}
|
|
2929
2880
|
}
|
|
2930
2881
|
|
|
2882
|
+
function freezePinnedArtifactSnapshot(workspace, files, sourceFiles) {
|
|
2883
|
+
for (const [source, entries] of [[true, sourceFiles], [false, files]]) {
|
|
2884
|
+
for (const [path, encoded] of Object.entries(entries)) {
|
|
2885
|
+
const segments = path.split('/');
|
|
2886
|
+
if (!path || path.includes('\\') || path.includes('\0') || segments.some(part => !part || part === '.' || part === '..')
|
|
2887
|
+
|| (source && segments.some(part => ['.notis', '.context', '.git', 'node_modules'].includes(part) || part.startsWith('.env')))
|
|
2888
|
+
|| typeof encoded !== 'string') throw usageError('Unsafe frozen source or artifact path.');
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
// Nest under the source project so imports resolve its installed dependencies.
|
|
2892
|
+
// Every staging write is relative to pinned directories, never a replaced parent path.
|
|
2893
|
+
const frozenName = mkdtempSync('release-');
|
|
2894
|
+
const frozenIdentity = capturePullTargetIdentity(frozenName);
|
|
2895
|
+
const frozenDir = join(workspace.projectDir, NOTIS_DIR, frozenName);
|
|
2896
|
+
let closed = false;
|
|
2897
|
+
const close = () => {
|
|
2898
|
+
process.removeListener('exit', close);
|
|
2899
|
+
if (closed) return;
|
|
2900
|
+
withAppReleaseWorkspace(workspace.projectDir, () => {
|
|
2901
|
+
assertPullTargetIdentity(frozenName, frozenIdentity);
|
|
2902
|
+
rmSync(frozenName, { recursive: true, force: true });
|
|
2903
|
+
}, { expected: workspace });
|
|
2904
|
+
closed = true;
|
|
2905
|
+
};
|
|
2906
|
+
function writeEntry(segments, content) {
|
|
2907
|
+
const name = segments[0];
|
|
2908
|
+
if (segments.length === 1) {
|
|
2909
|
+
writeFileSync(name, content, { flag: 'wx' });
|
|
2910
|
+
return;
|
|
2911
|
+
}
|
|
2912
|
+
const parentIdentity = capturePullTargetIdentity('.');
|
|
2913
|
+
let identity = capturePullTargetIdentity(name);
|
|
2914
|
+
if (!identity.exists) {
|
|
2915
|
+
mkdirSync(name);
|
|
2916
|
+
identity = capturePullTargetIdentity(name);
|
|
2917
|
+
}
|
|
2918
|
+
process.chdir(name);
|
|
2919
|
+
try {
|
|
2920
|
+
assertPullTargetIdentity('.', identity);
|
|
2921
|
+
writeEntry(segments.slice(1), content);
|
|
2922
|
+
} finally {
|
|
2923
|
+
process.chdir('..');
|
|
2924
|
+
assertPullTargetIdentity('.', parentIdentity);
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
try {
|
|
2928
|
+
process.chdir(frozenName);
|
|
2929
|
+
try {
|
|
2930
|
+
assertPullTargetIdentity('.', frozenIdentity);
|
|
2931
|
+
for (const [path, encoded] of Object.entries(sourceFiles)) {
|
|
2932
|
+
writeEntry(path.split('/'), Buffer.from(encoded, 'base64'));
|
|
2933
|
+
}
|
|
2934
|
+
for (const [path, encoded] of Object.entries(files)) {
|
|
2935
|
+
writeEntry([NOTIS_DIR, 'output', ...path.split('/')], Buffer.from(encoded, 'base64'));
|
|
2936
|
+
}
|
|
2937
|
+
} finally {
|
|
2938
|
+
process.chdir('..');
|
|
2939
|
+
assertPullTargetIdentity('.', workspace.notisIdentity);
|
|
2940
|
+
}
|
|
2941
|
+
const manifest = files['manifest.json'] ? JSON.parse(Buffer.from(files['manifest.json'], 'base64').toString('utf8')) : null;
|
|
2942
|
+
process.once('exit', close);
|
|
2943
|
+
return { projectDir: frozenDir, files, sourceFiles, manifest, close };
|
|
2944
|
+
} catch (error) {
|
|
2945
|
+
try { close(); } catch { /* Retain owned staging if its parent identity changed. */ }
|
|
2946
|
+
throw error;
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
/** Freeze a selected source slice using the same pinned-directory writer as releases. */
|
|
2951
|
+
export function freezeSourceWorkspace(projectDir, sourceFiles) {
|
|
2952
|
+
return withAppReleaseWorkspace(projectDir, workspace => freezePinnedArtifactSnapshot(workspace, {}, sourceFiles), { create: true });
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
function selectedBuildName(key) {
|
|
2956
|
+
if (typeof key !== 'string' || !/^[a-z][a-z0-9_-]{0,99}$/.test(key)) throw usageError('Choose a valid selected build key.');
|
|
2957
|
+
return `space-build-${key}.json`;
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
/** Immutable bytes, not another authoring project or installed dependency tree. */
|
|
2961
|
+
export function saveSelectedBuild(projectDir, key, release) {
|
|
2962
|
+
return withAppReleaseWorkspace(projectDir, () => {
|
|
2963
|
+
const name = selectedBuildName(key), temporary = `.${name}-${randomUUID()}`;
|
|
2964
|
+
const snapshot = { version: 1, key, files: release.files, sourceFiles: release.sourceFiles,
|
|
2965
|
+
source_hash: appFilesDigest(release.sourceFiles), artifact_hash: appFilesDigest(release.files) };
|
|
2966
|
+
try {
|
|
2967
|
+
writeFileSync(temporary, JSON.stringify(snapshot), { flag: 'wx', mode: 0o600 });
|
|
2968
|
+
renameSync(temporary, name);
|
|
2969
|
+
} finally { rmSync(temporary, { force: true }); }
|
|
2970
|
+
return join(projectDir, NOTIS_DIR, name);
|
|
2971
|
+
}, { create: true });
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
export function prepareSelectedBuild(projectDir, key, sourceFiles) {
|
|
2975
|
+
return withAppReleaseWorkspace(projectDir, workspace => {
|
|
2976
|
+
const name = selectedBuildName(key);
|
|
2977
|
+
let before;
|
|
2978
|
+
try { before = lstatSync(name, { bigint: true }); } catch (error) {
|
|
2979
|
+
if (error.code === 'ENOENT') throw usageError('Selected Space build is missing or stale. Build it again.');
|
|
2980
|
+
throw error;
|
|
2981
|
+
}
|
|
2982
|
+
if (!before.isFile() || before.isSymbolicLink()) throw usageError('Unsafe selected build receipt.');
|
|
2983
|
+
const descriptor = openSync(name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
2984
|
+
let snapshot;
|
|
2985
|
+
try { snapshot = JSON.parse(readStablePinnedFile(descriptor, before, 'Selected build receipt').toString('utf8')); }
|
|
2986
|
+
finally { closeSync(descriptor); }
|
|
2987
|
+
if (snapshot.version !== 1 || snapshot.key !== key || !snapshot.files || !snapshot.sourceFiles
|
|
2988
|
+
|| snapshot.source_hash !== appFilesDigest(sourceFiles) || snapshot.source_hash !== appFilesDigest(snapshot.sourceFiles)
|
|
2989
|
+
|| snapshot.artifact_hash !== appFilesDigest(snapshot.files)) throw usageError('Selected Space build is missing or stale. Build it again.');
|
|
2990
|
+
validateArtifactBoundary(Object.fromEntries(Object.entries(snapshot.files).map(([path, encoded]) => [path, Buffer.from(encoded, 'base64')])));
|
|
2991
|
+
return freezePinnedArtifactSnapshot(workspace, snapshot.files, snapshot.sourceFiles);
|
|
2992
|
+
});
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
/** A diagnostic, never an approval stamp or a replacement for exact-byte checks. */
|
|
2996
|
+
export function saveSpaceVerification(projectDir, diagnostic) {
|
|
2997
|
+
return withAppReleaseWorkspace(projectDir, (workspace) => {
|
|
2998
|
+
const name = `space-verification-${randomUUID()}.json`;
|
|
2999
|
+
writeFileSync(name, JSON.stringify(diagnostic, null, 2), { flag: 'wx', mode: 0o600 });
|
|
3000
|
+
return join(workspace.projectDir, '.notis', name);
|
|
3001
|
+
});
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
/** Retain the original CAS after an uncertain publish response, across CLI exits. */
|
|
3005
|
+
export function spacePublicationIntent(projectDir, identity, create = null) {
|
|
3006
|
+
const name = `space-publish-${createHash('sha256').update(JSON.stringify(identity)).digest('hex')}.json`;
|
|
3007
|
+
return withAppReleaseWorkspace(projectDir, () => {
|
|
3008
|
+
if (create) {
|
|
3009
|
+
const temporary = `.${name}-${randomUUID()}`;
|
|
3010
|
+
try {
|
|
3011
|
+
writeFileSync(temporary, JSON.stringify(create), { flag: 'wx', mode: 0o600 });
|
|
3012
|
+
try { linkSync(temporary, name); } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
3013
|
+
} finally { rmSync(temporary, { force: true }); }
|
|
3014
|
+
}
|
|
3015
|
+
let before;
|
|
3016
|
+
try { before = lstatSync(name, { bigint: true }); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
3017
|
+
if (!before.isFile() || before.isSymbolicLink()) throw usageError('Unsafe Space publication receipt.');
|
|
3018
|
+
const descriptor = openSync(name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
3019
|
+
try { return JSON.parse(readStablePinnedFile(descriptor, before, 'Space publication receipt').toString('utf8')); }
|
|
3020
|
+
finally { closeSync(descriptor); }
|
|
3021
|
+
});
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
function spaceStateFileName(name) {
|
|
3025
|
+
if (typeof name !== 'string' || !/^[a-z][a-z0-9-]*\.json$/.test(name)) throw usageError('Unsafe local Space state file name.');
|
|
3026
|
+
return name;
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
/** Read one local-only JSON state file under the pinned .notis directory; null when absent. */
|
|
3030
|
+
export function readSpaceStateFile(projectDir, name) {
|
|
3031
|
+
spaceStateFileName(name);
|
|
3032
|
+
if (!existsSync(join(resolve(projectDir), NOTIS_DIR))) return null;
|
|
3033
|
+
return withAppReleaseWorkspace(projectDir, () => {
|
|
3034
|
+
let before;
|
|
3035
|
+
try { before = lstatSync(name, { bigint: true }); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
3036
|
+
if (!before.isFile() || before.isSymbolicLink()) throw usageError(`Unsafe local Space state file: ${name}.`);
|
|
3037
|
+
const descriptor = openSync(name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
3038
|
+
try { return JSON.parse(readStablePinnedFile(descriptor, before, `Space state file ${name}`).toString('utf8')); }
|
|
3039
|
+
finally { closeSync(descriptor); }
|
|
3040
|
+
});
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
/** Replace one local-only JSON state file atomically inside the pinned .notis directory. */
|
|
3044
|
+
export function writeSpaceStateFile(projectDir, name, value) {
|
|
3045
|
+
spaceStateFileName(name);
|
|
3046
|
+
return withAppReleaseWorkspace(projectDir, (workspace) => {
|
|
3047
|
+
const temporary = `.${name}-${randomUUID()}`;
|
|
3048
|
+
try {
|
|
3049
|
+
writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
3050
|
+
renameSync(temporary, name);
|
|
3051
|
+
} finally { rmSync(temporary, { force: true }); }
|
|
3052
|
+
return join(workspace.projectDir, NOTIS_DIR, name);
|
|
3053
|
+
}, { create: true });
|
|
3054
|
+
}
|
|
3055
|
+
|
|
2931
3056
|
/** Capture one checked build. Verification and upload must use these same bytes. */
|
|
2932
3057
|
export function prepareAppRelease(projectDir) {
|
|
2933
3058
|
let release;
|
|
@@ -2948,64 +3073,7 @@ export function prepareAppRelease(projectDir) {
|
|
|
2948
3073
|
if (receipt?.source_hash !== appFilesDigest(sourceFiles) || receipt?.artifact_hash !== appFilesDigest(files)) {
|
|
2949
3074
|
throw usageError('Build output is missing or stale. Run notis apps build before deploying.');
|
|
2950
3075
|
}
|
|
2951
|
-
|
|
2952
|
-
// Every staging write is relative to pinned directories, never a replaced parent path.
|
|
2953
|
-
const frozenName = mkdtempSync('release-');
|
|
2954
|
-
const frozenIdentity = capturePullTargetIdentity(frozenName);
|
|
2955
|
-
const frozenDir = join(workspace.projectDir, NOTIS_DIR, frozenName);
|
|
2956
|
-
let closed = false;
|
|
2957
|
-
const close = () => {
|
|
2958
|
-
process.removeListener('exit', close);
|
|
2959
|
-
if (closed) return;
|
|
2960
|
-
withAppReleaseWorkspace(workspace.projectDir, () => {
|
|
2961
|
-
assertPullTargetIdentity(frozenName, frozenIdentity);
|
|
2962
|
-
rmSync(frozenName, { recursive: true, force: true });
|
|
2963
|
-
}, { expected: workspace });
|
|
2964
|
-
closed = true;
|
|
2965
|
-
};
|
|
2966
|
-
function writeEntry(segments, content) {
|
|
2967
|
-
const name = segments[0];
|
|
2968
|
-
if (segments.length === 1) {
|
|
2969
|
-
writeFileSync(name, content, { flag: 'wx' });
|
|
2970
|
-
return;
|
|
2971
|
-
}
|
|
2972
|
-
const parentIdentity = capturePullTargetIdentity('.');
|
|
2973
|
-
let identity = capturePullTargetIdentity(name);
|
|
2974
|
-
if (!identity.exists) {
|
|
2975
|
-
mkdirSync(name);
|
|
2976
|
-
identity = capturePullTargetIdentity(name);
|
|
2977
|
-
}
|
|
2978
|
-
process.chdir(name);
|
|
2979
|
-
try {
|
|
2980
|
-
assertPullTargetIdentity('.', identity);
|
|
2981
|
-
writeEntry(segments.slice(1), content);
|
|
2982
|
-
} finally {
|
|
2983
|
-
process.chdir('..');
|
|
2984
|
-
assertPullTargetIdentity('.', parentIdentity);
|
|
2985
|
-
}
|
|
2986
|
-
}
|
|
2987
|
-
try {
|
|
2988
|
-
process.chdir(frozenName);
|
|
2989
|
-
try {
|
|
2990
|
-
assertPullTargetIdentity('.', frozenIdentity);
|
|
2991
|
-
for (const [path, encoded] of Object.entries(sourceFiles)) {
|
|
2992
|
-
writeEntry(path.split('/'), Buffer.from(encoded, 'base64'));
|
|
2993
|
-
}
|
|
2994
|
-
for (const [path, encoded] of Object.entries(files)) {
|
|
2995
|
-
writeEntry([NOTIS_DIR, 'output', ...path.split('/')], Buffer.from(encoded, 'base64'));
|
|
2996
|
-
}
|
|
2997
|
-
} finally {
|
|
2998
|
-
process.chdir('..');
|
|
2999
|
-
assertPullTargetIdentity('.', workspace.notisIdentity);
|
|
3000
|
-
}
|
|
3001
|
-
const manifest = JSON.parse(Buffer.from(files['manifest.json'], 'base64').toString('utf8'));
|
|
3002
|
-
process.once('exit', close);
|
|
3003
|
-
release = { projectDir: frozenDir, files, sourceFiles, manifest, close };
|
|
3004
|
-
return release;
|
|
3005
|
-
} catch (error) {
|
|
3006
|
-
try { close(); } catch { /* Retain owned staging if its parent identity changed. */ }
|
|
3007
|
-
throw error;
|
|
3008
|
-
}
|
|
3076
|
+
return release = freezePinnedArtifactSnapshot(workspace, files, sourceFiles);
|
|
3009
3077
|
});
|
|
3010
3078
|
} catch (error) {
|
|
3011
3079
|
try { release?.close(); } catch { /* Parent replacement retains staging, never follows it. */ }
|