@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.
Files changed (106) hide show
  1. package/README.md +479 -80
  2. package/config/portal-route-roots.json +48 -0
  3. package/dist/agent-hooks/notis-agent-hook.mjs +29072 -2784
  4. package/dist/base-skills/notis-apps/SKILL.md +88 -67
  5. package/dist/base-skills/notis-apps/references/architecture.md +114 -167
  6. package/dist/base-skills/notis-apps/references/design.md +26 -107
  7. package/dist/base-skills/notis-apps/references/memory.md +58 -0
  8. package/dist/base-skills/notis-apps/references/reading.md +52 -74
  9. package/dist/base-skills/notis-apps/references/release.md +104 -142
  10. package/dist/base-skills/notis-apps/references/sdk.md +113 -60
  11. package/dist/base-skills/notis-apps/references/troubleshooting.md +22 -21
  12. package/dist/base-skills/notis-apps/references/views.md +142 -0
  13. package/dist/base-skills/notis-cli/SKILL.md +4 -4
  14. package/dist/base-skills/notis-cli/references/app-delivery.md +12 -11
  15. package/dist/base-skills/notis-cli/references/native-databases.md +6 -6
  16. package/dist/base-skills/notis-cli/references/tool-examples.md +4 -4
  17. package/dist/base-skills/notis-query/SKILL.md +34 -60
  18. package/dist/base-skills/notis-query/references/database-discovery.md +59 -69
  19. package/dist/base-skills/notis-query/references/documents.md +63 -50
  20. package/dist/base-skills/notis-query/references/query.md +57 -524
  21. package/dist/sdk/src/bundleModule.ts +79 -0
  22. package/dist/sdk/src/components/DocumentEditor.tsx +6 -3
  23. package/dist/sdk/src/components/DocumentPage.tsx +141 -0
  24. package/dist/sdk/src/components/RecordComponents.tsx +33 -0
  25. package/dist/sdk/src/components/RenderChartData.tsx +19 -0
  26. package/dist/sdk/src/config.ts +4 -26
  27. package/dist/sdk/src/documents.ts +2 -0
  28. package/dist/sdk/src/hooks/useAction.ts +28 -0
  29. package/dist/sdk/src/hooks/useCloudComputer.ts +35 -4
  30. package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
  31. package/dist/sdk/src/hooks/useHandover.ts +5 -4
  32. package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
  33. package/dist/sdk/src/hooks/useNativeDocuments.ts +83 -0
  34. package/dist/sdk/src/hooks/useNotis.ts +3 -0
  35. package/dist/sdk/src/hooks/useNotisNavigation.ts +26 -11
  36. package/dist/sdk/src/hooks/useQuery.ts +7 -4
  37. package/dist/sdk/src/hooks/useViewParams.ts +121 -0
  38. package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
  39. package/dist/sdk/src/index.ts +36 -0
  40. package/dist/sdk/src/interactions/shortcuts.tsx +17 -3
  41. package/dist/sdk/src/nativeData.ts +39 -0
  42. package/dist/sdk/src/presentation.tsx +82 -0
  43. package/dist/sdk/src/provider.tsx +9 -4
  44. package/dist/sdk/src/queryCache.ts +5 -3
  45. package/dist/sdk/src/queryErrors.ts +51 -0
  46. package/dist/sdk/src/runtime.ts +146 -8
  47. package/dist/sdk/src/space.ts +425 -0
  48. package/dist/sdk/src/styles.css +96 -72
  49. package/dist/sdk/src/vite.ts +11 -1
  50. package/dist/skill-sync/index.js +458 -68
  51. package/dist/skill-sync/index.js.map +4 -4
  52. package/dist/skill-sync-worker.mjs +451 -77
  53. package/dist/space-harness/host.js +66 -0
  54. package/dist/view-renderer/dist/host.css +1 -0
  55. package/dist/view-renderer/dist/host.js +1042 -0
  56. package/dist/view-renderer/dist/theme.css +73 -0
  57. package/dist/view-renderer/src/dom.js +180 -0
  58. package/dist/view-renderer/src/errors.js +8 -0
  59. package/dist/view-renderer/src/host.tsx +173 -0
  60. package/dist/view-renderer/src/index.js +4 -0
  61. package/dist/view-renderer/src/pool.js +76 -0
  62. package/dist/view-renderer/src/remote-broker.js +59 -0
  63. package/dist/view-renderer/src/renderer.js +201 -0
  64. package/package.json +11 -5
  65. package/skills/notis-apps/cli.md +270 -163
  66. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +2 -2
  67. package/skills/notis-onboarding/BRIEF.md +7 -3
  68. package/src/command-specs/apps.js +1 -8
  69. package/src/command-specs/files.js +29 -0
  70. package/src/command-specs/helpers.js +1 -0
  71. package/src/command-specs/index.js +10 -4
  72. package/src/command-specs/skills.js +229 -0
  73. package/src/command-specs/spaces.js +572 -0
  74. package/src/command-specs/views.js +69 -0
  75. package/src/runtime/agent-browser.js +30 -7
  76. package/src/runtime/app-boundary-validator.js +7 -4
  77. package/src/runtime/app-data-literals.js +82 -0
  78. package/src/runtime/app-platform.js +321 -253
  79. package/src/runtime/confined-space-build.js +74 -0
  80. package/src/runtime/confined-space-verify.js +49 -0
  81. package/src/runtime/native-record-files.js +47 -0
  82. package/src/runtime/retired-sdk-files.js +20 -0
  83. package/src/runtime/skill-sync/cloud-client.ts +26 -4
  84. package/src/runtime/skill-sync/index.ts +154 -35
  85. package/src/runtime/skill-sync/local-scanner.ts +125 -17
  86. package/src/runtime/skill-sync/native-identity.ts +102 -0
  87. package/src/runtime/skill-sync/native-plan.ts +59 -0
  88. package/src/runtime/skill-sync/sync-plan.ts +16 -9
  89. package/src/runtime/skill-sync/types.ts +32 -6
  90. package/src/runtime/source-root.js +32 -0
  91. package/src/runtime/space-collection-scaffold.js +66 -0
  92. package/src/runtime/space-database-structures.js +229 -0
  93. package/src/runtime/space-fixture-broker.js +50 -0
  94. package/src/runtime/space-lock.js +337 -0
  95. package/src/runtime/space-platform.js +105 -0
  96. package/src/runtime/space-source.js +281 -0
  97. package/src/runtime/space-test-server.js +327 -0
  98. package/src/runtime/space-verification.js +89 -0
  99. package/src/runtime/space-verify-browser-worker.js +51 -0
  100. package/src/runtime/space-view-manifest.js +254 -0
  101. package/src/runtime/sync-skills.js +1 -1
  102. package/src/runtime/transport.js +8 -1
  103. package/src/runtime/view-render.js +49 -0
  104. package/src/space-harness-host.ts +103 -0
  105. package/template/app/globals.css +1 -0
  106. 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*{[\s\S]*?\bdefineNotisApp\b[\s\S]*?}\s+from\s+['"]@notis\/sdk\/config['"]\s*;?/g, '')
169
- .replace(/defineNotisApp\s*\(/g, '(');
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 ts = requireFromConfig('typescript');
182
- const transpiled = ts.transpileModule(stripped, {
183
- compilerOptions: {
184
- module: ts.ModuleKind.ESNext,
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
- // For .js/.mjs files, import directly. For .ts, we need transpilation.
212
- if (configPath.endsWith('.ts')) {
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), '._notis_config_tmp.mjs');
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', 'skills', 'automations', 'onboarding', 'tagline', 'categories', 'author', 'screenshots'].some(key => {
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, skills, automations or Store metadata.');
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
- projectDir = realpathSync(projectDir);
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 prepareArtifactBuild(projectDir);
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
- await runProjectScript({
1123
- projectDir,
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 `packages/sdk` copy identical to the SDK this CLI
1873
- * ships. The copy is a mirror by contract (the parity test in the CLI keeps
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) => relPath === 'package.json' || relPath === 'tsconfig.json' || relPath.startsWith('src/'),
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
- const tar = gunzipSync(buffer);
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
- const params = new URLSearchParams({ app_id: appId, version: String(version || 'latest') });
2633
- const response = await fetch(`${apiBase.replace(/\/$/, '')}/portal_apps/source?${params.toString()}`, {
2634
- headers: { Authorization: `Bearer ${jwt}` },
2635
- });
2636
- if (!response.ok) {
2637
- const data = await response.json().catch(() => ({}));
2638
- throw usageError(typeof data?.error === 'string' ? data.error : `Failed to pull app source (${response.status}).`);
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 previousStatePath = join(targetRoot, STATE_FILE);
2734
- const backupStatePath = join(backupDir, STATE_FILE);
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
- writeLinkedState(targetRoot, {
2785
- app_id: appId,
2786
- ...(Number.isFinite(linkedVersion) ? { version: linkedVersion } : {}),
2787
- ...(expectedUpdatedAt ? { expected_updated_at: expectedUpdatedAt } : {}),
2788
- linked_at: new Date().toISOString(),
2789
- }, profileKey);
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
- // Nest under the source project so imports resolve its installed dependencies.
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. */ }