@notis_ai/cli 0.2.0-beta.19.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 (216) hide show
  1. package/README.md +822 -135
  2. package/bin/check-runtime.js +15 -0
  3. package/bin/notis.js +2 -0
  4. package/config/notis_app_boundary_rules.json +50 -0
  5. package/config/notis_app_design_rules.json +135 -0
  6. package/config/portal-route-roots.json +48 -0
  7. package/dist/agent-hooks/notis-agent-hook.mjs +45587 -0
  8. package/dist/base-skills/notis-apps/SKILL.md +93 -0
  9. package/dist/base-skills/notis-apps/references/architecture.md +114 -0
  10. package/dist/base-skills/notis-apps/references/context.md +81 -0
  11. package/dist/base-skills/notis-apps/references/design.md +97 -0
  12. package/dist/base-skills/notis-apps/references/memory.md +58 -0
  13. package/dist/base-skills/notis-apps/references/reading.md +67 -0
  14. package/dist/base-skills/notis-apps/references/release.md +107 -0
  15. package/dist/base-skills/notis-apps/references/sdk.md +116 -0
  16. package/dist/base-skills/notis-apps/references/troubleshooting.md +24 -0
  17. package/dist/base-skills/notis-apps/references/views.md +142 -0
  18. package/dist/base-skills/notis-cli/SKILL.md +154 -0
  19. package/dist/base-skills/notis-cli/references/app-delivery.md +19 -0
  20. package/dist/base-skills/notis-cli/references/intelligence.md +94 -0
  21. package/dist/base-skills/notis-cli/references/native-databases.md +21 -0
  22. package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
  23. package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
  24. package/dist/base-skills/notis-query/SKILL.md +41 -0
  25. package/dist/base-skills/notis-query/references/database-discovery.md +60 -0
  26. package/dist/base-skills/notis-query/references/documents.md +63 -0
  27. package/dist/base-skills/notis-query/references/query.md +76 -0
  28. package/{template/packages/notis-sdk → dist/sdk}/package.json +14 -4
  29. package/dist/sdk/src/agentContext.ts +36 -0
  30. package/dist/sdk/src/bundleModule.ts +79 -0
  31. package/dist/sdk/src/components/Dialog.tsx +46 -0
  32. package/dist/sdk/src/components/DocumentEditor.tsx +106 -0
  33. package/dist/sdk/src/components/DocumentPage.tsx +141 -0
  34. package/dist/sdk/src/components/Markdown.tsx +60 -0
  35. package/dist/sdk/src/components/MarkdownEditor.tsx +121 -0
  36. package/dist/sdk/src/components/MultiSelectActionBar.tsx +285 -0
  37. package/dist/sdk/src/components/MultiSelectCheckbox.tsx +97 -0
  38. package/dist/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  39. package/dist/sdk/src/components/NotisCommentBoundary.tsx +172 -0
  40. package/dist/sdk/src/components/NotisSelectionBoundary.tsx +59 -0
  41. package/dist/sdk/src/components/RecordComponents.tsx +33 -0
  42. package/dist/sdk/src/components/RenderChartData.tsx +19 -0
  43. package/dist/sdk/src/components/ShortcutHints.tsx +56 -0
  44. package/dist/sdk/src/components/Skeleton.tsx +24 -0
  45. package/dist/sdk/src/config.ts +239 -0
  46. package/dist/sdk/src/documents.ts +258 -0
  47. package/dist/sdk/src/hooks/useAction.ts +28 -0
  48. package/dist/sdk/src/hooks/useActiveResource.ts +19 -0
  49. package/dist/sdk/src/hooks/useAgentContext.ts +23 -0
  50. package/dist/sdk/src/hooks/useCloudComputer.ts +95 -0
  51. package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
  52. package/dist/sdk/src/hooks/useCollectionInteractions.ts +838 -0
  53. package/dist/sdk/src/hooks/useDatabaseSchema.ts +49 -0
  54. package/dist/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  55. package/dist/sdk/src/hooks/useDocument.ts +43 -0
  56. package/dist/sdk/src/hooks/useDocuments.ts +84 -0
  57. package/dist/sdk/src/hooks/useHandover.ts +78 -0
  58. package/dist/sdk/src/hooks/useLongPressSelection.ts +79 -0
  59. package/dist/sdk/src/hooks/useMultiSelect.ts +95 -0
  60. package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
  61. package/dist/sdk/src/hooks/useNativeDocuments.ts +83 -0
  62. package/dist/sdk/src/hooks/useNotis.ts +42 -0
  63. package/dist/sdk/src/hooks/useNotisNavigation.ts +67 -0
  64. package/dist/sdk/src/hooks/useQuery.ts +74 -0
  65. package/dist/sdk/src/hooks/useTool.ts +65 -0
  66. package/dist/sdk/src/hooks/useToolQuery.ts +12 -0
  67. package/dist/sdk/src/hooks/useTopBarSearch.ts +81 -0
  68. package/dist/sdk/src/hooks/useUpsertDocument.ts +95 -0
  69. package/dist/sdk/src/hooks/useViewParams.ts +121 -0
  70. package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
  71. package/dist/sdk/src/index.ts +200 -0
  72. package/dist/sdk/src/interactions/actions.ts +59 -0
  73. package/dist/sdk/src/interactions/shortcuts.tsx +740 -0
  74. package/dist/sdk/src/interactions/visibility.ts +13 -0
  75. package/dist/sdk/src/interactions.ts +45 -0
  76. package/dist/sdk/src/nativeData.ts +39 -0
  77. package/dist/sdk/src/presentation.tsx +82 -0
  78. package/dist/sdk/src/provider.tsx +49 -0
  79. package/dist/sdk/src/queryCache.ts +172 -0
  80. package/dist/sdk/src/queryErrors.ts +51 -0
  81. package/dist/sdk/src/runtime.ts +603 -0
  82. package/dist/sdk/src/space.ts +425 -0
  83. package/dist/sdk/src/styles.css +290 -0
  84. package/dist/sdk/src/tailwind.ts +66 -0
  85. package/dist/sdk/src/vite.ts +83 -0
  86. package/dist/skill-sync/index.js +2143 -0
  87. package/dist/skill-sync/index.js.map +7 -0
  88. package/dist/skill-sync-worker.mjs +3487 -0
  89. package/dist/space-harness/host.js +66 -0
  90. package/dist/view-renderer/dist/host.css +1 -0
  91. package/dist/view-renderer/dist/host.js +1042 -0
  92. package/dist/view-renderer/dist/theme.css +73 -0
  93. package/dist/view-renderer/src/dom.js +180 -0
  94. package/dist/view-renderer/src/errors.js +8 -0
  95. package/dist/view-renderer/src/host.tsx +173 -0
  96. package/dist/view-renderer/src/index.js +4 -0
  97. package/dist/view-renderer/src/pool.js +76 -0
  98. package/dist/view-renderer/src/remote-broker.js +59 -0
  99. package/dist/view-renderer/src/renderer.js +201 -0
  100. package/package.json +25 -8
  101. package/skills/notis-apps/cli.md +406 -0
  102. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
  103. package/skills/notis-onboarding/BRIEF.md +133 -0
  104. package/skills/notis-query/cli.md +39 -0
  105. package/src/agent-hook-entry.js +5 -0
  106. package/src/cli.js +294 -25
  107. package/src/command-specs/agents.js +392 -0
  108. package/src/command-specs/apps.js +1476 -203
  109. package/src/command-specs/auth.js +114 -137
  110. package/src/command-specs/diagnostics.js +730 -0
  111. package/src/command-specs/files.js +29 -0
  112. package/src/command-specs/handover.js +374 -0
  113. package/src/command-specs/helpers.js +86 -82
  114. package/src/command-specs/index.js +31 -6
  115. package/src/command-specs/meta.js +150 -18
  116. package/src/command-specs/onboarding.js +290 -0
  117. package/src/command-specs/profile.js +358 -0
  118. package/src/command-specs/skills.js +304 -0
  119. package/src/command-specs/smoke.js +386 -0
  120. package/src/command-specs/spaces.js +572 -0
  121. package/src/command-specs/tools.js +455 -139
  122. package/src/command-specs/views.js +69 -0
  123. package/src/runtime/agent-browser.js +700 -0
  124. package/src/runtime/agent-memory-state.js +126 -0
  125. package/src/runtime/agent-setup.js +383 -0
  126. package/src/runtime/app-boundary-validator.js +407 -0
  127. package/src/runtime/app-changelog.js +79 -0
  128. package/src/runtime/app-data-literals.js +82 -0
  129. package/src/runtime/app-platform.js +2752 -237
  130. package/src/runtime/app-registry-scaffolds.js +367 -0
  131. package/src/runtime/app-test-server.js +293 -0
  132. package/src/runtime/assets/store-screenshot-dark.png +0 -0
  133. package/src/runtime/auth-recovery.js +110 -0
  134. package/src/runtime/base-skills.d.ts +20 -0
  135. package/src/runtime/base-skills.js +167 -0
  136. package/src/runtime/channel.js +133 -0
  137. package/src/runtime/confined-space-build.js +74 -0
  138. package/src/runtime/confined-space-verify.js +49 -0
  139. package/src/runtime/delegated-context.js +68 -0
  140. package/src/runtime/errors.js +1 -0
  141. package/src/runtime/git.js +233 -0
  142. package/src/runtime/login-listener.js +15 -0
  143. package/src/runtime/native-record-files.js +47 -0
  144. package/src/runtime/oauth.js +2622 -0
  145. package/src/runtime/output.js +37 -5
  146. package/src/runtime/ports.js +31 -0
  147. package/src/runtime/profiles.js +906 -55
  148. package/src/runtime/retired-sdk-files.js +20 -0
  149. package/src/runtime/screenshot-framing.js +85 -0
  150. package/src/runtime/skill-sync/cloud-client.ts +121 -0
  151. package/src/runtime/skill-sync/index.ts +905 -0
  152. package/src/runtime/skill-sync/local-scanner.ts +1259 -0
  153. package/src/runtime/skill-sync/native-identity.ts +102 -0
  154. package/src/runtime/skill-sync/native-plan.ts +59 -0
  155. package/src/runtime/skill-sync/symlink-manager.ts +433 -0
  156. package/src/runtime/skill-sync/sync-plan.ts +54 -0
  157. package/src/runtime/skill-sync/types.ts +157 -0
  158. package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
  159. package/src/runtime/skill-sync-service.js +109 -0
  160. package/src/runtime/source-root.js +32 -0
  161. package/src/runtime/space-collection-scaffold.js +66 -0
  162. package/src/runtime/space-database-structures.js +229 -0
  163. package/src/runtime/space-fixture-broker.js +50 -0
  164. package/src/runtime/space-lock.js +337 -0
  165. package/src/runtime/space-platform.js +105 -0
  166. package/src/runtime/space-source.js +281 -0
  167. package/src/runtime/space-test-server.js +327 -0
  168. package/src/runtime/space-verification.js +89 -0
  169. package/src/runtime/space-verify-browser-worker.js +51 -0
  170. package/src/runtime/space-view-manifest.js +254 -0
  171. package/src/runtime/store-screenshot.js +146 -0
  172. package/src/runtime/sync-skills.d.ts +37 -0
  173. package/src/runtime/sync-skills.js +231 -0
  174. package/src/runtime/telemetry.js +92 -0
  175. package/src/runtime/transport.js +331 -45
  176. package/src/runtime/view-render.js +49 -0
  177. package/src/skill-sync-worker-entry.js +2 -0
  178. package/src/skill-sync-worker.js +50 -0
  179. package/src/space-harness-host.ts +103 -0
  180. package/template/.harness/index.html.tmpl +435 -0
  181. package/template/CHANGELOG.md +5 -0
  182. package/template/app/globals.css +29 -3
  183. package/template/app/layout.tsx +6 -3
  184. package/template/app/page.tsx +49 -42
  185. package/template/components/page-heading.tsx +23 -0
  186. package/template/components/ui/badge.tsx +7 -4
  187. package/template/components/ui/button.tsx +1 -1
  188. package/template/components/ui/card.tsx +24 -11
  189. package/template/components/ui/native-select.tsx +24 -0
  190. package/template/notis.config.ts +24 -6
  191. package/template/package-lock.json +3642 -0
  192. package/template/package.json +19 -16
  193. package/template/postcss.config.mjs +1 -1
  194. package/template/tailwind.config.ts +1 -6
  195. package/template/tsconfig.json +1 -0
  196. package/src/command-specs/db.js +0 -163
  197. package/src/runtime/app-preview-server.js +0 -312
  198. package/template/packages/notis-sdk/src/config.ts +0 -48
  199. package/template/packages/notis-sdk/src/helpers.ts +0 -131
  200. package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
  201. package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
  202. package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
  203. package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
  204. package/template/packages/notis-sdk/src/hooks/useNotis.ts +0 -31
  205. package/template/packages/notis-sdk/src/hooks/useNotisNavigation.ts +0 -49
  206. package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
  207. package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
  208. package/template/packages/notis-sdk/src/index.ts +0 -47
  209. package/template/packages/notis-sdk/src/provider.tsx +0 -44
  210. package/template/packages/notis-sdk/src/runtime.ts +0 -159
  211. package/template/packages/notis-sdk/src/styles.css +0 -123
  212. package/template/packages/notis-sdk/src/vite.ts +0 -54
  213. package/template/packages/notis-sdk/tsconfig.json +0 -15
  214. /package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useBackend.ts +0 -0
  215. /package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useTools.ts +0 -0
  216. /package/{template/packages/notis-sdk → dist/sdk}/src/ui.ts +0 -0
@@ -7,26 +7,148 @@
7
7
  */
8
8
 
9
9
  import { spawn } from 'node:child_process';
10
- import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync, readdirSync } from 'node:fs';
10
+ import { createHash, randomUUID } from 'node:crypto';
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';
11
12
  import { createRequire } from 'node:module';
12
- import { dirname, join, resolve } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
13
15
  import { fileURLToPath } from 'node:url';
16
+ import { gunzipSync } from 'node:zlib';
14
17
 
15
18
  import { usageError } from './errors.js';
19
+ import { acquireScaffoldSource, loadScaffoldCatalog } from './app-registry-scaffolds.js';
20
+ import { validateArtifactBoundary, validateProjectBoundary, validateProjectDesign } from './app-boundary-validator.js';
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';
16
24
 
17
25
  const NOTIS_DIR = '.notis';
18
26
  const STATE_FILE = join(NOTIS_DIR, 'state.json');
19
27
  const OUTPUT_DIR = join(NOTIS_DIR, 'output');
20
28
  const BUNDLE_DIR = join(OUTPUT_DIR, 'bundle');
21
29
  const MANIFEST_FILE = join(OUTPUT_DIR, 'manifest.json');
30
+ const METADATA_DIR = 'metadata';
31
+ const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
32
+ const TEMPLATE_SDK_DIR = join(CLI_ROOT, 'dist', 'sdk');
33
+ export const NOTIS_APP_CATEGORIES = [
34
+ 'Productivity',
35
+ 'Sales & Marketing',
36
+ 'Operations',
37
+ 'Product & Engineering',
38
+ 'Personal',
39
+ ];
40
+ export const MIN_LISTING_SCREENSHOTS = 3;
41
+ const SOURCE_COPY_EXCLUDES = new Set([
42
+ 'node_modules',
43
+ '.notis',
44
+ '.git',
45
+ '.context',
46
+ 'dist',
47
+ 'tsconfig.tsbuildinfo',
48
+ '.DS_Store',
49
+ // Interpreter droppings: a stray `python -m py_compile` in a skill's
50
+ // scripts/ directory must not ship version-specific bytecode in the bundle.
51
+ '__pycache__',
52
+ ]);
53
+ const SOURCE_COPY_EXCLUDES_CASEFOLDED = new Set(
54
+ [...SOURCE_COPY_EXCLUDES].map((name) => name.toLocaleLowerCase('en-US')),
55
+ );
56
+ const SCAFFOLD_COPY_EXCLUDES = new Set([
57
+ ...SOURCE_COPY_EXCLUDES,
58
+ 'coverage',
59
+ '.next',
60
+ '.turbo',
61
+ ]);
62
+ // Listing media describes the scaffold's own Store entry, so a new project must
63
+ // never inherit it. Scaffold packaging still ships these files -- only the
64
+ // `apps init` copy drops them. Screenshots only: `screenshot-fixtures.json` is
65
+ // not listing media, it is the stub data the dev harness serves, and dropping
66
+ // it would make every route of a fresh project render its empty state.
67
+ const SCAFFOLD_LISTING_MEDIA = /^metadata\/screenshot-\d+\.png$/i;
68
+ const PULL_LOCK_TIMEOUT_MS = 30_000;
69
+ const PULL_LOCK_POLL_MS = 25;
70
+ const STATE_WRITE_LOCK_TIMEOUT_MS = 5_000;
71
+ const STATE_WRITE_LOCK_STALE_MS = 2_000;
72
+ const stateWriteLockWait = new Int32Array(new SharedArrayBuffer(4));
22
73
  let appConfigImportNonce = 0;
23
74
 
24
75
  // ---------------------------------------------------------------------------
25
76
  // Project directory resolution
26
77
  // ---------------------------------------------------------------------------
27
78
 
79
+ // Every Notis app the user did not deliberately place lives here, mirroring the
80
+ // desktop's synced-skills root (~/.notis/skills). A default that depends on the
81
+ // caller's working directory is unusable for agents: the shell sits wherever
82
+ // the previous command left it, so `apps init` would scatter projects into
83
+ // unrelated checkouts. Pass an explicit [dir] to override -- that is how an app
84
+ // ends up in a tracked git repo or an existing monorepo.
85
+ export const DEFAULT_APPS_ROOT = join(homedir(), NOTIS_DIR, 'apps');
86
+
87
+ export function defaultAppProjectDir(slug) {
88
+ const safeSlug = String(slug || '').trim();
89
+ if (!safeSlug || safeSlug.includes('/') || safeSlug.includes('\\') || safeSlug.startsWith('.')) {
90
+ throw usageError(`Cannot derive a default project directory from "${slug}". Pass an explicit target directory.`);
91
+ }
92
+ return join(DEFAULT_APPS_ROOT, safeSlug);
93
+ }
94
+
95
+ // A `~/...` path only expands when a shell is involved. Agents routinely spawn
96
+ // the CLI without one, and the documented default home is written with a tilde,
97
+ // so expand it here instead of creating a literal "~" directory.
98
+ export function expandHomePath(inputPath) {
99
+ const value = String(inputPath ?? '');
100
+ if (value === '~') {
101
+ return homedir();
102
+ }
103
+ if (value.startsWith('~/') || value.startsWith('~\\')) {
104
+ return join(homedir(), value.slice(2));
105
+ }
106
+ return value;
107
+ }
108
+
28
109
  export function resolveProjectDir(inputDir = '.') {
29
- return resolve(process.cwd(), inputDir);
110
+ return resolve(process.cwd(), expandHomePath(inputDir));
111
+ }
112
+
113
+ export function getBundleDir(projectDir) {
114
+ return join(projectDir, BUNDLE_DIR);
115
+ }
116
+
117
+ export function resolveBuiltBundleDir(projectDir) {
118
+ const bundleDir = getBundleDir(projectDir);
119
+ if (existsSync(join(bundleDir, 'app.js'))) {
120
+ return bundleDir;
121
+ }
122
+ return null;
123
+ }
124
+
125
+ function normalizeShadowScopedCss(css) {
126
+ return css
127
+ // Keep defaults on both the Portal shadow host and the app root used
128
+ // by the standalone harness. Removing :root erases Tailwind v4 theme
129
+ // variables in previews, collapsing spacing, type sizes and controls.
130
+ .replace(/(?:html|:root)\s*,\s*:host\s*\{/g, '[data-notis-app-root],:host{')
131
+ .replace(/:root\s*\{/g, '[data-notis-app-root],:host{')
132
+ .replace(/html\s*\{/g, '[data-notis-app-root],:host{')
133
+ // Shadow trees do not contain a body element. Route those defaults to the
134
+ // app root contract instead so authors still get the expected reset.
135
+ .replace(/body\s*\{/g, '[data-notis-app-root]{');
136
+ }
137
+
138
+ function normalizeBundleStylesheets(projectDir) {
139
+ const bundleDir = join(projectDir, BUNDLE_DIR);
140
+ if (!existsSync(bundleDir)) {
141
+ return;
142
+ }
143
+
144
+ for (const entry of readdirSync(bundleDir)) {
145
+ if (!entry.endsWith('.css')) {
146
+ continue;
147
+ }
148
+ const cssPath = join(bundleDir, entry);
149
+ const normalizedCss = normalizeShadowScopedCss(readFileSync(cssPath, 'utf-8'));
150
+ writeFileSync(cssPath, normalizedCss);
151
+ }
30
152
  }
31
153
 
32
154
  // ---------------------------------------------------------------------------
@@ -38,28 +160,41 @@ export function resolveProjectDir(inputDir = '.') {
38
160
  * approach: we read the file, transpile with the project's TypeScript compiler
39
161
  * when available, then evaluate as ESM.
40
162
  */
163
+ function stripNotisSdkImports(source) {
164
+ return source
165
+ .replace(/import\s+type\s+[\s\S]*?from\s+['"][^'"]*['"]\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
+ });
176
+ }
177
+
41
178
  function transpileTsConfigSource(source, configPath) {
42
- const requireFromConfig = createRequire(`file://${configPath}`);
179
+ // Strip the @notis/sdk/config import unconditionally. The SDK package often
180
+ // points its `exports` at raw .ts source, which Node cannot import from the
181
+ // temp .mjs file we emit below. `defineNotisApp` is just an identity helper,
182
+ // so removing the import and replacing the call with a parens-wrapped
183
+ // expression preserves the config value without ever resolving the SDK.
184
+ const stripped = stripNotisSdkImports(source);
43
185
  try {
44
- const ts = requireFromConfig('typescript');
45
- const transpiled = ts.transpileModule(source, {
46
- compilerOptions: {
47
- module: ts.ModuleKind.ESNext,
48
- target: ts.ScriptTarget.ES2020,
49
- },
50
- fileName: configPath,
51
- });
52
- return transpiled.outputText;
53
- } catch {
54
- return source
55
- .replace(/import\s+type\s+[\s\S]*?from\s+['"][^'"]*['"]\s*;?/g, '')
56
- .replace(/import\s*{[\s\S]*?\bdefineNotisApp\b[\s\S]*?}\s+from\s+['"]@notis\/sdk\/config['"]\s*;?/g, '')
57
- .replace(/defineNotisApp\s*\(/, '(');
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}`);
58
190
  }
59
191
  }
60
192
 
61
- export async function loadAppConfig(projectDir) {
62
- 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
+ }
63
198
  let configPath = null;
64
199
 
65
200
  for (const name of configPaths) {
@@ -74,12 +209,13 @@ export async function loadAppConfig(projectDir) {
74
209
  throw usageError('No notis.config.ts found in project directory.');
75
210
  }
76
211
 
77
- // For .js/.mjs files, import directly. For .ts, we need transpilation.
78
- 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
+ {
79
215
  const source = readFileSync(configPath, 'utf-8');
80
- const jsSource = transpileTsConfigSource(source, configPath);
216
+ const jsSource = configPath.endsWith('.ts') ? transpileTsConfigSource(source, configPath) : stripNotisSdkImports(source);
81
217
 
82
- const tmpPath = join(dirname(configPath), '._notis_config_tmp.mjs');
218
+ const tmpPath = join(dirname(configPath), `._notis_config_${randomUUID()}.mjs`);
83
219
  mkdirSync(dirname(tmpPath), { recursive: true });
84
220
  writeFileSync(tmpPath, jsSource);
85
221
  try {
@@ -101,8 +237,6 @@ export async function loadAppConfig(projectDir) {
101
237
  }
102
238
  }
103
239
 
104
- const mod = await import(`file://${configPath}`);
105
- return mod.default || mod;
106
240
  }
107
241
 
108
242
  // ---------------------------------------------------------------------------
@@ -148,31 +282,323 @@ export function detectProjectWarnings(projectDir, appConfig = null) {
148
282
  warnings.push('Missing Tailwind config.');
149
283
  }
150
284
 
151
- if (appConfig && (!Array.isArray(appConfig.databases) || appConfig.databases.length === 0)) {
285
+ const readsWorkspaceDatabases =
286
+ appConfig?.capabilities?.workspaceDatabases === 'read';
287
+ if (
288
+ appConfig
289
+ && appConfig.kind !== 'report'
290
+ && !readsWorkspaceDatabases
291
+ && (!Array.isArray(appConfig.databases) || appConfig.databases.length === 0)
292
+ ) {
152
293
  warnings.push('No database references declared in notis.config.ts.');
153
294
  }
154
295
 
296
+ if (appConfig && appConfig.kind !== 'report') {
297
+ try {
298
+ const listing = inspectListingReadiness(projectDir, appConfig);
299
+ warnings.push(...listing.errors, ...listing.warnings);
300
+ } catch (error) {
301
+ warnings.push(error instanceof Error ? error.message : String(error));
302
+ }
303
+ }
304
+
305
+ return warnings;
306
+ }
307
+
308
+ function safeKebab(value) {
309
+ return String(value || '')
310
+ .trim()
311
+ .toLowerCase()
312
+ .replace(/[^a-z0-9]+/g, '-')
313
+ .replace(/(^-|-$)+/g, '');
314
+ }
315
+
316
+ function jsStringLiteral(value) {
317
+ return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
318
+ }
319
+
320
+ function normalizeCategories(categories = []) {
321
+ if (!Array.isArray(categories)) {
322
+ return [];
323
+ }
324
+ const allowed = new Set(NOTIS_APP_CATEGORIES);
325
+ const normalized = [];
326
+ for (const category of categories) {
327
+ if (typeof category !== 'string' || !category.trim()) {
328
+ continue;
329
+ }
330
+ const trimmed = category.trim();
331
+ if (!allowed.has(trimmed)) {
332
+ throw usageError(
333
+ `Invalid Notis app category "${trimmed}". Use one of: ${NOTIS_APP_CATEGORIES.join(', ')}.`,
334
+ );
335
+ }
336
+ if (!normalized.includes(trimmed)) {
337
+ normalized.push(trimmed);
338
+ }
339
+ }
340
+ return normalized;
341
+ }
342
+
343
+ function pngDimensions(buffer) {
344
+ const signature = '89504e470d0a1a0a';
345
+ if (!Buffer.isBuffer(buffer) || buffer.length < 24 || buffer.subarray(0, 8).toString('hex') !== signature) {
346
+ return null;
347
+ }
348
+ return {
349
+ width: buffer.readUInt32BE(16),
350
+ height: buffer.readUInt32BE(20),
351
+ };
352
+ }
353
+
354
+ function imageAssetMeta(projectDir, relPath, { maxBytes }) {
355
+ const fullPath = join(projectDir, relPath);
356
+ const content = readFileSync(fullPath);
357
+ const dimensions = pngDimensions(content);
358
+ const errors = [];
359
+ const warnings = [];
360
+
361
+ if (!relPath.toLowerCase().endsWith('.png')) {
362
+ errors.push(`${relPath} must be a PNG file.`);
363
+ }
364
+ if (content.length > maxBytes) {
365
+ errors.push(`${relPath} must be ${Math.round(maxBytes / 1024 / 1024)} MB or smaller.`);
366
+ }
367
+ if (!dimensions) {
368
+ errors.push(`${relPath} is not a valid PNG file.`);
369
+ } else if (dimensions.width !== 2000 || dimensions.height !== 1250) {
370
+ errors.push(`${relPath} must be exactly 2000x1250 pixels.`);
371
+ }
372
+
373
+ return {
374
+ path: relPath,
375
+ content_type: 'image/png',
376
+ bytes: content.length,
377
+ width: dimensions?.width ?? null,
378
+ height: dimensions?.height ?? null,
379
+ errors,
380
+ warnings,
381
+ };
382
+ }
383
+
384
+ export function discoverMetadataAssets(projectDir) {
385
+ const metadataDir = join(projectDir, METADATA_DIR);
386
+ const screenshots = [];
387
+
388
+ if (existsSync(metadataDir)) {
389
+ for (const entry of readdirSync(metadataDir, { withFileTypes: true })) {
390
+ if (!entry.isFile()) continue;
391
+ const match = /^screenshot-(\d+)\.png$/i.exec(entry.name);
392
+ if (!match) continue;
393
+ screenshots.push({
394
+ index: Number.parseInt(match[1], 10),
395
+ ...imageAssetMeta(projectDir, join(METADATA_DIR, entry.name), {
396
+ maxBytes: 2 * 1024 * 1024,
397
+ }),
398
+ });
399
+ }
400
+ }
401
+
402
+ screenshots.sort((a, b) => a.index - b.index);
403
+ return {
404
+ screenshots: screenshots.slice(0, 6),
405
+ };
406
+ }
407
+
408
+ function normalizeConfiguredScreenshots(appConfig = null) {
409
+ if (!Array.isArray(appConfig?.screenshots) || appConfig.screenshots.length === 0) {
410
+ return null;
411
+ }
412
+ if (appConfig.screenshots.length > 6) {
413
+ throw usageError('Configure at most six listing screenshots.');
414
+ }
415
+
416
+ const seenPaths = new Set();
417
+ return appConfig.screenshots.map((entry, index) => {
418
+ const path = typeof entry?.path === 'string' ? entry.path.trim().replace(/\\/g, '/') : '';
419
+ const alt = typeof entry?.alt === 'string' ? entry.alt.trim() : '';
420
+ const route = typeof entry?.route === 'string' ? entry.route.trim() : '';
421
+ const scenario = typeof entry?.scenario === 'string' ? entry.scenario.trim() : '';
422
+ const focus = typeof entry?.focus === 'string' ? entry.focus.trim() : '';
423
+ const theme = typeof entry?.theme === 'string' ? entry.theme.trim().toLowerCase() : '';
424
+ if (!/^metadata\/screenshot-\d+\.png$/i.test(path)) {
425
+ throw usageError(`screenshots[${index}].path must match metadata/screenshot-N.png.`);
426
+ }
427
+ const expectedPath = `metadata/screenshot-${index + 1}.png`;
428
+ if (path !== expectedPath) {
429
+ throw usageError(`screenshots[${index}].path must be ${expectedPath} so capture order stays stable.`);
430
+ }
431
+ if (seenPaths.has(path)) {
432
+ throw usageError(`Duplicate listing screenshot path: ${path}`);
433
+ }
434
+ if (theme && theme !== 'light' && theme !== 'dark') {
435
+ throw usageError(`screenshots[${index}].theme must be light or dark.`);
436
+ }
437
+ seenPaths.add(path);
438
+ return {
439
+ path,
440
+ alt,
441
+ route: route || null,
442
+ scenario: scenario || null,
443
+ focus: focus || null,
444
+ theme: theme || 'light',
445
+ };
446
+ });
447
+ }
448
+
449
+ /** Resolve listing screenshot files in their configured editorial order. */
450
+ export function resolveListingScreenshots(projectDir, appConfig = null) {
451
+ const discovered = discoverMetadataAssets(projectDir).screenshots;
452
+ const configured = normalizeConfiguredScreenshots(appConfig);
453
+ if (!configured) {
454
+ return discovered.map((asset) => ({
455
+ ...asset,
456
+ alt: null,
457
+ route: null,
458
+ scenario: null,
459
+ focus: null,
460
+ theme: 'light',
461
+ }));
462
+ }
463
+
464
+ const byPath = new Map(discovered.map((asset) => [asset.path.replace(/\\/g, '/'), asset]));
465
+ return configured.map((entry) => {
466
+ const asset = byPath.get(entry.path);
467
+ if (!asset) {
468
+ return {
469
+ ...entry,
470
+ content_type: 'image/png',
471
+ bytes: null,
472
+ width: null,
473
+ height: null,
474
+ errors: [`${entry.path} is configured but the file does not exist.`],
475
+ warnings: [],
476
+ };
477
+ }
478
+ return { ...asset, ...entry };
479
+ });
480
+ }
481
+
482
+ /**
483
+ * Screenshot scenarios named in notis.config.ts that metadata/screenshot-fixtures.json
484
+ * does not define. A missing scenario is silent at capture time -- the harness
485
+ * simply falls back to the default fixtures -- so it is reported as a warning.
486
+ */
487
+ export function findUnknownScreenshotScenarios(projectDir, screenshots = []) {
488
+ const named = screenshots.filter((entry) => entry?.scenario);
489
+ if (named.length === 0) {
490
+ return [];
491
+ }
492
+ const fixturesPath = join(projectDir, METADATA_DIR, 'screenshot-fixtures.json');
493
+ if (!existsSync(fixturesPath)) {
494
+ return ['metadata/screenshot-fixtures.json is missing, so screenshot scenarios cannot be applied.'];
495
+ }
496
+ let defined;
497
+ try {
498
+ const parsed = JSON.parse(readFileSync(fixturesPath, 'utf-8'));
499
+ const scenarios = parsed?.scenarios && typeof parsed.scenarios === 'object' ? parsed.scenarios : {};
500
+ defined = new Set(Object.keys(scenarios));
501
+ } catch {
502
+ return ['metadata/screenshot-fixtures.json is not valid JSON, so screenshot scenarios cannot be applied.'];
503
+ }
504
+ const warnings = [];
505
+ for (const entry of named) {
506
+ if (!defined.has(entry.scenario)) {
507
+ warnings.push(
508
+ `${entry.path} names scenario "${entry.scenario}", which metadata/screenshot-fixtures.json does not define.`,
509
+ );
510
+ }
511
+ }
155
512
  return warnings;
156
513
  }
157
514
 
515
+ export function inspectListingReadiness(projectDir, appConfig = null) {
516
+ const config = appConfig || {};
517
+ const warnings = [];
518
+ const errors = [];
519
+ const screenshots = resolveListingScreenshots(projectDir, config);
520
+ const metadata = { screenshots };
521
+ const categories = normalizeCategories(config.categories || []);
522
+ const changelog = readAppChangelog(projectDir);
523
+ errors.push(...changelog.errors);
524
+
525
+ const packagePath = join(projectDir, 'package.json');
526
+ if (!existsSync(packagePath)) {
527
+ errors.push('package.json is required for Store publication.');
528
+ } else {
529
+ try {
530
+ const packageJson = JSON.parse(readFileSync(packagePath, 'utf-8'));
531
+ const notisAppVersion = String(packageJson.notisAppVersion || '').trim();
532
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(notisAppVersion)) {
533
+ errors.push('package.json must include a semver `notisAppVersion` (for example, "0.1.0").');
534
+ }
535
+ } catch {
536
+ errors.push('package.json must contain valid JSON for Store publication.');
537
+ }
538
+ }
539
+
540
+ if (!String(config.tagline || '').trim()) {
541
+ errors.push('Listing tagline missing in notis.config.ts.');
542
+ }
543
+ if (categories.length === 0) {
544
+ errors.push(`Listing category missing in notis.config.ts. Use one of: ${NOTIS_APP_CATEGORIES.join(', ')}.`);
545
+ }
546
+ if (metadata.screenshots.length < MIN_LISTING_SCREENSHOTS) {
547
+ errors.push(`Listing screenshots: ${metadata.screenshots.length}/${MIN_LISTING_SCREENSHOTS} minimum in metadata/screenshot-N.png. Run \`notis apps screenshot\` to generate them.`);
548
+ }
549
+ for (const screenshot of metadata.screenshots) {
550
+ errors.push(...screenshot.errors);
551
+ warnings.push(...screenshot.warnings);
552
+ if (!screenshot.alt) {
553
+ errors.push(`${screenshot.path} is missing descriptive alt text in notis.config.ts -> screenshots.`);
554
+ }
555
+ }
556
+
557
+ return {
558
+ ready:
559
+ errors.length === 0 &&
560
+ Boolean(String(config.tagline || '').trim()) &&
561
+ categories.length > 0 &&
562
+ metadata.screenshots.length >= MIN_LISTING_SCREENSHOTS &&
563
+ metadata.screenshots.every((screenshot) => Boolean(screenshot.alt)) &&
564
+ changelog.entries.length > 0,
565
+ warnings,
566
+ errors,
567
+ metadata,
568
+ categories,
569
+ changelog,
570
+ };
571
+ }
572
+
158
573
  // ---------------------------------------------------------------------------
159
574
  // Build
160
575
  // ---------------------------------------------------------------------------
161
576
 
162
- export async function runProjectScript({ projectDir, scriptName, env = {}, stdio = 'inherit' }) {
577
+ export async function runProjectScript({ projectDir, scriptName, args = [], env = {}, stdio = 'inherit' }) {
163
578
  await new Promise((resolvePromise, rejectPromise) => {
164
- const child = spawn('npm', ['run', scriptName], {
579
+ const child = spawn('npm', ['run', scriptName, ...(args.length ? ['--', ...args] : [])], {
165
580
  cwd: projectDir,
166
581
  stdio,
167
582
  env: { ...process.env, ...env },
168
583
  });
584
+ let capturedOutput = '';
585
+ for (const stream of [child.stdout, child.stderr]) {
586
+ stream?.on('data', (chunk) => {
587
+ capturedOutput += chunk.toString();
588
+ });
589
+ }
169
590
  child.on('error', rejectPromise);
170
591
  child.on('exit', (code) => {
171
592
  if (code === 0) {
172
593
  resolvePromise();
173
594
  return;
174
595
  }
175
- rejectPromise(new Error(`npm run ${scriptName} failed with exit code ${code}`));
596
+ const detail = capturedOutput.trim();
597
+ rejectPromise(
598
+ new Error(
599
+ `npm run ${scriptName} failed with exit code ${code}${detail ? `:\n${detail}` : ''}`,
600
+ ),
601
+ );
176
602
  });
177
603
  });
178
604
  }
@@ -181,7 +607,7 @@ export async function runProjectScript({ projectDir, scriptName, env = {}, stdio
181
607
  * Derive an export name from a route path.
182
608
  * '/' -> 'index', '/inbox' -> 'inbox', '/my-tasks' -> 'myTasks'
183
609
  */
184
- function exportNameFromPath(routePath) {
610
+ export function exportNameFromPath(routePath) {
185
611
  if (routePath === '/') return 'index';
186
612
  const slug = routePath.replace(/^\//, '').replace(/\//g, '-');
187
613
  const identifier = slug
@@ -236,11 +662,91 @@ function autoDetectRoutes(projectDir) {
236
662
  return routes;
237
663
  }
238
664
 
665
+ function validateConfiguredRoutes(routes) {
666
+ if (routes.length === 0) {
667
+ return;
668
+ }
669
+
670
+ const slugSet = new Set();
671
+ let defaultCount = 0;
672
+
673
+ for (const route of routes) {
674
+ if (!route.slug || typeof route.slug !== 'string') {
675
+ throw usageError(`Route "${route.path}" must define a slug.`);
676
+ }
677
+ if (slugSet.has(route.slug)) {
678
+ throw usageError(`Duplicate route slug "${route.slug}".`);
679
+ }
680
+ slugSet.add(route.slug);
681
+ if (route.default) {
682
+ defaultCount += 1;
683
+ }
684
+ if (route.resourceDeepLinks !== undefined && typeof route.resourceDeepLinks !== 'boolean') {
685
+ throw usageError(`Route "${route.slug}" resourceDeepLinks must be a boolean.`);
686
+ }
687
+ }
688
+
689
+ if (defaultCount !== 1) {
690
+ throw usageError(`Expected exactly one default route, received ${defaultCount}.`);
691
+ }
692
+
693
+ for (const route of routes) {
694
+ if (route.parentSlug && !slugSet.has(route.parentSlug)) {
695
+ throw usageError(
696
+ `Route "${route.slug}" references unknown parentSlug "${route.parentSlug}".`,
697
+ );
698
+ }
699
+ const collection = route.collection || null;
700
+ if (
701
+ collection?.sidebar?.mode === 'tree' &&
702
+ (!collection.parentProperty || typeof collection.parentProperty !== 'string')
703
+ ) {
704
+ throw usageError(
705
+ `Tree collection route "${route.slug}" must define collection.parentProperty.`,
706
+ );
707
+ }
708
+ }
709
+
710
+ const childrenByParent = new Map();
711
+ for (const route of routes) {
712
+ if (!route.parentSlug) {
713
+ continue;
714
+ }
715
+ const children = childrenByParent.get(route.parentSlug) || [];
716
+ children.push(route.slug);
717
+ childrenByParent.set(route.parentSlug, children);
718
+ }
719
+
720
+ for (const route of routes) {
721
+ const seen = new Set([route.slug]);
722
+ let current = route.parentSlug || null;
723
+ while (current) {
724
+ if (seen.has(current)) {
725
+ throw usageError(`Route parent cycle detected at "${route.slug}".`);
726
+ }
727
+ seen.add(current);
728
+ current = routes.find((candidate) => candidate.slug === current)?.parentSlug || null;
729
+ }
730
+ }
731
+
732
+ for (const route of routes) {
733
+ if (
734
+ route.collection?.sidebar?.mode === 'tree' &&
735
+ (childrenByParent.get(route.slug) || []).length > 0
736
+ ) {
737
+ throw usageError(
738
+ `Tree collection route "${route.slug}" cannot also define static child routes.`,
739
+ );
740
+ }
741
+ }
742
+ }
743
+
239
744
  function resolveConfiguredRoutes(appConfig, projectDir) {
240
745
  const configuredRoutes = Array.isArray(appConfig.routes) ? appConfig.routes : [];
746
+ validateConfiguredRoutes(configuredRoutes);
241
747
  const routes = configuredRoutes.map((route) => ({
242
748
  ...route,
243
- slug: route.slug || slugFromPath(route.path),
749
+ slug: route.slug,
244
750
  export_name: route.exportName || route.export_name || exportNameFromPath(route.path),
245
751
  }));
246
752
  return routes.length > 0 ? routes : autoDetectRoutes(projectDir);
@@ -270,17 +776,42 @@ function generateEntryFile(projectDir, routes) {
270
776
  return entryPath;
271
777
  }
272
778
 
779
+ export async function prepareArtifactBuild(projectDir, { enforceDesign = true, log = null } = {}) {
780
+ validateProjectBoundary(projectDir);
781
+ validateProjectDesign(projectDir, { enforce: enforceDesign, log });
782
+ const appConfig = await loadAppConfig(projectDir);
783
+ const detectedRoutes = resolveConfiguredRoutes(appConfig, projectDir);
784
+
785
+ generateEntryFile(projectDir, detectedRoutes);
786
+
787
+ const manifest = generateManifest(appConfig, projectDir);
788
+ const manifestPath = join(projectDir, MANIFEST_FILE);
789
+ mkdirSync(dirname(manifestPath), { recursive: true });
790
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
791
+
792
+ return { appConfig, manifest, routes: detectedRoutes };
793
+ }
794
+
273
795
  /**
274
796
  * Generate the manifest from app config and build output.
275
797
  */
276
- function generateManifest(appConfig, projectDir) {
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
+ }
277
806
  const routes = resolveConfiguredRoutes(appConfig, projectDir).map((route) => {
278
807
  const entry = {
279
808
  path: route.path,
280
- slug: route.slug || slugFromPath(route.path),
809
+ slug: route.slug,
281
810
  name: route.name,
282
811
  icon: route.icon || null,
812
+ parentSlug: route.parentSlug || null,
283
813
  default: route.default || false,
814
+ resourceDeepLinks: route.resourceDeepLinks === true,
284
815
  export_name: route.exportName || route.export_name || exportNameFromPath(route.path),
285
816
  collection: route.collection || null,
286
817
  };
@@ -290,57 +821,222 @@ function generateManifest(appConfig, projectDir) {
290
821
  return entry;
291
822
  });
292
823
 
293
- const databases = appConfig.databases || [];
824
+ if (appConfig.kind === 'report') {
825
+ if (routes.length !== 1 || routes[0].collection || routes[0].resourceDeepLinks) {
826
+ throw usageError('Reports require one standalone route, without app collections or resource deep links.');
827
+ }
828
+ if (['databases', 'automations', 'tagline', 'categories', 'author', 'screenshots'].some(key => {
829
+ const value = appConfig[key];
830
+ return Array.isArray(value) ? value.length > 0 : Boolean(value);
831
+ })) throw usageError('Reports cannot own databases, automations or Store metadata.');
832
+ if (Object.values(appConfig.capabilities || {}).some(value => value !== 'read')) {
833
+ throw usageError('Reports support read capabilities only; shell grants require an installed app.');
834
+ }
835
+ const databaseAccess = appConfig.databaseAccess || [];
836
+ if (!Array.isArray(databaseAccess) || databaseAccess.some(entry =>
837
+ !entry || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(entry.id || '') ||
838
+ !['read', 'write'].includes(entry.access)
839
+ )) throw usageError('databaseAccess entries require an existing database UUID and read/write access.');
840
+ return { kind: 'report', version: 1, spec_version: 4,
841
+ routes: routes.map(route => ({ ...route, default: true, tool_access: { base: { tools: appConfig.tools || [] } } })),
842
+ bundle: { js: 'bundle/app.js', css: 'bundle/app.css' },
843
+ tools: appConfig.tools || [], tool_bindings: normalizeAppToolBindings(appConfig.toolBindings),
844
+ capabilities: normalizeAppCapabilities(appConfig.capabilities), database_access: databaseAccess };
845
+ }
846
+
847
+ // Database entries may be a bare slug (structure only) or an object opting
848
+ // into shipping rows to installers. Normalize to the manifest's snake_case.
849
+ const databases = (appConfig.databases || []).map((entry) => {
850
+ if (typeof entry === 'string') return entry;
851
+ if (!entry || typeof entry !== 'object' || !entry.slug) return entry;
852
+ const seedDocuments = entry.seedDocuments ?? entry.seed_documents;
853
+ return seedDocuments === true ? { slug: entry.slug, seed_documents: true } : entry.slug;
854
+ });
855
+ const categories = normalizeCategories(appConfig.categories || []);
856
+ const metadata = { screenshots: resolveListingScreenshots(projectDir, appConfig) };
857
+ const appSlug = safeKebab(appConfig.name);
858
+ const displayTitle = appConfig.title || appConfig.displayName || appConfig.name;
859
+ const listingMedia = {
860
+ screenshots: metadata.screenshots.map((screenshot) => ({
861
+ path: screenshot.path,
862
+ content_type: screenshot.content_type,
863
+ width: screenshot.width,
864
+ height: screenshot.height,
865
+ bytes: screenshot.bytes,
866
+ alt: screenshot.alt || null,
867
+ theme: screenshot.theme || 'light',
868
+ })),
869
+ };
870
+ const changelog = readAppChangelog(projectDir);
871
+ if (changelog.exists && changelog.errors.length > 0) {
872
+ throw usageError(changelog.errors.join('\n'));
873
+ }
874
+ const latestChangelogEntry = changelog.entries[0] || null;
875
+ const legacyVersionNotes = latestChangelogEntry?.body
876
+ || appConfig.versionNotes
877
+ || appConfig.version_notes
878
+ || null;
879
+ let releaseVersion = null;
880
+ try {
881
+ const packageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'));
882
+ releaseVersion = String(packageJson.notisAppVersion || '').trim() || null;
883
+ } catch {
884
+ // Store readiness reports missing or malformed package metadata. Ordinary
885
+ // development builds remain usable without a Store release version.
886
+ }
294
887
 
295
888
  return {
296
889
  version: 1,
297
- spec_version: 3,
890
+ spec_version: 4,
298
891
  app: {
299
- name: appConfig.name,
892
+ name: displayTitle,
893
+ slug: appSlug || null,
300
894
  description: appConfig.description || null,
301
895
  icon: appConfig.icon || null,
896
+ accent: appConfig.accent || null,
897
+ title: displayTitle,
898
+ tagline: appConfig.tagline || null,
899
+ categories,
900
+ author: appConfig.author || null,
901
+ release_version: releaseVersion,
902
+ version_notes: legacyVersionNotes,
903
+ },
904
+ listing: {
905
+ title: displayTitle,
906
+ tagline: appConfig.tagline || null,
907
+ categories,
908
+ author: appConfig.author || null,
909
+ version_notes: legacyVersionNotes,
910
+ ...(changelog.exists
911
+ ? {
912
+ changelog: {
913
+ source_path: changelog.source_path,
914
+ entries: changelog.entries,
915
+ },
916
+ }
917
+ : {}),
918
+ media: listingMedia,
302
919
  },
920
+ metadata: listingMedia,
303
921
  routes,
304
922
  bundle: {
305
923
  js: 'bundle/app.js',
306
924
  css: 'bundle/app.css',
307
925
  },
308
926
  databases,
927
+ capabilities: normalizeAppCapabilities(appConfig.capabilities),
309
928
  tools: appConfig.tools || [],
929
+ tool_bindings: normalizeAppToolBindings(appConfig.toolBindings),
310
930
  };
311
931
  }
312
932
 
313
933
  /**
314
- * Build the app bundle: generate entry file, run `vite build`, package into .notis/output/.
934
+ * Keep the persisted app row's presentation metadata aligned with the bundle
935
+ * manifest. The app slug remains the stable identity; title/name is only the
936
+ * user-facing label.
315
937
  */
316
- export async function buildArtifact(projectDir) {
317
- const appConfig = await loadAppConfig(projectDir);
318
-
319
- // Auto-detect or use configured routes
320
- const detectedRoutes = resolveConfiguredRoutes(appConfig, projectDir);
938
+ export function appRowFieldsFromManifest(manifest) {
939
+ const app = manifest?.app && typeof manifest.app === 'object' ? manifest.app : {};
940
+ const displayName = typeof app.title === 'string' && app.title.trim()
941
+ ? app.title.trim()
942
+ : typeof app.name === 'string' && app.name.trim()
943
+ ? app.name.trim()
944
+ : null;
945
+ return {
946
+ ...(displayName ? { name: displayName } : {}),
947
+ accent: app.accent ?? null,
948
+ };
949
+ }
321
950
 
322
- // Generate the entry file that re-exports all route page components
323
- generateEntryFile(projectDir, detectedRoutes);
951
+ export function normalizeAppToolBindings(bindings) {
952
+ return (Array.isArray(bindings) ? bindings : [])
953
+ .map((binding) => {
954
+ const name = typeof binding?.name === 'string' ? binding.name.trim() : '';
955
+ const providerToolName =
956
+ typeof binding?.providerToolName === 'string' ? binding.providerToolName.trim() : '';
957
+ return name && providerToolName
958
+ ? { name, provider_tool_name: providerToolName }
959
+ : null;
960
+ })
961
+ .filter(Boolean);
962
+ }
324
963
 
325
- // Run Vite build
326
- await runProjectScript({
327
- projectDir,
328
- scriptName: 'build',
329
- });
964
+ /**
965
+ * Keeps only capabilities the platform actually understands, at the exact
966
+ * values it accepts. An unknown key or value is dropped rather than passed
967
+ * through, so a typo can never reach the server as a permission grant.
968
+ */
969
+ export function normalizeAppCapabilities(capabilities) {
970
+ if (!capabilities || typeof capabilities !== 'object') {
971
+ return {};
972
+ }
973
+ const normalized = {};
974
+ if (capabilities.workspaceDatabases === 'read') {
975
+ normalized.workspaceDatabases = 'read';
976
+ }
977
+ if (capabilities.cloudComputer === 'read' || capabilities.cloudComputer === 'shell') {
978
+ normalized.cloudComputer = capabilities.cloudComputer;
979
+ }
980
+ return normalized;
981
+ }
330
982
 
331
- // Verify output
332
- const bundleDir = join(projectDir, BUNDLE_DIR);
333
- const jsPath = join(bundleDir, 'app.js');
334
- if (!existsSync(jsPath)) {
335
- throw usageError('Vite build did not produce .notis/output/bundle/app.js. Check your vite.config.ts.');
983
+ /**
984
+ * Build the app bundle: generate entry file, run `vite build`, package into .notis/output/.
985
+ */
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));
989
+ const workspace = withAppReleaseWorkspace(projectDir, (identity) => {
990
+ rmSync('build-receipt.json', { force: true });
991
+ return identity;
992
+ }, { create: true });
993
+ // SDK refresh is intentional source preparation, before the immutable build receipt.
994
+ if (refreshSdk) syncEmbeddedSdk(projectDir);
995
+ const sourceHash = appFilesDigest(collectSourceFiles(projectDir));
996
+ await prepare(projectDir);
997
+
998
+ // Historical source must round-trip byte-for-byte. Supply the compatible
999
+ // config loader at execution for the old canonical script, without editing
1000
+ // pulled package.json files or interpreting custom shell commands.
1001
+ const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'));
1002
+ const args = String(pkg.scripts?.build || '').trim() === 'vite build'
1003
+ ? ['--configLoader', 'runner']
1004
+ : [];
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 });
1007
+
1008
+ withAppReleaseWorkspace(projectDir, () => {}, { expected: workspace });
1009
+
1010
+ // Verify the canonical `.notis/output/bundle` packaging contract.
1011
+ const builtBundleDir = resolveBuiltBundleDir(projectDir);
1012
+ if (executable && !builtBundleDir) {
1013
+ throw usageError(
1014
+ 'Vite build did not produce app.js in .notis/output/bundle. Check your vite.config.ts.',
1015
+ );
336
1016
  }
1017
+ normalizeBundleStylesheets(projectDir);
1018
+ if (includeMetadata) copyMetadataAssets(projectDir);
1019
+ if (finalize) await finalize(projectDir);
1020
+ withAppReleaseWorkspace(projectDir, () => {}, { expected: workspace });
337
1021
 
338
- // Generate manifest
339
- const manifest = generateManifest(appConfig, projectDir);
340
- const manifestPath = join(projectDir, MANIFEST_FILE);
341
- mkdirSync(dirname(manifestPath), { recursive: true });
342
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
1022
+ validateArtifactBoundary(readArtifactFiles(projectDir));
1023
+
1024
+ const manifest = readManifest(projectDir);
343
1025
 
1026
+ if (sourceHash !== appFilesDigest(collectSourceFiles(projectDir))) {
1027
+ throw usageError('App source changed during the build. Build again before deploying.');
1028
+ }
1029
+ const receipt = JSON.stringify({
1030
+ source_hash: sourceHash,
1031
+ artifact_hash: appFilesDigest(collectArtifactFiles(projectDir)),
1032
+ });
1033
+ withAppReleaseWorkspace(projectDir, () => {
1034
+ const temporary = `.build-receipt-${randomUUID()}`;
1035
+ try {
1036
+ writeFileSync(temporary, receipt, { flag: 'wx' });
1037
+ renameSync(temporary, 'build-receipt.json');
1038
+ } finally { rmSync(temporary, { force: true }); }
1039
+ }, { expected: workspace });
344
1040
  return { manifest, outputDir: join(projectDir, OUTPUT_DIR) };
345
1041
  }
346
1042
 
@@ -355,71 +1051,1051 @@ export function readManifest(projectDir) {
355
1051
  return JSON.parse(readFileSync(manifestPath, 'utf-8'));
356
1052
  }
357
1053
 
1054
+ function copyMetadataAssets(projectDir) {
1055
+ const metadataDir = join(projectDir, METADATA_DIR);
1056
+ const outputMetadataDir = join(projectDir, OUTPUT_DIR, METADATA_DIR);
1057
+ // Vite clears the bundle directory, not sibling listing media. Always make
1058
+ // the packaged metadata an exact reflection of the source tree so removed
1059
+ // placeholders can never survive into a later deploy.
1060
+ rmSync(outputMetadataDir, { recursive: true, force: true });
1061
+ if (!existsSync(metadataDir)) {
1062
+ return;
1063
+ }
1064
+ mkdirSync(outputMetadataDir, { recursive: true });
1065
+ for (const entry of readdirSync(metadataDir, { withFileTypes: true })) {
1066
+ if (!entry.isFile()) continue;
1067
+ if (!/^screenshot-\d+\.png$/i.test(entry.name)) {
1068
+ continue;
1069
+ }
1070
+ cpSync(join(metadataDir, entry.name), join(outputMetadataDir, entry.name));
1071
+ }
1072
+ }
1073
+
358
1074
  // ---------------------------------------------------------------------------
359
1075
  // Linking
360
1076
  // ---------------------------------------------------------------------------
361
1077
 
362
- export function readLinkedState(projectDir) {
363
- const statePath = join(projectDir, STATE_FILE);
364
- if (!existsSync(statePath)) return null;
365
- return JSON.parse(readFileSync(statePath, 'utf-8'));
1078
+ const PROFILE_LINK_FIELDS = new Set([
1079
+ 'app_id',
1080
+ 'linked_at',
1081
+ 'expected_updated_at',
1082
+ 'deployed_at',
1083
+ 'version',
1084
+ ]);
1085
+
1086
+ function splitLinkedState(state) {
1087
+ const base = {};
1088
+ const link = {};
1089
+ for (const [key, value] of Object.entries(state || {})) {
1090
+ if (key === 'profiles') continue;
1091
+ (PROFILE_LINK_FIELDS.has(key) ? link : base)[key] = value;
1092
+ }
1093
+ return { base, link };
366
1094
  }
367
1095
 
368
- export function writeLinkedState(projectDir, state) {
1096
+ export function appLinkedStateProfileKey({ apiBase, userId }) {
1097
+ const normalizedApi = String(apiBase || '').trim().replace(/\/$/, '');
1098
+ const normalizedUser = String(userId || '').trim();
1099
+ if (!normalizedApi || !normalizedUser) return null;
1100
+ return Buffer.from(`${normalizedApi}\0${normalizedUser}`).toString('base64url');
1101
+ }
1102
+
1103
+ export function readLinkedState(projectDir, profileKey = null) {
369
1104
  const statePath = join(projectDir, STATE_FILE);
370
- mkdirSync(dirname(statePath), { recursive: true });
371
- writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n');
1105
+ assertLinkedStatePathSafe(projectDir);
1106
+ if (!existsSync(statePath)) return null;
1107
+ const raw = JSON.parse(readFileSync(statePath, 'utf-8'));
1108
+ if (!profileKey) return raw;
1109
+ const { base, link: legacyLink } = splitLinkedState(raw);
1110
+ const profiles = raw?.profiles && typeof raw.profiles === 'object' ? raw.profiles : {};
1111
+ const scoped = profiles[profileKey];
1112
+ return {
1113
+ ...base,
1114
+ ...(scoped && typeof scoped === 'object' ? scoped : legacyLink),
1115
+ };
372
1116
  }
373
1117
 
374
- export function requireLinkedAppId(projectDir, explicitAppId) {
375
- if (explicitAppId) return explicitAppId;
376
- const state = readLinkedState(projectDir);
377
- if (state?.app_id) return state.app_id;
378
- throw usageError('This project is not linked to a Notis app. Run "notis apps link <app-id> ." first.');
1118
+ function readStateWriteLockOwner(lockPath) {
1119
+ try {
1120
+ return readFileSync(join(lockPath, 'owner'), 'utf8').trim();
1121
+ } catch {
1122
+ return null;
1123
+ }
379
1124
  }
380
1125
 
381
- // ---------------------------------------------------------------------------
382
- // Scaffolding
383
- // ---------------------------------------------------------------------------
1126
+ function reclaimStaleStateWriteLock(lockPath, observedOwner, observedMtimeMs) {
1127
+ const quarantinePath = `${lockPath}.stale-${process.pid}-${randomUUID()}`;
1128
+ try {
1129
+ renameSync(lockPath, quarantinePath);
1130
+ } catch (error) {
1131
+ if (error?.code === 'ENOENT') return false;
1132
+ throw error;
1133
+ }
384
1134
 
385
- /**
386
- * Scaffold a new Notis app project from the SDK template.
387
- */
388
- export function scaffoldProject({ projectDir, appName }) {
389
- const templateDir = resolve(
390
- dirname(fileURLToPath(import.meta.url)),
391
- '../../template',
392
- );
1135
+ let unchanged = false;
1136
+ try {
1137
+ const quarantinedStat = lstatSync(quarantinePath);
1138
+ unchanged = readStateWriteLockOwner(quarantinePath) === observedOwner
1139
+ && quarantinedStat.mtimeMs === observedMtimeMs;
1140
+ } catch (error) {
1141
+ try {
1142
+ renameSync(quarantinePath, lockPath);
1143
+ } catch {
1144
+ // Fail closed below if the quarantined lock cannot be restored.
1145
+ }
1146
+ throw error;
1147
+ }
393
1148
 
394
- if (!existsSync(templateDir)) {
395
- throw usageError(`SDK template not found at ${templateDir}. Ensure @notis_ai/cli is installed correctly.`);
1149
+ if (!unchanged) {
1150
+ try {
1151
+ renameSync(quarantinePath, lockPath);
1152
+ } catch {
1153
+ throw usageError(`Notis app state lock changed while it was being recovered: ${lockPath}`);
1154
+ }
1155
+ return false;
396
1156
  }
397
1157
 
398
- mkdirSync(projectDir, { recursive: true });
399
- cpSync(templateDir, projectDir, { recursive: true });
1158
+ rmSync(quarantinePath, { recursive: true, force: true });
1159
+ return true;
1160
+ }
400
1161
 
401
- // Update package.json with the app name
402
- const pkgPath = join(projectDir, 'package.json');
403
- if (existsSync(pkgPath)) {
404
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
405
- pkg.name = appName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
406
- writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
1162
+ function withStateWriteLock(statePath, callback) {
1163
+ mkdirSync(dirname(statePath), { recursive: true, mode: 0o700 });
1164
+ const lockPath = `${statePath}.write-lock`;
1165
+ const ownerId = `${process.pid}.${randomUUID()}`;
1166
+ const startedAt = Date.now();
1167
+ while (true) {
1168
+ try {
1169
+ mkdirSync(lockPath, { mode: 0o700 });
1170
+ try {
1171
+ writeFileSync(join(lockPath, 'owner'), ownerId, { mode: 0o600, flag: 'wx' });
1172
+ } catch (error) {
1173
+ // A stale-lock recovery may have moved this candidate while the owner
1174
+ // record was being written. Never remove a replacement writer's lock.
1175
+ if (readStateWriteLockOwner(lockPath) === ownerId) {
1176
+ rmSync(lockPath, { recursive: true, force: true });
1177
+ }
1178
+ throw error;
1179
+ }
1180
+ break;
1181
+ } catch (error) {
1182
+ if (error?.code !== 'EEXIST') throw error;
1183
+ try {
1184
+ const lockStat = lstatSync(lockPath);
1185
+ if (lockStat.isSymbolicLink() || !lockStat.isDirectory()) {
1186
+ throw usageError(`Refusing unsafe Notis app state lock: ${lockPath}`);
1187
+ }
1188
+ const observedOwner = readStateWriteLockOwner(lockPath);
1189
+ const ownerPidText = String(observedOwner || '').split('.')[0];
1190
+ const ownerPid = /^\d+$/.test(ownerPidText) ? Number.parseInt(ownerPidText, 10) : null;
1191
+ // PID liveness alone is insufficient: operating systems can reuse a
1192
+ // crashed writer's PID for an unrelated process. A linked-state write
1193
+ // is synchronous and normally holds this lock only for milliseconds,
1194
+ // so an aged lock is stale even when that numeric PID now exists.
1195
+ let stale = Date.now() - lockStat.mtimeMs >= STATE_WRITE_LOCK_STALE_MS;
1196
+ if (!stale && Number.isInteger(ownerPid) && ownerPid > 0) {
1197
+ try {
1198
+ process.kill(ownerPid, 0);
1199
+ } catch (ownerError) {
1200
+ if (ownerError?.code === 'ESRCH') {
1201
+ stale = true;
1202
+ }
1203
+ }
1204
+ }
1205
+ if (stale && reclaimStaleStateWriteLock(lockPath, observedOwner, lockStat.mtimeMs)) {
1206
+ continue;
1207
+ }
1208
+ } catch (statError) {
1209
+ if (statError?.code === 'ENOENT') continue;
1210
+ throw statError;
1211
+ }
1212
+ if (Date.now() - startedAt >= STATE_WRITE_LOCK_TIMEOUT_MS) {
1213
+ throw usageError(`Timed out waiting to update Notis app state: ${statePath}`);
1214
+ }
1215
+ Atomics.wait(stateWriteLockWait, 0, 0, 10);
1216
+ }
407
1217
  }
408
-
409
- // Update notis.config.ts with the app name
410
- const configPath = join(projectDir, 'notis.config.ts');
411
- if (existsSync(configPath)) {
412
- let config = readFileSync(configPath, 'utf-8');
413
- config = config.replace(/'My Notis App'/, `'${appName.replace(/'/g, "\\'")}'`);
414
- writeFileSync(configPath, config);
1218
+ try {
1219
+ return callback();
1220
+ } finally {
1221
+ try {
1222
+ if (readStateWriteLockOwner(lockPath) === ownerId) {
1223
+ rmSync(lockPath, { recursive: true, force: true });
1224
+ }
1225
+ } catch {
1226
+ // Never remove a lock that no longer belongs to this writer.
1227
+ }
415
1228
  }
416
-
417
- return { projectDir };
418
1229
  }
419
1230
 
420
- // ---------------------------------------------------------------------------
421
- // Deploy helpers
422
- // ---------------------------------------------------------------------------
1231
+ export function writeLinkedState(projectDir, state, profileKey = null) {
1232
+ const statePath = join(projectDir, STATE_FILE);
1233
+ assertLinkedStatePathSafe(projectDir);
1234
+ return withStateWriteLock(statePath, () => {
1235
+ let nextState = state;
1236
+ if (profileKey) {
1237
+ let current = {};
1238
+ if (existsSync(statePath)) {
1239
+ current = JSON.parse(readFileSync(statePath, 'utf-8'));
1240
+ }
1241
+ const { base: currentBase } = splitLinkedState(current);
1242
+ const { base } = splitLinkedState(state);
1243
+ const { link } = splitLinkedState(state);
1244
+ const profiles = current?.profiles && typeof current.profiles === 'object'
1245
+ ? { ...current.profiles }
1246
+ : {};
1247
+ profiles[profileKey] = link;
1248
+ nextState = { ...currentBase, ...base, profiles };
1249
+ }
1250
+ const temporary = `${statePath}.${process.pid}.${randomUUID()}.tmp`;
1251
+ writeFileSync(temporary, JSON.stringify(nextState, null, 2) + '\n');
1252
+ renameSync(temporary, statePath);
1253
+ });
1254
+ }
1255
+
1256
+ function assertLinkedStatePathSafe(projectDir) {
1257
+ const notisDir = join(projectDir, NOTIS_DIR);
1258
+ try {
1259
+ const directoryStat = lstatSync(notisDir);
1260
+ if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
1261
+ throw usageError(`Refusing to use unsafe Notis state directory: ${notisDir}`);
1262
+ }
1263
+ } catch (error) {
1264
+ if (error?.code !== 'ENOENT') throw error;
1265
+ }
1266
+
1267
+ const statePath = join(projectDir, STATE_FILE);
1268
+ try {
1269
+ const stateStat = lstatSync(statePath);
1270
+ if (stateStat.isSymbolicLink() || !stateStat.isFile()) {
1271
+ throw usageError(`Refusing to use unsafe Notis state file: ${statePath}`);
1272
+ }
1273
+ } catch (error) {
1274
+ if (error?.code !== 'ENOENT') throw error;
1275
+ }
1276
+ }
1277
+
1278
+ export function requireLinkedAppId(projectDir, explicitAppId, profileKey = null) {
1279
+ if (explicitAppId) return explicitAppId;
1280
+ const state = readLinkedState(projectDir, profileKey);
1281
+ if (state?.app_id) return state.app_id;
1282
+ throw usageError('This project is not linked to a Notis app. Run "notis apps link <app-id> ." first.');
1283
+ }
1284
+
1285
+ // ---------------------------------------------------------------------------
1286
+ // Scaffolding
1287
+ // ---------------------------------------------------------------------------
1288
+
1289
+ /**
1290
+ * Scaffold a new Notis app project from the SDK template or from a published
1291
+ * Store app downloaded from the public registry repository.
1292
+ */
1293
+ export async function scaffoldProject({ projectDir, appName, fromSlug = null }) {
1294
+ let scaffoldSource = null;
1295
+ if (fromSlug) {
1296
+ scaffoldSource = await acquireScaffoldSource(fromSlug);
1297
+ if (!scaffoldSource) {
1298
+ const known = (await loadScaffoldCatalog()).map((entry) => entry.slug).join(', ') || 'none';
1299
+ throw usageError(`Unknown scaffold "${fromSlug}". Available scaffolds: ${known}.`);
1300
+ }
1301
+ }
1302
+ try {
1303
+ return scaffoldProjectFromDir({
1304
+ projectDir,
1305
+ appName,
1306
+ fromSlug,
1307
+ templateDir: scaffoldSource ? scaffoldSource.dir : join(CLI_ROOT, 'template'),
1308
+ });
1309
+ } finally {
1310
+ scaffoldSource?.cleanup();
1311
+ }
1312
+ }
1313
+
1314
+ function scaffoldProjectFromDir({ projectDir, appName, fromSlug, templateDir }) {
1315
+ const sourceDir = resolve(templateDir);
1316
+ const requestedProjectDir = resolve(projectDir);
1317
+ if (!existsSync(sourceDir)) {
1318
+ throw usageError(`SDK template not found at ${sourceDir}. Ensure @notis_ai/cli is installed correctly.`);
1319
+ }
1320
+
1321
+ const parentDir = dirname(requestedProjectDir);
1322
+ const targetName = basename(requestedProjectDir);
1323
+ mkdirSync(parentDir, { recursive: true });
1324
+ const canonicalParent = realpathSync(parentDir);
1325
+ const parentIdentity = capturePullTargetIdentity(canonicalParent);
1326
+ const initialTargetIdentity = captureScaffoldTargetIdentity(requestedProjectDir);
1327
+ const previousCwd = process.cwd();
1328
+ const previousCwdIdentity = capturePullTargetIdentity(previousCwd);
1329
+ let cwdPinned = false;
1330
+ let stageName = null;
1331
+ let cleanupStage = true;
1332
+ let activeTargetIdentity = null;
1333
+ const scaffoldLockName = '.notis-app-scaffold-lock';
1334
+ const scaffoldLockOwnerId = `${process.pid}.${randomUUID()}`;
1335
+ try {
1336
+ process.chdir(canonicalParent);
1337
+ cwdPinned = true;
1338
+ assertPullTargetIdentity('.', parentIdentity);
1339
+ const lockedTargetIdentity = captureScaffoldTargetIdentity(targetName);
1340
+ if (!pullTargetIdentitiesMatch(initialTargetIdentity, lockedTargetIdentity)) {
1341
+ throw usageError(`App scaffold target changed before copying: ${requestedProjectDir}`);
1342
+ }
1343
+ if (!lockedTargetIdentity.exists) {
1344
+ try {
1345
+ mkdirSync(targetName);
1346
+ } catch (error) {
1347
+ if (error?.code === 'EEXIST') {
1348
+ throw usageError(`App scaffold target changed before copying: ${requestedProjectDir}`);
1349
+ }
1350
+ throw error;
1351
+ }
1352
+ }
1353
+ activeTargetIdentity = captureScaffoldTargetIdentity(targetName);
1354
+ process.chdir(targetName);
1355
+ if (!pullTargetIdentitiesMatch(
1356
+ activeTargetIdentity,
1357
+ captureScaffoldTargetIdentity('.'),
1358
+ )) {
1359
+ throw usageError(`App scaffold target changed before copying: ${requestedProjectDir}`);
1360
+ }
1361
+ mkdirSync(scaffoldLockName, { mode: 0o700 });
1362
+ writeFileSync(
1363
+ join(scaffoldLockName, 'owner'),
1364
+ JSON.stringify({ id: scaffoldLockOwnerId }),
1365
+ { mode: 0o600 },
1366
+ );
1367
+
1368
+ stageName = basename(mkdtempSync(join('.', '.notis-app-scaffold-')));
1369
+ projectDir = stageName;
1370
+
1371
+ copyScaffoldSource(sourceDir, projectDir);
1372
+
1373
+ // Update package.json with the app name
1374
+ const pkgPath = join(projectDir, 'package.json');
1375
+ if (existsSync(pkgPath)) {
1376
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
1377
+ pkg.name = appName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
1378
+ // A scaffold creates a new app identity, even when its source comes from a
1379
+ // versioned Store example. New apps must therefore start their own release
1380
+ // history instead of inheriting the example app's registry version.
1381
+ pkg.notisAppVersion = '0.1.0';
1382
+ normalizeScaffoldPackageScripts(pkg);
1383
+ ensureScaffoldLocalSdk(projectDir, pkg);
1384
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
1385
+ normalizeScaffoldLockfile(projectDir, pkg);
1386
+ }
1387
+
1388
+ // Update notis.config.ts with the app name
1389
+ const configPath = join(projectDir, 'notis.config.ts');
1390
+ if (existsSync(configPath)) {
1391
+ let config = readFileSync(configPath, 'utf-8');
1392
+ const displayName = jsStringLiteral(appName);
1393
+ const slugName = jsStringLiteral(safeKebab(appName) || 'my-notis-app');
1394
+ if (fromSlug) {
1395
+ if (/name\s*:/.test(config)) {
1396
+ config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName}`);
1397
+ }
1398
+ if (/title\s*:/.test(config)) {
1399
+ config = config.replace(/title\s*:\s*(['"`])[\s\S]*?\1/, `title: ${displayName}`);
1400
+ } else {
1401
+ config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName},\n title: ${displayName}`);
1402
+ }
1403
+ } else {
1404
+ if (/name\s*:/.test(config)) {
1405
+ config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName}`);
1406
+ }
1407
+ if (/title\s*:/.test(config)) {
1408
+ config = config.replace(/title\s*:\s*(['"`])[\s\S]*?\1/, `title: ${displayName}`);
1409
+ } else {
1410
+ config = config.replace(/'My Notis App'/, displayName);
1411
+ }
1412
+ }
1413
+ config = removeConfigArrayProperty(config, 'screenshots');
1414
+ writeFileSync(configPath, config);
1415
+ }
1416
+
1417
+ resetScaffoldChangelog(projectDir, appName);
1418
+
1419
+ assertPullParentIdentity(parentDir, canonicalParent, parentIdentity);
1420
+ assertPullTargetIdentity(requestedProjectDir, activeTargetIdentity);
1421
+ const stageRoot = realpathSync(stageName);
1422
+ const installedEntries = [];
1423
+ const installedDirectoryIdentities = new Map([['', activeTargetIdentity]]);
1424
+ const captureInstalledEntry = (entryPath) => {
1425
+ const entryStat = lstatSync(entryPath, { bigint: true });
1426
+ return {
1427
+ exists: true,
1428
+ path: entryPath,
1429
+ dev: entryStat.dev,
1430
+ ino: entryStat.ino,
1431
+ directory: entryStat.isDirectory(),
1432
+ };
1433
+ };
1434
+ const installExclusive = (sourcePath, destinationName, relativePath, parentIdentity) => {
1435
+ const sourceStat = lstatSync(sourcePath);
1436
+ if (sourceStat.isSymbolicLink()) {
1437
+ throw usageError(`Refusing symlinked scaffold source entry: ${sourcePath}`);
1438
+ }
1439
+ if (sourceStat.isDirectory()) {
1440
+ mkdirSync(destinationName, { mode: sourceStat.mode & 0o777 });
1441
+ const installedDirectory = captureInstalledEntry(destinationName);
1442
+ installedDirectory.path = relativePath;
1443
+ installedEntries.push(installedDirectory);
1444
+ installedDirectoryIdentities.set(relativePath, installedDirectory);
1445
+ process.chdir(destinationName);
1446
+ if (!pullTargetIdentitiesMatch(
1447
+ installedDirectory,
1448
+ capturePullTargetIdentity('.'),
1449
+ )) {
1450
+ throw usageError(`App scaffold destination changed during installation: ${relativePath}`);
1451
+ }
1452
+ try {
1453
+ for (const name of readdirSync(sourcePath)) {
1454
+ installExclusive(
1455
+ join(sourcePath, name),
1456
+ name,
1457
+ `${relativePath}/${name}`,
1458
+ installedDirectory,
1459
+ );
1460
+ }
1461
+ } finally {
1462
+ const parentRelativePath = dirname(relativePath) === '.' ? '' : dirname(relativePath);
1463
+ process.chdir(parentRelativePath
1464
+ ? join(requestedProjectDir, parentRelativePath)
1465
+ : requestedProjectDir);
1466
+ if (!pullTargetIdentitiesMatch(
1467
+ parentIdentity,
1468
+ capturePullTargetIdentity('.'),
1469
+ )) {
1470
+ throw usageError(`App scaffold destination changed during installation: ${parentRelativePath || '.'}`);
1471
+ }
1472
+ }
1473
+ return;
1474
+ }
1475
+ if (!sourceStat.isFile()) {
1476
+ throw usageError(`Refusing unsupported scaffold source entry: ${sourcePath}`);
1477
+ }
1478
+ copyFileSync(sourcePath, destinationName, fsConstants.COPYFILE_EXCL);
1479
+ const installedFile = captureInstalledEntry(destinationName);
1480
+ installedFile.path = relativePath;
1481
+ installedEntries.push(installedFile);
1482
+ };
1483
+ try {
1484
+ for (const name of readdirSync(stageName)) {
1485
+ installExclusive(join(stageRoot, name), name, name, activeTargetIdentity);
1486
+ }
1487
+ assertPullParentIdentity(parentDir, canonicalParent, parentIdentity);
1488
+ assertPullTargetIdentity(requestedProjectDir, activeTargetIdentity);
1489
+ } catch (error) {
1490
+ try {
1491
+ for (const installed of installedEntries.reverse()) {
1492
+ const parentRelativePath = dirname(installed.path) === '.' ? '' : dirname(installed.path);
1493
+ const parentPath = parentRelativePath
1494
+ ? join(requestedProjectDir, parentRelativePath)
1495
+ : requestedProjectDir;
1496
+ const expectedParentIdentity = installedDirectoryIdentities.get(parentRelativePath);
1497
+ process.chdir(parentPath);
1498
+ if (!expectedParentIdentity || !pullTargetIdentitiesMatch(
1499
+ expectedParentIdentity,
1500
+ capturePullTargetIdentity('.'),
1501
+ )) {
1502
+ throw usageError(
1503
+ `Refusing to roll back a scaffold directory that changed concurrently: ${parentPath}`,
1504
+ );
1505
+ }
1506
+ const entryName = basename(installed.path);
1507
+ const current = captureInstalledEntry(entryName);
1508
+ if (
1509
+ current.dev !== installed.dev
1510
+ || current.ino !== installed.ino
1511
+ || current.directory !== installed.directory
1512
+ ) {
1513
+ throw usageError(
1514
+ `Refusing to roll back a scaffold entry that changed concurrently: ${installed.path}`,
1515
+ );
1516
+ }
1517
+ if (installed.directory) {
1518
+ rmdirSync(entryName);
1519
+ } else {
1520
+ unlinkSync(entryName);
1521
+ }
1522
+ }
1523
+ } catch (rollbackError) {
1524
+ cleanupStage = false;
1525
+ throw usageError(
1526
+ `${error instanceof Error ? error.message : String(error)} `
1527
+ + `Scaffold rollback failed; recovery files were retained at `
1528
+ + `${join(requestedProjectDir, stageName)}: `
1529
+ + `${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
1530
+ );
1531
+ }
1532
+ throw error;
1533
+ }
1534
+ try {
1535
+ rmSync(stageName, { recursive: true, force: true });
1536
+ stageName = null;
1537
+ } catch (error) {
1538
+ cleanupStage = false;
1539
+ throw usageError(
1540
+ `App scaffold was installed, but staging cleanup failed; recovery files were retained at `
1541
+ + `${join(requestedProjectDir, stageName)}: `
1542
+ + `${error instanceof Error ? error.message : String(error)}`,
1543
+ );
1544
+ }
1545
+ } finally {
1546
+ try {
1547
+ let targetPinnedForCleanup = false;
1548
+ if (activeTargetIdentity) {
1549
+ try {
1550
+ process.chdir(requestedProjectDir);
1551
+ targetPinnedForCleanup = pullTargetIdentitiesMatch(
1552
+ activeTargetIdentity,
1553
+ capturePullTargetIdentity('.'),
1554
+ );
1555
+ } catch {
1556
+ targetPinnedForCleanup = false;
1557
+ }
1558
+ }
1559
+ if (targetPinnedForCleanup && cleanupStage && stageName) {
1560
+ rmSync(stageName, { recursive: true, force: true });
1561
+ }
1562
+ if (
1563
+ targetPinnedForCleanup
1564
+ && readPullLockOwner(scaffoldLockName)?.id === scaffoldLockOwnerId
1565
+ ) {
1566
+ rmSync(scaffoldLockName, { recursive: true, force: true });
1567
+ }
1568
+ } finally {
1569
+ if (cwdPinned) {
1570
+ const cwdMatchesPrevious = (
1571
+ previousCwdIdentity.exists
1572
+ && pullTargetIdentitiesMatch(previousCwdIdentity, capturePullTargetIdentity('.'))
1573
+ );
1574
+ if (!cwdMatchesPrevious) {
1575
+ process.chdir(previousCwd);
1576
+ }
1577
+ }
1578
+ }
1579
+ }
1580
+
1581
+ return { projectDir: requestedProjectDir };
1582
+ }
1583
+
1584
+ function normalizeScaffoldPackageScripts(pkg) {
1585
+ const scripts = pkg?.scripts;
1586
+ if (!scripts || typeof scripts !== 'object' || Array.isArray(scripts)) {
1587
+ return;
1588
+ }
1589
+ // The linked SDK exports TypeScript. Vite's bundled config loader externalizes
1590
+ // that import to Node; the minimum supported Node 22.12 lacks native TS loading.
1591
+ // Normalize only canonical commands; never parse or rewrite custom shell code.
1592
+ for (const [name, command] of Object.entries(scripts)) {
1593
+ if (typeof command === 'string' && /^(vite|vite build|vite preview)$/.test(command.trim())) {
1594
+ scripts[name] = `${command.trim()} --configLoader runner`;
1595
+ }
1596
+ }
1597
+ const generateEntry = String(scripts['generate-entry'] || '').trim();
1598
+ if (!/^tsx\s+\.\.\/\.\.\/scripts\/generate-entry\.ts\s+\.$/.test(generateEntry)) {
1599
+ return;
1600
+ }
1601
+ for (const [name, command] of Object.entries(scripts)) {
1602
+ if (name === 'generate-entry' || typeof command !== 'string') {
1603
+ continue;
1604
+ }
1605
+ if (command.trim() === 'npm run generate-entry') {
1606
+ delete scripts[name];
1607
+ }
1608
+ }
1609
+ scripts['generate-entry'] = 'node -e ""';
1610
+ }
1611
+
1612
+ function captureScaffoldTargetIdentity(targetDir) {
1613
+ try {
1614
+ const targetStat = lstatSync(targetDir, { bigint: true });
1615
+ if (targetStat.isSymbolicLink() || !targetStat.isDirectory()) {
1616
+ throw usageError(`Refusing to scaffold through an unsafe target: ${targetDir}`);
1617
+ }
1618
+ if (readdirSync(targetDir).length > 0) {
1619
+ throw usageError(`App scaffold target must be empty: ${targetDir}`);
1620
+ }
1621
+ return { exists: true, dev: targetStat.dev, ino: targetStat.ino };
1622
+ } catch (error) {
1623
+ if (error?.code === 'ENOENT') return { exists: false, dev: null, ino: null };
1624
+ throw error;
1625
+ }
1626
+ }
1627
+
1628
+ /**
1629
+ * Drop `property: [ ... ]` from a notis.config.ts source.
1630
+ *
1631
+ * The scan tracks bracket depth while skipping string literals, so entries
1632
+ * whose text contains a bracket (alt text, selectors) cannot end the array
1633
+ * early. When the array cannot be resolved the source is returned untouched --
1634
+ * a stale screenshots list is a warning at verify time, a broken config is not.
1635
+ */
1636
+ function removeConfigArrayProperty(source, property) {
1637
+ const start = source.search(new RegExp(`^[ \\t]*${property}[ \\t]*:[ \\t]*\\[`, 'm'));
1638
+ if (start === -1) {
1639
+ return source;
1640
+ }
1641
+ let index = source.indexOf('[', start);
1642
+ let depth = 0;
1643
+ let quote = null;
1644
+ for (; index < source.length; index += 1) {
1645
+ const char = source[index];
1646
+ if (quote) {
1647
+ if (char === '\\') {
1648
+ index += 1;
1649
+ } else if (char === quote) {
1650
+ quote = null;
1651
+ }
1652
+ continue;
1653
+ }
1654
+ if (char === '\'' || char === '"' || char === '`') {
1655
+ quote = char;
1656
+ continue;
1657
+ }
1658
+ if (char === '[') {
1659
+ depth += 1;
1660
+ } else if (char === ']') {
1661
+ depth -= 1;
1662
+ if (depth === 0) {
1663
+ break;
1664
+ }
1665
+ }
1666
+ }
1667
+ if (depth !== 0) {
1668
+ return source;
1669
+ }
1670
+ let end = index + 1;
1671
+ if (source[end] === ',') {
1672
+ end += 1;
1673
+ }
1674
+ while (end < source.length && (source[end] === ' ' || source[end] === '\t')) {
1675
+ end += 1;
1676
+ }
1677
+ if (source[end] === '\n') {
1678
+ end += 1;
1679
+ }
1680
+ return source.slice(0, start) + source.slice(end);
1681
+ }
1682
+
1683
+ /**
1684
+ * A new project starts its own release history: the scaffold's entries describe
1685
+ * releases of a different app.
1686
+ */
1687
+ function resetScaffoldChangelog(projectDir, appName) {
1688
+ const changelogPath = join(projectDir, 'CHANGELOG.md');
1689
+ if (!existsSync(changelogPath)) {
1690
+ return;
1691
+ }
1692
+ writeFileSync(
1693
+ changelogPath,
1694
+ `# ${appName} Changelog\n\n## [Initial Release] - ${CHANGELOG_MERGE_DATE}\n\n- First Store release.\n`,
1695
+ );
1696
+ }
1697
+
1698
+ function normalizeScaffoldLockfile(projectDir, pkg) {
1699
+ const lockPath = join(projectDir, 'package-lock.json');
1700
+ if (!existsSync(lockPath)) {
1701
+ return;
1702
+ }
1703
+
1704
+ const lockfile = JSON.parse(readFileSync(lockPath, 'utf-8'));
1705
+ lockfile.name = pkg.name;
1706
+ if (lockfile.packages?.['']) {
1707
+ lockfile.packages[''].name = pkg.name;
1708
+ }
1709
+ writeFileSync(lockPath, JSON.stringify(lockfile, null, 2) + '\n');
1710
+ }
1711
+
1712
+ function ensureScaffoldLocalSdk(projectDir, pkg) {
1713
+ let shouldInstallLocalSdk = false;
1714
+ for (const dependencyGroup of ['dependencies', 'devDependencies']) {
1715
+ const dependencyValue = pkg[dependencyGroup]?.['@notis/sdk'];
1716
+ if (typeof dependencyValue === 'string' && dependencyValue.startsWith('file:')) {
1717
+ pkg[dependencyGroup]['@notis/sdk'] = 'file:./packages/sdk';
1718
+ shouldInstallLocalSdk = true;
1719
+ }
1720
+ }
1721
+
1722
+ if (!shouldInstallLocalSdk) {
1723
+ return;
1724
+ }
1725
+
1726
+ const localSdkDir = join(projectDir, 'packages', 'sdk');
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);
1731
+ return;
1732
+ }
1733
+ if (!existsSync(join(TEMPLATE_SDK_DIR, 'package.json'))) {
1734
+ throw usageError(`SDK template not found at ${TEMPLATE_SDK_DIR}. Ensure @notis_ai/cli is installed correctly.`);
1735
+ }
1736
+
1737
+ mkdirSync(dirname(localSdkDir), { recursive: 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('/')) });
1740
+ }
1741
+
1742
+ function listFilesRecursive(dir, base = dir, results = []) {
1743
+ if (!existsSync(dir)) return results;
1744
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
1745
+ if (entry.name === 'node_modules' || entry.name === 'dist') continue;
1746
+ const fullPath = join(dir, entry.name);
1747
+ if (entry.isDirectory()) {
1748
+ listFilesRecursive(fullPath, base, results);
1749
+ } else {
1750
+ results.push(relative(base, fullPath).split(sep).join('/'));
1751
+ }
1752
+ }
1753
+ return results;
1754
+ }
1755
+
1756
+ /**
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
1759
+ * the bundled artifact generated from packages/sdk), so an app never edits it; before
1760
+ * this sync existed every app silently ran whatever SDK snapshot it was
1761
+ * scaffolded with and never received hook or style updates.
1762
+ *
1763
+ * Files that exist locally but not in the template are left alone with a
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.
1768
+ */
1769
+ export function syncEmbeddedSdk(projectDir, { log = null, templateSdkDir = TEMPLATE_SDK_DIR, retiredFiles = RETIRED_SDK_FILES } = {}) {
1770
+ const canonicalProjectDir = realpathSync(assertSourceRoot(projectDir));
1771
+ const canonicalTemplateDir = resolve(templateSdkDir);
1772
+ if (!existsSync(join(canonicalTemplateDir, 'package.json'))) {
1773
+ return { updated: false, reason: 'no-template-sdk' };
1774
+ }
1775
+ const previousCwd = process.cwd();
1776
+ const previousIdentity = capturePullTargetIdentity(previousCwd);
1777
+ const projectIdentity = capturePullTargetIdentity(canonicalProjectDir);
1778
+ const templateFiles = listFilesRecursive(canonicalTemplateDir).filter(
1779
+ (relPath) => !HOST_ONLY_SDK_FILES.includes(relPath)
1780
+ && (relPath === 'package.json' || relPath === 'tsconfig.json' || relPath.startsWith('src/')),
1781
+ );
1782
+ const changed = [];
1783
+ let foreign = [];
1784
+
1785
+ // Pin each app-controlled parent as cwd before touching a child. Absolute
1786
+ // path writes would follow a replaced packages/sdk or src directory link.
1787
+ function inDirectory(name, callback, { create = false } = {}) {
1788
+ const parentIdentity = capturePullTargetIdentity('.');
1789
+ let identity = capturePullTargetIdentity(name);
1790
+ if (!identity.exists) {
1791
+ if (!create) return { updated: false, reason: 'no-embedded-sdk' };
1792
+ mkdirSync(name);
1793
+ identity = capturePullTargetIdentity(name);
1794
+ }
1795
+ process.chdir(name);
1796
+ try {
1797
+ assertPullTargetIdentity('.', identity);
1798
+ return callback();
1799
+ } finally {
1800
+ process.chdir('..');
1801
+ assertPullTargetIdentity('.', parentIdentity);
1802
+ }
1803
+ }
1804
+
1805
+ function mirrorFile(segments, source) {
1806
+ if (segments.length > 1) {
1807
+ return inDirectory(segments[0], () => mirrorFile(segments.slice(1), source), { create: true });
1808
+ }
1809
+ const name = segments[0];
1810
+ let before;
1811
+ try { before = lstatSync(name, { bigint: true }); }
1812
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
1813
+ if (before) {
1814
+ if (before.isSymbolicLink() || !before.isFile()) {
1815
+ throw usageError(`Refusing to refresh the SDK through an unsafe target: ${name}`);
1816
+ }
1817
+ const descriptor = openSync(name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
1818
+ try {
1819
+ if (Buffer.compare(readStablePinnedFile(descriptor, before, `SDK file ${name}`), source) === 0) return false;
1820
+ } finally { closeSync(descriptor); }
1821
+ }
1822
+ // Replace, never truncate: an app-controlled hard link must not modify
1823
+ // another file. O_EXCL staging and rename are relative to the pinned cwd.
1824
+ const temporary = `.notis-sdk-sync-${randomUUID()}`;
1825
+ try {
1826
+ writeFileSync(temporary, source, { flag: 'wx' });
1827
+ renameSync(temporary, name);
1828
+ } finally { rmSync(temporary, { force: true }); }
1829
+ return true;
1830
+ }
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
+
1869
+ try {
1870
+ process.chdir(canonicalProjectDir);
1871
+ assertPullTargetIdentity('.', projectIdentity);
1872
+ const result = inDirectory('packages', () => inDirectory('sdk', () => {
1873
+ let packageStat;
1874
+ try { packageStat = lstatSync('package.json'); }
1875
+ catch (error) {
1876
+ if (error.code === 'ENOENT') return { updated: false, reason: 'no-embedded-sdk' };
1877
+ throw error;
1878
+ }
1879
+ if (packageStat.isSymbolicLink() || !packageStat.isFile()) {
1880
+ throw usageError('Refusing to refresh the SDK through an unsafe package.json target.');
1881
+ }
1882
+ for (const relPath of templateFiles) {
1883
+ const source = readFileSync(join(canonicalTemplateDir, relPath));
1884
+ if (mirrorFile(relPath.split('/'), source)) changed.push(relPath);
1885
+ }
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
+ }
1890
+ foreign = listFilesRecursive('.').filter((relPath) => relPath.startsWith('src/') && !templateSet.has(relPath));
1891
+ return null;
1892
+ }));
1893
+ if (result) return result;
1894
+ assertPullTargetIdentity('.', projectIdentity);
1895
+ } finally {
1896
+ process.chdir(previousCwd);
1897
+ assertPullTargetIdentity('.', previousIdentity);
1898
+ }
1899
+
1900
+ const templateVersion = JSON.parse(readFileSync(join(canonicalTemplateDir, 'package.json'), 'utf-8')).version;
1901
+ const emit = typeof log === 'function' ? log : (message) => process.stderr.write(`${message}\n`);
1902
+ if (changed.length > 0) {
1903
+ emit(`Updated embedded @notis/sdk to ${templateVersion} (${changed.length} file${changed.length === 1 ? '' : 's'}).`);
1904
+ }
1905
+ for (const relPath of foreign) {
1906
+ emit(`Warning: packages/sdk/${relPath} is not part of the @notis/sdk template and was left unchanged.`);
1907
+ }
1908
+ return { updated: changed.length > 0, changed, foreign, templateVersion };
1909
+ }
1910
+
1911
+ // Registry bookkeeping shipped alongside a published app's source: the listing
1912
+ // descriptor and the rendered Store gallery describe the published app, so a
1913
+ // scaffold copy must not inherit them.
1914
+ const REGISTRY_ARTIFACTS = /^(notis-listing\.json$|screenshots(\/|$))/;
1915
+
1916
+ function readStablePinnedFile(descriptor, expectedStat, label) {
1917
+ const before = fstatSync(descriptor, { bigint: true });
1918
+ if (
1919
+ !before.isFile()
1920
+ || before.dev !== expectedStat.dev
1921
+ || before.ino !== expectedStat.ino
1922
+ ) {
1923
+ throw usageError(`${label} changed before it could be read.`);
1924
+ }
1925
+ const content = readFileSync(descriptor);
1926
+ const after = fstatSync(descriptor, { bigint: true });
1927
+ if (
1928
+ after.dev !== before.dev
1929
+ || after.ino !== before.ino
1930
+ || after.size !== before.size
1931
+ || after.mtimeNs !== before.mtimeNs
1932
+ || after.ctimeNs !== before.ctimeNs
1933
+ ) {
1934
+ throw usageError(`${label} changed while it was being read.`);
1935
+ }
1936
+ return content;
1937
+ }
1938
+
1939
+ function captureDirectoryRevision() {
1940
+ const stat = statSync('.', { bigint: true });
1941
+ return { dev: stat.dev, ino: stat.ino, mtimeNs: stat.mtimeNs, ctimeNs: stat.ctimeNs };
1942
+ }
1943
+
1944
+ function assertDirectoryRevisionUnchanged(before, label) {
1945
+ const after = captureDirectoryRevision();
1946
+ if (
1947
+ after.dev !== before.dev
1948
+ || after.ino !== before.ino
1949
+ || after.mtimeNs !== before.mtimeNs
1950
+ || after.ctimeNs !== before.ctimeNs
1951
+ ) {
1952
+ throw usageError(`${label} changed while it was being read.`);
1953
+ }
1954
+ }
1955
+
1956
+ function copyScaffoldSource(sourceDir, targetDir) {
1957
+ function shouldCopy(path) {
1958
+ const name = path.split(/[\\/]/).pop();
1959
+ if (!name) return true;
1960
+ const relPath = relative(sourceDir, path).replace(/\\/g, '/');
1961
+ if (SCAFFOLD_LISTING_MEDIA.test(relPath) || REGISTRY_ARTIFACTS.test(relPath)) {
1962
+ return false;
1963
+ }
1964
+ return !SCAFFOLD_COPY_EXCLUDES.has(name)
1965
+ && !name.toLocaleLowerCase('en-US').startsWith('.notis-app-scaffold-')
1966
+ && !name.startsWith('.env')
1967
+ && !/\.(test|spec)\.[cm]?[jt]sx?$/i.test(name);
1968
+ }
1969
+
1970
+ const canonicalSource = realpathSync(sourceDir);
1971
+ const sourceIdentity = capturePullTargetIdentity(canonicalSource);
1972
+ const previousCwd = process.cwd();
1973
+ const previousCwdIdentity = capturePullTargetIdentity(previousCwd);
1974
+ const destinationIdentity = capturePullTargetIdentity(targetDir);
1975
+ const snapshot = { directories: [], files: [] };
1976
+
1977
+ function snapshotCurrent(sourcePath, node, expectedDirectoryIdentity) {
1978
+ if (!pullTargetIdentitiesMatch(expectedDirectoryIdentity, capturePullTargetIdentity('.'))) {
1979
+ throw usageError(`Scaffold source directory changed while copying: ${sourcePath}`);
1980
+ }
1981
+ const directoryRevision = captureDirectoryRevision();
1982
+ const parentIdentity = expectedDirectoryIdentity;
1983
+ for (const name of readdirSync('.')) {
1984
+ const logicalSourcePath = join(sourcePath, name);
1985
+ if (!shouldCopy(logicalSourcePath)) {
1986
+ continue;
1987
+ }
1988
+ const sourceStat = lstatSync(name, { bigint: true });
1989
+ if (sourceStat.isSymbolicLink()) {
1990
+ throw usageError(`Refusing symlinked scaffold source entry: ${logicalSourcePath}`);
1991
+ }
1992
+ if (sourceStat.isDirectory()) {
1993
+ const childNode = {
1994
+ name,
1995
+ mode: Number(sourceStat.mode & 0o777n),
1996
+ directories: [],
1997
+ files: [],
1998
+ };
1999
+ node.directories.push(childNode);
2000
+ process.chdir(name);
2001
+ const childIdentity = capturePullTargetIdentity('.');
2002
+ if (!pullTargetIdentitiesMatch(
2003
+ { exists: true, dev: sourceStat.dev, ino: sourceStat.ino },
2004
+ childIdentity,
2005
+ )) {
2006
+ throw usageError(`Scaffold source directory changed while copying: ${logicalSourcePath}`);
2007
+ }
2008
+ try {
2009
+ snapshotCurrent(logicalSourcePath, childNode, childIdentity);
2010
+ } finally {
2011
+ process.chdir('..');
2012
+ if (!pullTargetIdentitiesMatch(parentIdentity, capturePullTargetIdentity('.'))) {
2013
+ throw usageError(`Scaffold source directory changed while copying: ${sourcePath}`);
2014
+ }
2015
+ }
2016
+ continue;
2017
+ }
2018
+ if (!sourceStat.isFile()) {
2019
+ throw usageError(`Refusing unsupported scaffold source entry: ${logicalSourcePath}`);
2020
+ }
2021
+ const descriptor = openSync(name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
2022
+ try {
2023
+ node.files.push({
2024
+ name,
2025
+ mode: Number(sourceStat.mode & 0o777n),
2026
+ content: readStablePinnedFile(
2027
+ descriptor,
2028
+ sourceStat,
2029
+ `Scaffold source file ${logicalSourcePath}`,
2030
+ ),
2031
+ });
2032
+ } finally {
2033
+ closeSync(descriptor);
2034
+ }
2035
+ }
2036
+ assertDirectoryRevisionUnchanged(directoryRevision, `Scaffold source directory ${sourcePath}`);
2037
+ }
2038
+
2039
+ try {
2040
+ process.chdir(canonicalSource);
2041
+ snapshotCurrent(canonicalSource, snapshot, sourceIdentity);
2042
+ } finally {
2043
+ process.chdir(previousCwd);
2044
+ if (!pullTargetIdentitiesMatch(previousCwdIdentity, capturePullTargetIdentity('.'))) {
2045
+ throw usageError(`Working directory changed while copying scaffold source: ${previousCwd}`);
2046
+ }
2047
+ }
2048
+
2049
+ function writeSnapshot(node, logicalPath, expectedDirectoryIdentity) {
2050
+ if (!pullTargetIdentitiesMatch(expectedDirectoryIdentity, capturePullTargetIdentity('.'))) {
2051
+ throw usageError(`Scaffold destination changed while copying: ${logicalPath || '.'}`);
2052
+ }
2053
+ const parentIdentity = expectedDirectoryIdentity;
2054
+ for (const directory of node.directories) {
2055
+ mkdirSync(directory.name, { mode: directory.mode });
2056
+ const directoryStat = lstatSync(directory.name, { bigint: true });
2057
+ if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
2058
+ throw usageError(`Refusing unsafe scaffold destination: ${join(logicalPath, directory.name)}`);
2059
+ }
2060
+ process.chdir(directory.name);
2061
+ const childIdentity = capturePullTargetIdentity('.');
2062
+ if (!pullTargetIdentitiesMatch(
2063
+ { exists: true, dev: directoryStat.dev, ino: directoryStat.ino },
2064
+ childIdentity,
2065
+ )) {
2066
+ throw usageError(`Scaffold destination changed while copying: ${join(logicalPath, directory.name)}`);
2067
+ }
2068
+ try {
2069
+ writeSnapshot(directory, join(logicalPath, directory.name), childIdentity);
2070
+ } finally {
2071
+ process.chdir('..');
2072
+ if (!pullTargetIdentitiesMatch(parentIdentity, capturePullTargetIdentity('.'))) {
2073
+ throw usageError(`Scaffold destination changed while copying: ${logicalPath || '.'}`);
2074
+ }
2075
+ }
2076
+ }
2077
+ for (const file of node.files) {
2078
+ writeFileSync(file.name, file.content, { flag: 'wx', mode: file.mode });
2079
+ }
2080
+ }
2081
+
2082
+ try {
2083
+ process.chdir(targetDir);
2084
+ if (!pullTargetIdentitiesMatch(destinationIdentity, capturePullTargetIdentity('.'))) {
2085
+ throw usageError(`Scaffold destination changed while copying: ${targetDir}`);
2086
+ }
2087
+ writeSnapshot(snapshot, '', destinationIdentity);
2088
+ } finally {
2089
+ process.chdir(previousCwd);
2090
+ if (!pullTargetIdentitiesMatch(previousCwdIdentity, capturePullTargetIdentity('.'))) {
2091
+ throw usageError(`Working directory changed while copying scaffold source: ${previousCwd}`);
2092
+ }
2093
+ }
2094
+ }
2095
+
2096
+ // ---------------------------------------------------------------------------
2097
+ // Deploy helpers
2098
+ // ---------------------------------------------------------------------------
423
2099
 
424
2100
  /**
425
2101
  * Collect all files from .notis/output/ as base64-encoded entries for upload.
@@ -430,11 +2106,14 @@ export function collectArtifactFiles(projectDir) {
430
2106
  throw usageError('No build output found. Run "notis apps build" first.');
431
2107
  }
432
2108
 
2109
+ validateArtifactBoundary(readArtifactFiles(projectDir));
2110
+
433
2111
  const files = {};
434
2112
 
435
2113
  function walk(dir, prefix) {
436
2114
  const entries = readdirSync(dir, { withFileTypes: true });
437
2115
  for (const entry of entries) {
2116
+ if (entry.name.startsWith('.') || (!prefix && entry.name === 'verify.json')) continue;
438
2117
  const fullPath = join(dir, entry.name);
439
2118
  const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
440
2119
  if (entry.isDirectory()) {
@@ -449,176 +2128,1012 @@ export function collectArtifactFiles(projectDir) {
449
2128
  return files;
450
2129
  }
451
2130
 
452
- // ---------------------------------------------------------------------------
453
- // Direct deploy (bypasses backend server)
454
- // ---------------------------------------------------------------------------
2131
+ function shouldExcludeSourceEntry(name) {
2132
+ const casefolded = String(name || '').toLocaleLowerCase('en-US');
2133
+ return (
2134
+ SOURCE_COPY_EXCLUDES_CASEFOLDED.has(casefolded)
2135
+ || casefolded.startsWith('.notis-app-pull-')
2136
+ || casefolded.startsWith('.notis-app-scaffold-')
2137
+ || casefolded.startsWith('._notis_config_')
2138
+ || casefolded.startsWith('.env')
2139
+ || casefolded.endsWith('.pyc')
2140
+ || casefolded.endsWith('.pyo')
2141
+ );
2142
+ }
455
2143
 
456
- /**
457
- * Resolve Supabase credentials from the server/.env file in the repo workspace.
458
- * Falls back to environment variables.
459
- */
460
- function resolveSupabaseCredentials() {
461
- const envPaths = [
462
- resolve(process.cwd(), 'server/.env'),
463
- resolve(process.cwd(), '../server/.env'),
464
- resolve(process.cwd(), '../../server/.env'),
465
- ];
466
-
467
- let supabaseUrl = process.env.SUPABASE_URL;
468
- let supabaseSubdomain = process.env.SUPABASE_SUBDOMAIN;
469
- let supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
470
-
471
- for (const envPath of envPaths) {
472
- if (existsSync(envPath)) {
473
- const content = readFileSync(envPath, 'utf-8');
474
- for (const line of content.split('\n')) {
475
- const trimmed = line.trim();
476
- if (trimmed.startsWith('#') || !trimmed.includes('=')) continue;
477
- const eqIdx = trimmed.indexOf('=');
478
- const key = trimmed.slice(0, eqIdx).trim();
479
- let value = trimmed.slice(eqIdx + 1).trim();
480
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
481
- value = value.slice(1, -1);
2144
+ function readSourceFiles(projectDir) {
2145
+ const files = {};
2146
+ const canonicalProjectDir = realpathSync(projectDir);
2147
+ const projectIdentity = capturePullTargetIdentity(canonicalProjectDir);
2148
+ const previousCwd = process.cwd();
2149
+ const previousCwdIdentity = capturePullTargetIdentity(previousCwd);
2150
+
2151
+ function walkCurrent(prefix, expectedDirectoryIdentity) {
2152
+ if (!pullTargetIdentitiesMatch(expectedDirectoryIdentity, capturePullTargetIdentity('.'))) {
2153
+ throw usageError(`App source directory changed while packaging: ${prefix || '.'}`);
2154
+ }
2155
+ const directoryRevision = captureDirectoryRevision();
2156
+ const parentIdentity = expectedDirectoryIdentity;
2157
+ for (const name of readdirSync('.')) {
2158
+ if (shouldExcludeSourceEntry(name)) {
2159
+ continue;
2160
+ }
2161
+ const entryStat = lstatSync(name, { bigint: true });
2162
+ if (entryStat.isSymbolicLink()) {
2163
+ continue;
2164
+ }
2165
+ const relPath = prefix ? `${prefix}/${name}` : name;
2166
+ if (entryStat.isDirectory()) {
2167
+ process.chdir(name);
2168
+ const childIdentity = capturePullTargetIdentity('.');
2169
+ if (!pullTargetIdentitiesMatch(
2170
+ { exists: true, dev: entryStat.dev, ino: entryStat.ino },
2171
+ childIdentity,
2172
+ )) {
2173
+ throw usageError(`App source directory changed while packaging: ${relPath}`);
2174
+ }
2175
+ try {
2176
+ walkCurrent(relPath, childIdentity);
2177
+ } finally {
2178
+ process.chdir('..');
2179
+ if (!pullTargetIdentitiesMatch(parentIdentity, capturePullTargetIdentity('.'))) {
2180
+ throw usageError(`App source directory changed while packaging: ${prefix || '.'}`);
2181
+ }
482
2182
  }
483
- if (key === 'SUPABASE_URL' && !supabaseUrl) supabaseUrl = value;
484
- if (key === 'SUPABASE_SUBDOMAIN' && !supabaseSubdomain) supabaseSubdomain = value;
485
- if ((key === 'SUPABASE_SERVICE_ROLE_KEY' || key === 'SUPABASE_SERVICE_KEY') && !supabaseKey) supabaseKey = value;
2183
+ } else if (entryStat.isFile()) {
2184
+ const descriptor = openSync(name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
2185
+ try {
2186
+ files[relPath] = readStablePinnedFile(
2187
+ descriptor,
2188
+ entryStat,
2189
+ `App source file ${relPath}`,
2190
+ );
2191
+ } finally {
2192
+ closeSync(descriptor);
2193
+ }
2194
+ }
2195
+ }
2196
+ assertDirectoryRevisionUnchanged(directoryRevision, `App source directory ${prefix || '.'}`);
2197
+ }
2198
+
2199
+ try {
2200
+ process.chdir(canonicalProjectDir);
2201
+ walkCurrent('', projectIdentity);
2202
+ } finally {
2203
+ process.chdir(previousCwd);
2204
+ if (!pullTargetIdentitiesMatch(previousCwdIdentity, capturePullTargetIdentity('.'))) {
2205
+ throw usageError(`Working directory changed while packaging app source: ${previousCwd}`);
2206
+ }
2207
+ }
2208
+ return files;
2209
+ }
2210
+
2211
+ export function collectSourceFiles(projectDir) {
2212
+ const files = readSourceFiles(projectDir);
2213
+ const encoded = {};
2214
+ for (const [relPath, content] of Object.entries(files)) {
2215
+ encoded[relPath] = content.toString('base64');
2216
+ }
2217
+ return encoded;
2218
+ }
2219
+
2220
+ function cleanTarPath(name) {
2221
+ const cleaned = String(name || '').replace(/\\/g, '/').replace(/^\/+/, '');
2222
+ const parts = cleaned.split('/').filter(Boolean);
2223
+ if (!parts.length || parts.includes('..')) {
2224
+ throw usageError(`Refusing to extract unsafe path from source archive: ${name}`);
2225
+ }
2226
+ return parts.join('/');
2227
+ }
2228
+
2229
+ function parsePaxPath(data) {
2230
+ if (data.length === 0 || data.length > 64 * 1024) {
2231
+ throw usageError('Refusing to extract an invalid PAX source archive header.');
2232
+ }
2233
+ let offset = 0;
2234
+ let path = null;
2235
+ while (offset < data.length) {
2236
+ const space = data.indexOf(0x20, offset);
2237
+ if (space <= offset) {
2238
+ throw usageError('Refusing to extract a malformed PAX source archive header.');
2239
+ }
2240
+ const lengthText = data.subarray(offset, space).toString('ascii');
2241
+ if (!/^\d+$/.test(lengthText)) {
2242
+ throw usageError('Refusing to extract a malformed PAX source archive header.');
2243
+ }
2244
+ const recordLength = Number.parseInt(lengthText, 10);
2245
+ const recordEnd = offset + recordLength;
2246
+ if (recordLength <= space - offset + 3 || recordEnd > data.length || data[recordEnd - 1] !== 0x0a) {
2247
+ throw usageError('Refusing to extract a malformed PAX source archive header.');
2248
+ }
2249
+ const record = data.subarray(space + 1, recordEnd - 1);
2250
+ const equals = record.indexOf(0x3d);
2251
+ if (equals <= 0) {
2252
+ throw usageError('Refusing to extract a malformed PAX source archive header.');
2253
+ }
2254
+ const key = record.subarray(0, equals).toString('ascii');
2255
+ if (key === 'path') {
2256
+ try {
2257
+ path = new TextDecoder('utf-8', { fatal: true }).decode(record.subarray(equals + 1));
2258
+ } catch {
2259
+ throw usageError('Refusing to extract a PAX source path with invalid UTF-8.');
486
2260
  }
2261
+ if (!path || Buffer.byteLength(path, 'utf-8') > 4096) {
2262
+ throw usageError('Refusing to extract an invalid PAX source path.');
2263
+ }
2264
+ }
2265
+ offset = recordEnd;
2266
+ }
2267
+ if (!path) {
2268
+ throw usageError('Refusing to extract a PAX source header without a path.');
2269
+ }
2270
+ return path;
2271
+ }
2272
+
2273
+ function extractTarGz(buffer, targetDir) {
2274
+ // Source snapshots allow 256 MiB of files plus bounded tar/PAX metadata.
2275
+ const tar = gunzipSync(buffer, { maxOutputLength: 320 * 1024 * 1024 });
2276
+ let offset = 0;
2277
+ let pendingPaxPath = null;
2278
+ const extractedPaths = new Map();
2279
+ while (offset + 512 <= tar.length) {
2280
+ const header = tar.subarray(offset, offset + 512);
2281
+ offset += 512;
2282
+ if (header.every((byte) => byte === 0)) {
487
2283
  break;
488
2284
  }
2285
+
2286
+ const name = header.subarray(0, 100).toString('utf-8').replace(/\0.*$/, '');
2287
+ const prefix = header.subarray(345, 500).toString('utf-8').replace(/\0.*$/, '');
2288
+ const fullName = prefix ? `${prefix}/${name}` : name;
2289
+ const sizeRaw = header.subarray(124, 136).toString('utf-8').replace(/\0.*$/, '').trim();
2290
+ const size = Number.parseInt(sizeRaw || '0', 8);
2291
+ const typeFlag = header[156];
2292
+ if (!Number.isSafeInteger(size) || size < 0 || offset + size > tar.length) {
2293
+ throw usageError('Refusing to extract a malformed source archive entry size.');
2294
+ }
2295
+ const data = tar.subarray(offset, offset + size);
2296
+ if (typeFlag === 120) {
2297
+ if (pendingPaxPath !== null) {
2298
+ throw usageError('Refusing to extract stacked PAX source archive headers.');
2299
+ }
2300
+ pendingPaxPath = parsePaxPath(data);
2301
+ offset += Math.ceil(size / 512) * 512;
2302
+ continue;
2303
+ }
2304
+
2305
+ const relPath = cleanTarPath(pendingPaxPath || fullName);
2306
+ pendingPaxPath = null;
2307
+ const parts = relPath.split('/');
2308
+ for (let index = 1; index <= parts.length; index += 1) {
2309
+ const pathPrefix = parts.slice(0, index).join('/');
2310
+ const casefoldedPath = pathPrefix.toLocaleLowerCase('en-US');
2311
+ const priorPath = extractedPaths.get(casefoldedPath);
2312
+ if (priorPath && priorPath !== pathPrefix) {
2313
+ throw usageError(
2314
+ `Refusing to extract case-colliding source archive paths: ${priorPath} and ${pathPrefix}`,
2315
+ );
2316
+ }
2317
+ extractedPaths.set(casefoldedPath, pathPrefix);
2318
+ }
2319
+ if (relPath.split('/').some((part) => shouldExcludeSourceEntry(part))) {
2320
+ throw usageError(`Refusing to extract local-only path from source archive: ${relPath}`);
2321
+ }
2322
+ const outputPath = join(targetDir, relPath);
2323
+
2324
+ if (typeFlag === 53) {
2325
+ mkdirSync(outputPath, { recursive: true });
2326
+ } else if (typeFlag === 0 || typeFlag === 48) {
2327
+ mkdirSync(dirname(outputPath), { recursive: true });
2328
+ writeFileSync(outputPath, data);
2329
+ } else {
2330
+ throw usageError(`Refusing to extract unsupported source archive entry: ${relPath}`);
2331
+ }
2332
+
2333
+ offset += Math.ceil(size / 512) * 512;
2334
+ }
2335
+ if (pendingPaxPath !== null) {
2336
+ throw usageError('Refusing to extract a dangling PAX source archive header.');
489
2337
  }
2338
+ }
2339
+
2340
+ function collectPullPreservedPaths(targetDir) {
2341
+ const paths = [];
490
2342
 
491
- // Derive URL from subdomain if needed
492
- if (!supabaseUrl && supabaseSubdomain) {
493
- supabaseUrl = `https://${supabaseSubdomain}.supabase.co`;
2343
+ function walk(dir, prefix) {
2344
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
2345
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
2346
+ if (entry.isSymbolicLink() || shouldExcludeSourceEntry(entry.name)) {
2347
+ paths.push(relPath);
2348
+ continue;
2349
+ }
2350
+ if (entry.isDirectory()) {
2351
+ walk(join(dir, entry.name), relPath);
2352
+ }
2353
+ }
494
2354
  }
495
2355
 
496
- if (!supabaseUrl || !supabaseKey) {
497
- throw usageError(
498
- 'Cannot resolve Supabase credentials for direct deploy. ' +
499
- 'Ensure server/.env exists with SUPABASE_SUBDOMAIN (or SUPABASE_URL) and SUPABASE_SERVICE_KEY, ' +
500
- 'or set them as environment variables.',
501
- );
2356
+ if (existsSync(targetDir)) {
2357
+ walk(targetDir, '');
502
2358
  }
2359
+ return paths;
2360
+ }
503
2361
 
504
- return { supabaseUrl, supabaseKey };
2362
+ function readPullLockOwner(lockDirectory) {
2363
+ try {
2364
+ return JSON.parse(readFileSync(join(lockDirectory, 'owner'), 'utf-8'));
2365
+ } catch {
2366
+ return null;
2367
+ }
505
2368
  }
506
2369
 
507
- /**
508
- * Upload a file to Supabase Storage with upsert.
509
- */
510
- async function uploadToStorage(supabaseUrl, supabaseKey, bucket, storagePath, content, contentType) {
511
- const url = `${supabaseUrl}/storage/v1/object/${bucket}/${storagePath}`;
512
- const response = await fetch(url, {
513
- method: 'POST',
514
- headers: {
515
- 'Authorization': `Bearer ${supabaseKey}`,
516
- 'Content-Type': contentType,
517
- 'x-upsert': 'true',
518
- },
519
- body: content,
520
- });
2370
+ function capturePullTargetIdentity(targetDir) {
2371
+ try {
2372
+ const targetStat = lstatSync(targetDir, { bigint: true });
2373
+ if (targetStat.isSymbolicLink() || !targetStat.isDirectory()) {
2374
+ throw usageError(`Refusing to pull app source through an unsafe target: ${targetDir}`);
2375
+ }
2376
+ return { exists: true, dev: targetStat.dev, ino: targetStat.ino };
2377
+ } catch (error) {
2378
+ if (error?.code === 'ENOENT') return { exists: false, dev: null, ino: null };
2379
+ throw error;
2380
+ }
2381
+ }
521
2382
 
522
- if (!response.ok) {
523
- const text = await response.text().catch(() => '');
524
- throw new Error(`Storage upload failed (${response.status}): ${text}`);
2383
+ function assertPullTargetIdentity(targetDir, expected) {
2384
+ const current = capturePullTargetIdentity(targetDir);
2385
+ if (!pullTargetIdentitiesMatch(current, expected)) {
2386
+ throw usageError(`App source pull target changed while the download was in progress: ${targetDir}`);
525
2387
  }
526
2388
  }
527
2389
 
528
- /**
529
- * Get the current app manifest version from the apps table.
530
- */
531
- async function getAppCurrentVersion(supabaseUrl, supabaseKey, appId) {
532
- const encodedAppId = encodeURIComponent(appId);
533
- const url = `${supabaseUrl}/rest/v1/apps?id=eq.${encodedAppId}&select=manifest`;
534
- const response = await fetch(url, {
535
- headers: {
536
- 'Authorization': `Bearer ${supabaseKey}`,
537
- 'apikey': supabaseKey,
538
- },
539
- });
540
- if (!response.ok) throw new Error(`Failed to get app version: ${response.status}`);
541
- const rows = await response.json();
542
- if (!rows.length) throw usageError(`App ${appId} not found.`);
543
- const manifest = rows[0].manifest || {};
544
- return { currentVersion: manifest.version || 0, manifest };
2390
+ function pullTargetIdentitiesMatch(left, right) {
2391
+ return (
2392
+ left.exists === right.exists
2393
+ && (!left.exists || (left.dev === right.dev && left.ino === right.ino))
2394
+ );
545
2395
  }
546
2396
 
547
- /**
548
- * Update the app manifest in the apps table.
549
- */
550
- async function updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, manifest) {
551
- const encodedAppId = encodeURIComponent(appId);
552
- const url = `${supabaseUrl}/rest/v1/apps?id=eq.${encodedAppId}`;
553
- const response = await fetch(url, {
554
- method: 'PATCH',
555
- headers: {
556
- 'Authorization': `Bearer ${supabaseKey}`,
557
- 'apikey': supabaseKey,
558
- 'Content-Type': 'application/json',
559
- 'Prefer': 'return=minimal',
560
- },
561
- body: JSON.stringify({
562
- manifest: { ...manifest, version: newVersion },
563
- updated_at: new Date().toISOString(),
564
- }),
565
- });
566
- if (!response.ok) {
567
- const text = await response.text().catch(() => '');
568
- throw new Error(`Failed to update app version: ${response.status} ${text}`);
2397
+ function assertPullParentIdentity(parentDir, canonicalParent, expectedIdentity) {
2398
+ let currentCanonicalParent;
2399
+ try {
2400
+ currentCanonicalParent = realpathSync(parentDir);
2401
+ } catch {
2402
+ throw usageError(`App source pull parent changed while the download was in progress: ${parentDir}`);
2403
+ }
2404
+ if (
2405
+ currentCanonicalParent !== canonicalParent
2406
+ || !pullTargetIdentitiesMatch(
2407
+ capturePullTargetIdentity(canonicalParent),
2408
+ expectedIdentity,
2409
+ )
2410
+ ) {
2411
+ throw usageError(`App source pull parent changed while the download was in progress: ${parentDir}`);
569
2412
  }
570
2413
  }
571
2414
 
572
- const CONTENT_TYPE_MAP = {
573
- '.js': 'application/javascript',
574
- '.css': 'text/css',
575
- '.json': 'application/json',
576
- '.html': 'text/html',
577
- };
2415
+ async function withPullTargetLock(targetDir, callback) {
2416
+ const requestedTarget = resolve(targetDir);
2417
+ const parentDir = dirname(requestedTarget);
2418
+ mkdirSync(parentDir, { recursive: true });
2419
+ const canonicalParent = realpathSync(parentDir);
2420
+ const canonicalParentIdentity = capturePullTargetIdentity(canonicalParent);
2421
+ const initialRequestedIdentity = capturePullTargetIdentity(requestedTarget);
2422
+ const canonicalTarget = initialRequestedIdentity.exists
2423
+ ? realpathSync(requestedTarget)
2424
+ : join(canonicalParent, basename(requestedTarget));
2425
+ if (
2426
+ initialRequestedIdentity.exists
2427
+ && !pullTargetIdentitiesMatch(
2428
+ initialRequestedIdentity,
2429
+ capturePullTargetIdentity(canonicalTarget),
2430
+ )
2431
+ ) {
2432
+ throw usageError(`App source pull target changed before locking: ${requestedTarget}`);
2433
+ }
2434
+ const lockDirectory = join(canonicalParent, `.${basename(canonicalTarget)}.notis-pull-lock`);
2435
+ const ownerId = `${process.pid}.${randomUUID()}`;
2436
+ const deadline = Date.now() + PULL_LOCK_TIMEOUT_MS;
578
2437
 
579
- /**
580
- * Deploy app bundle directly to Supabase storage, bypassing the backend server.
581
- */
582
- export async function directDeploy(projectDir, appId) {
583
- const { supabaseUrl, supabaseKey } = resolveSupabaseCredentials();
584
- const manifest = readManifest(projectDir);
2438
+ for (;;) {
2439
+ try {
2440
+ mkdirSync(lockDirectory, { mode: 0o700 });
2441
+ writeFileSync(
2442
+ join(lockDirectory, 'owner'),
2443
+ JSON.stringify({ id: ownerId, pid: process.pid, at: Date.now() }),
2444
+ { mode: 0o600 },
2445
+ );
2446
+ break;
2447
+ } catch (error) {
2448
+ if (error?.code !== 'EEXIST') throw error;
2449
+ let lockStat;
2450
+ try {
2451
+ lockStat = lstatSync(lockDirectory);
2452
+ } catch (statError) {
2453
+ if (statError?.code === 'ENOENT') continue;
2454
+ throw statError;
2455
+ }
2456
+ if (lockStat.isSymbolicLink() || !lockStat.isDirectory()) {
2457
+ throw usageError(`Refusing to use unsafe app source pull lock: ${lockDirectory}`);
2458
+ }
2459
+ if (Date.now() >= deadline) {
2460
+ throw usageError(
2461
+ `Timed out waiting to pull app source into ${targetDir}. `
2462
+ + `If no pull process is running, remove the orphaned lock: ${lockDirectory}`,
2463
+ );
2464
+ }
2465
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, PULL_LOCK_POLL_MS));
2466
+ }
2467
+ }
2468
+
2469
+ try {
2470
+ const assertOwnership = () => {
2471
+ if (readPullLockOwner(lockDirectory)?.id !== ownerId) {
2472
+ throw usageError(`Lost the app source pull lock for ${targetDir}.`);
2473
+ }
2474
+ };
2475
+ const assertParentIdentity = () => assertPullParentIdentity(
2476
+ parentDir,
2477
+ canonicalParent,
2478
+ canonicalParentIdentity,
2479
+ );
2480
+ assertParentIdentity();
2481
+ const lockedTargetIdentity = capturePullTargetIdentity(canonicalTarget);
2482
+ if (!pullTargetIdentitiesMatch(initialRequestedIdentity, lockedTargetIdentity)) {
2483
+ throw usageError(`App source pull target changed before locking: ${requestedTarget}`);
2484
+ }
2485
+ return await callback(
2486
+ assertOwnership,
2487
+ assertParentIdentity,
2488
+ canonicalTarget,
2489
+ lockedTargetIdentity,
2490
+ );
2491
+ } finally {
2492
+ try {
2493
+ if (readPullLockOwner(lockDirectory)?.id === ownerId) {
2494
+ rmSync(lockDirectory, { recursive: true, force: true });
2495
+ }
2496
+ } catch {
2497
+ // A reclaimed lock belongs to its new owner and must not be removed.
2498
+ }
2499
+ }
2500
+ }
2501
+
2502
+ // Persist only an unfinished create intent. Concurrent/retried invocations share
2503
+ // its key; a fully read-back creation closes it so later new creations cannot
2504
+ // replay an app that was subsequently deleted.
2505
+ export function beginAppCreateIntent(identity, explicitKey = null) {
2506
+ const identityHash = createHash('sha256').update(JSON.stringify(identity)).digest('hex');
2507
+ const path = join(homedir(), '.notis', 'app-create-intents', `${identityHash}.json`);
2508
+ return withStateWriteLock(path, () => {
2509
+ let intent;
2510
+ if (existsSync(path)) intent = JSON.parse(readFileSync(path, 'utf8'));
2511
+ if (intent && explicitKey && intent.key !== explicitKey) {
2512
+ throw usageError('An unfinished creation exists for this identity. Reconcile it before changing its idempotency key.');
2513
+ }
2514
+ if (!intent) {
2515
+ intent = { key: explicitKey || `app-create-${randomUUID()}` };
2516
+ writeFileSync(path, JSON.stringify(intent), { mode: 0o600 });
2517
+ }
2518
+ return { ...intent, complete() {
2519
+ withStateWriteLock(path, () => {
2520
+ if (existsSync(path) && JSON.parse(readFileSync(path, 'utf8')).key === intent.key) unlinkSync(path);
2521
+ });
2522
+ } };
2523
+ });
2524
+ }
2525
+
2526
+ export async function pullAppSource(args) {
2527
+ const targetDir = resolve(args.targetDir);
2528
+ return withPullTargetLock(
2529
+ targetDir,
2530
+ (
2531
+ assertLockOwnership,
2532
+ assertParentIdentity,
2533
+ canonicalTarget,
2534
+ targetIdentity,
2535
+ ) => pullAppSourceUnlocked(
2536
+ { ...args, targetDir: canonicalTarget },
2537
+ assertLockOwnership,
2538
+ assertParentIdentity,
2539
+ targetIdentity,
2540
+ ),
2541
+ );
2542
+ }
2543
+
2544
+ async function pullAppSourceUnlocked({
2545
+ apiBase,
2546
+ jwt,
2547
+ appId,
2548
+ targetDir,
2549
+ version = 'latest',
2550
+ force = false,
2551
+ profileKey = null,
2552
+ expectedUpdatedAt = null,
2553
+ downloadSource = null,
2554
+ sourceKind = 'app',
2555
+ sourceLink = null,
2556
+ }, assertLockOwnership, assertParentIdentity, initialTargetIdentity) {
2557
+ assertLockOwnership();
2558
+ assertParentIdentity();
2559
+ const targetWasNonEmpty = existsSync(targetDir) && readdirSync(targetDir).length > 0;
2560
+ if (targetWasNonEmpty) {
2561
+ if (!force) {
2562
+ throw usageError(`Target directory is not empty: ${targetDir}. Pass --force to overwrite it.`);
2563
+ }
2564
+ }
585
2565
 
586
- // Get current version and increment
587
- const { currentVersion } = await getAppCurrentVersion(supabaseUrl, supabaseKey, appId);
588
- const newVersion = currentVersion + 1;
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());
2584
+ }
2585
+ const requestedVersion = String(version || 'latest') === 'latest'
2586
+ ? null
2587
+ : Number.parseInt(String(version), 10);
2588
+ if (!Number.isInteger(pulledVersion) || pulledVersion <= 0) {
2589
+ throw usageError('The app source response did not identify a valid positive version.');
2590
+ }
2591
+ if (requestedVersion !== null && (!Number.isInteger(requestedVersion) || requestedVersion <= 0)) {
2592
+ throw usageError(`Invalid app source version: ${version}`);
2593
+ }
2594
+ if (
2595
+ requestedVersion !== null
2596
+ && pulledVersion !== requestedVersion
2597
+ ) {
2598
+ throw usageError(
2599
+ `Requested app source version ${requestedVersion}, but the server returned version ${pulledVersion}.`,
2600
+ );
2601
+ }
2602
+ const linkedVersion = pulledVersion;
2603
+ assertParentIdentity();
2604
+ assertLockOwnership();
2605
+
2606
+ // A failed download or malformed archive must never damage an existing
2607
+ // checkout. Extract into a transaction directory under the pinned target,
2608
+ // then replace
2609
+ // only source-managed entries. Local-only state such as .git, .env files,
2610
+ // dependencies, symlinks, build output, and .notis runtime directories is
2611
+ // moved aside recursively and restored into the new source tree.
2612
+ let cleanupTransaction = true;
2613
+ let cwdPinned = false;
2614
+ let transactionDir = null;
2615
+ let transactionDisplayPath = null;
2616
+ let operationError = null;
2617
+ const previousCwd = process.cwd();
2618
+ const previousCwdIdentity = capturePullTargetIdentity(previousCwd);
2619
+ try {
2620
+ const parentDir = dirname(targetDir);
2621
+ const targetName = basename(targetDir);
2622
+ const parentIdentity = capturePullTargetIdentity(parentDir);
2623
+ assertParentIdentity();
2624
+ process.chdir(parentDir);
2625
+ cwdPinned = true;
2626
+ assertPullTargetIdentity('.', parentIdentity);
2627
+
2628
+ assertParentIdentity();
2629
+ assertPullTargetIdentity(targetName, initialTargetIdentity);
2630
+ if (!initialTargetIdentity.exists) {
2631
+ try {
2632
+ mkdirSync(targetName);
2633
+ } catch (error) {
2634
+ if (error?.code === 'EEXIST') {
2635
+ throw usageError(`App source pull target changed while the download was in progress: ${targetDir}`);
2636
+ }
2637
+ throw error;
2638
+ }
2639
+ }
2640
+ const activeTargetIdentity = capturePullTargetIdentity(targetName);
2641
+ if (
2642
+ initialTargetIdentity.exists
2643
+ && (
2644
+ activeTargetIdentity.dev !== initialTargetIdentity.dev
2645
+ || activeTargetIdentity.ino !== initialTargetIdentity.ino
2646
+ )
2647
+ ) {
2648
+ throw usageError(`App source pull target changed while the download was in progress: ${targetDir}`);
2649
+ }
2650
+ assertLockOwnership();
2651
+ process.chdir(targetName);
2652
+ assertPullTargetIdentity('.', activeTargetIdentity);
2653
+ const targetRoot = '.';
2654
+ assertLinkedStatePathSafe(targetRoot);
2655
+ const preservedPaths = collectPullPreservedPaths(targetRoot);
2656
+
2657
+ const transactionName = basename(mkdtempSync(join('.', '.notis-app-pull-')));
2658
+ transactionDir = transactionName;
2659
+ transactionDisplayPath = join(targetDir, transactionName);
2660
+ const stageDir = join(transactionDir, 'stage');
2661
+ const backupDir = join(transactionDir, 'backup');
2662
+ const preservedDir = join(transactionDir, 'preserved');
2663
+ mkdirSync(stageDir, { recursive: true });
2664
+ mkdirSync(backupDir, { recursive: true });
2665
+ mkdirSync(preservedDir, { recursive: true });
2666
+
2667
+ extractTarGz(archiveBuffer, stageDir);
2668
+ const stagedEntries = readdirSync(stageDir);
2669
+ if (stagedEntries.length === 0) {
2670
+ throw usageError('The app source archive did not contain any editable source files.');
2671
+ }
589
2672
 
590
- // Upload all files from the output directory
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);
2676
+ const hadPreviousState = existsSync(previousStatePath);
2677
+ if (hadPreviousState) {
2678
+ mkdirSync(dirname(backupStatePath), { recursive: true });
2679
+ cpSync(previousStatePath, backupStatePath);
2680
+ }
2681
+
2682
+ const movedPreserved = [];
2683
+ const movedOriginal = [];
2684
+ const movedStaged = [];
2685
+ const restoredPreserved = [];
2686
+ try {
2687
+ for (const relPath of preservedPaths) {
2688
+ const preservedPath = join(preservedDir, relPath);
2689
+ mkdirSync(dirname(preservedPath), { recursive: true });
2690
+ renameSync(join(targetRoot, relPath), preservedPath);
2691
+ movedPreserved.push(relPath);
2692
+ }
2693
+ assertPullTargetIdentity(targetDir, activeTargetIdentity);
2694
+ assertLockOwnership();
2695
+ const managedTargetEntries = readdirSync(targetRoot)
2696
+ .filter((name) => name !== transactionName);
2697
+ for (const name of managedTargetEntries) {
2698
+ renameSync(join(targetRoot, name), join(backupDir, name));
2699
+ movedOriginal.push(name);
2700
+ }
2701
+ assertPullTargetIdentity(targetDir, activeTargetIdentity);
2702
+ assertLockOwnership();
2703
+ for (const name of stagedEntries) {
2704
+ renameSync(join(stageDir, name), join(targetRoot, name));
2705
+ movedStaged.push(name);
2706
+ }
2707
+ assertPullTargetIdentity(targetDir, activeTargetIdentity);
2708
+ assertLockOwnership();
2709
+ for (const relPath of movedPreserved) {
2710
+ const targetPath = join(targetRoot, relPath);
2711
+ try {
2712
+ lstatSync(targetPath);
2713
+ throw usageError(
2714
+ `Local-only path conflicts with pulled source: ${relPath}. Move it aside and retry.`,
2715
+ );
2716
+ } catch (error) {
2717
+ if (error?.code !== 'ENOENT') throw error;
2718
+ }
2719
+ mkdirSync(dirname(targetPath), { recursive: true });
2720
+ renameSync(join(preservedDir, relPath), targetPath);
2721
+ restoredPreserved.push(relPath);
2722
+ }
2723
+ assertPullTargetIdentity(targetDir, activeTargetIdentity);
2724
+ assertLockOwnership();
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
+ }
2741
+ assertPullTargetIdentity(targetDir, activeTargetIdentity);
2742
+ assertLockOwnership();
2743
+ } catch (error) {
2744
+ try {
2745
+ for (const relPath of restoredPreserved.reverse()) {
2746
+ const preservedPath = join(preservedDir, relPath);
2747
+ mkdirSync(dirname(preservedPath), { recursive: true });
2748
+ renameSync(join(targetRoot, relPath), preservedPath);
2749
+ }
2750
+ for (const name of movedStaged.reverse()) {
2751
+ renameSync(join(targetRoot, name), join(stageDir, name));
2752
+ }
2753
+ for (const name of movedOriginal.reverse()) {
2754
+ renameSync(join(backupDir, name), join(targetRoot, name));
2755
+ }
2756
+ for (const relPath of movedPreserved.reverse()) {
2757
+ const targetPath = join(targetRoot, relPath);
2758
+ mkdirSync(dirname(targetPath), { recursive: true });
2759
+ renameSync(join(preservedDir, relPath), targetPath);
2760
+ }
2761
+ if (hadPreviousState) {
2762
+ cpSync(backupStatePath, previousStatePath);
2763
+ } else {
2764
+ rmSync(previousStatePath, { force: true });
2765
+ }
2766
+ } catch (rollbackError) {
2767
+ cleanupTransaction = false;
2768
+ throw usageError(
2769
+ `${error instanceof Error ? error.message : String(error)} `
2770
+ + `Rollback failed; recovery files were retained at ${transactionDisplayPath}: `
2771
+ + `${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
2772
+ );
2773
+ }
2774
+ throw error;
2775
+ }
2776
+ } catch (error) {
2777
+ operationError = error;
2778
+ throw error;
2779
+ } finally {
2780
+ let cleanupError = null;
2781
+ try {
2782
+ if (cleanupTransaction && transactionDir) {
2783
+ rmSync(transactionDir, { recursive: true, force: true });
2784
+ }
2785
+ } catch (error) {
2786
+ cleanupError = error;
2787
+ } finally {
2788
+ if (cwdPinned) {
2789
+ const cwdMatchesTarget = (
2790
+ previousCwdIdentity.exists
2791
+ && pullTargetIdentitiesMatch(previousCwdIdentity, capturePullTargetIdentity('.'))
2792
+ );
2793
+ if (!cwdMatchesTarget) {
2794
+ try {
2795
+ process.chdir(previousCwd);
2796
+ } catch {
2797
+ // The command is about to return; never trade recovered source for a cwd error.
2798
+ }
2799
+ }
2800
+ }
2801
+ }
2802
+ if (cleanupError) {
2803
+ const cleanupMessage = cleanupError instanceof Error
2804
+ ? cleanupError.message
2805
+ : String(cleanupError);
2806
+ if (operationError) {
2807
+ throw usageError(
2808
+ `${operationError instanceof Error ? operationError.message : String(operationError)} `
2809
+ + `Transaction cleanup also failed; recovery files were retained at `
2810
+ + `${transactionDisplayPath}: ${cleanupMessage}`,
2811
+ );
2812
+ }
2813
+ throw usageError(
2814
+ `App source was updated, but transaction cleanup failed; recovery files were retained at `
2815
+ + `${transactionDisplayPath}: ${cleanupMessage}`,
2816
+ );
2817
+ }
2818
+ }
2819
+
2820
+ return { projectDir: targetDir, version: pulledVersion || version };
2821
+ }
2822
+
2823
+ function readArtifactFiles(projectDir) {
591
2824
  const outputDir = join(projectDir, OUTPUT_DIR);
592
- const bucket = 'app-code';
2825
+ if (!existsSync(outputDir)) {
2826
+ throw usageError('No build output found. Run "notis apps build" first.');
2827
+ }
2828
+
2829
+ const files = {};
593
2830
 
594
- async function uploadDir(dir, prefix) {
2831
+ function walk(dir, prefix) {
595
2832
  const entries = readdirSync(dir, { withFileTypes: true });
596
2833
  for (const entry of entries) {
597
2834
  const fullPath = join(dir, entry.name);
598
2835
  const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
599
2836
  if (entry.isDirectory()) {
600
- await uploadDir(fullPath, relPath);
2837
+ walk(fullPath, relPath);
601
2838
  } else {
602
- const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : '';
603
- const contentType = CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
604
- const content = readFileSync(fullPath);
605
- const storagePath = `${appId}/v${newVersion}/${relPath}`;
606
- await uploadToStorage(supabaseUrl, supabaseKey, bucket, storagePath, content, contentType);
2839
+ files[relPath] = readFileSync(fullPath);
607
2840
  }
608
2841
  }
609
2842
  }
610
2843
 
611
- await uploadDir(outputDir, '');
2844
+ walk(outputDir, '');
2845
+ return files;
2846
+ }
2847
+
2848
+ /** Fingerprint exact source/artifact bytes, independent of directory enumeration order. */
2849
+ export function appFilesDigest(files) {
2850
+ return createHash('sha256').update(JSON.stringify(Object.keys(files).sort().map((key) => [key, files[key]]))).digest('hex');
2851
+ }
2852
+
2853
+ /** Run synchronous release bookkeeping relative to a pinned, real .notis directory. */
2854
+ function withAppReleaseWorkspace(projectDir, operation, { create = false, expected = null } = {}) {
2855
+ const canonicalProjectDir = expected?.projectDir || realpathSync(projectDir);
2856
+ const projectIdentity = expected?.projectIdentity || capturePullTargetIdentity(canonicalProjectDir);
2857
+ const previousCwd = process.cwd();
2858
+ const previousIdentity = capturePullTargetIdentity(previousCwd);
2859
+ try {
2860
+ process.chdir(canonicalProjectDir);
2861
+ assertPullTargetIdentity('.', projectIdentity);
2862
+ let notisIdentity = capturePullTargetIdentity(NOTIS_DIR);
2863
+ if (!notisIdentity.exists && create) {
2864
+ mkdirSync(NOTIS_DIR);
2865
+ notisIdentity = capturePullTargetIdentity(NOTIS_DIR);
2866
+ }
2867
+ if (!notisIdentity.exists) throw usageError('No build workspace found. Run notis apps build first.');
2868
+ if (expected) assertPullTargetIdentity(NOTIS_DIR, expected.notisIdentity);
2869
+ process.chdir(NOTIS_DIR);
2870
+ assertPullTargetIdentity('.', notisIdentity);
2871
+ const value = operation({ projectDir: canonicalProjectDir, projectIdentity, notisIdentity });
2872
+ assertPullTargetIdentity('.', notisIdentity);
2873
+ assertPullTargetIdentity(canonicalProjectDir, projectIdentity);
2874
+ assertPullTargetIdentity(join(canonicalProjectDir, NOTIS_DIR), notisIdentity);
2875
+ return value;
2876
+ } finally {
2877
+ process.chdir(previousCwd);
2878
+ assertPullTargetIdentity('.', previousIdentity);
2879
+ }
2880
+ }
612
2881
 
613
- // Update the app record -- include storage_prefix so the portal can resolve bundle URLs
614
- const storagePrefix = `${appId}/v${newVersion}/`;
615
- const deployManifest = {
616
- ...manifest,
617
- version: newVersion,
618
- storage_bucket: bucket,
619
- storage_prefix: storagePrefix,
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;
620
2905
  };
621
- await updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, deployManifest);
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
+ }
622
3028
 
623
- return { version: newVersion };
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
+
3056
+ /** Capture one checked build. Verification and upload must use these same bytes. */
3057
+ export function prepareAppRelease(projectDir) {
3058
+ let release;
3059
+ try {
3060
+ return withAppReleaseWorkspace(projectDir, (workspace) => {
3061
+ const sourceFiles = collectSourceFiles(workspace.projectDir);
3062
+ const files = collectArtifactFiles(workspace.projectDir);
3063
+ let receipt;
3064
+ try {
3065
+ const before = lstatSync('build-receipt.json', { bigint: true });
3066
+ if (!before.isFile() || before.isSymbolicLink()) throw usageError('Unsafe build receipt.');
3067
+ const descriptor = openSync('build-receipt.json', fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
3068
+ try { receipt = JSON.parse(readStablePinnedFile(descriptor, before, 'Build receipt').toString('utf8')); }
3069
+ finally { closeSync(descriptor); }
3070
+ } catch (error) {
3071
+ if (error?.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
3072
+ }
3073
+ if (receipt?.source_hash !== appFilesDigest(sourceFiles) || receipt?.artifact_hash !== appFilesDigest(files)) {
3074
+ throw usageError('Build output is missing or stale. Run notis apps build before deploying.');
3075
+ }
3076
+ return release = freezePinnedArtifactSnapshot(workspace, files, sourceFiles);
3077
+ });
3078
+ } catch (error) {
3079
+ try { release?.close(); } catch { /* Parent replacement retains staging, never follows it. */ }
3080
+ throw error;
3081
+ }
3082
+ }
3083
+
3084
+ // ---------------------------------------------------------------------------
3085
+ // Standalone verification diagnostics, never deployment authority.
3086
+ // Deploy automatically verifies a frozen snapshot before upload; neither a
3087
+ // forged passing report nor an environment variable may bypass that check.
3088
+ // ---------------------------------------------------------------------------
3089
+ export const VERIFY_STAMP_FILE = join(OUTPUT_DIR, 'verify.json');
3090
+
3091
+ export function computeArtifactHash(projectDir) {
3092
+ const outputDir = join(projectDir, OUTPUT_DIR);
3093
+ const bundleDir = join(outputDir, 'bundle');
3094
+ const hash = createHash('sha256');
3095
+ const files = listFilesRecursive(bundleDir).sort();
3096
+ for (const relPath of files) {
3097
+ hash.update(`bundle/${relPath}\0`);
3098
+ hash.update(readFileSync(join(bundleDir, relPath)));
3099
+ hash.update('\0');
3100
+ }
3101
+ const manifestPath = join(projectDir, MANIFEST_FILE);
3102
+ if (existsSync(manifestPath)) {
3103
+ hash.update('manifest.json\0');
3104
+ hash.update(readFileSync(manifestPath));
3105
+ }
3106
+ if (files.length === 0 && !existsSync(manifestPath)) return null;
3107
+ return hash.digest('hex');
3108
+ }
3109
+
3110
+ export function writeVerifyStamp(projectDir, { ok, mode, summary, results }) {
3111
+ const stamp = {
3112
+ version: 1,
3113
+ artifact_hash: computeArtifactHash(projectDir),
3114
+ generated_at: new Date().toISOString(),
3115
+ mode,
3116
+ ok: Boolean(ok),
3117
+ summary,
3118
+ routes: (results || []).map((result) => ({
3119
+ route: result.route,
3120
+ status: result.status,
3121
+ assertions: (result.assertions || []).map((assertion) => ({ code: assertion.code, message: assertion.message })),
3122
+ })),
3123
+ };
3124
+ const stampPath = join(projectDir, VERIFY_STAMP_FILE);
3125
+ mkdirSync(dirname(stampPath), { recursive: true });
3126
+ writeFileSync(stampPath, JSON.stringify(stamp, null, 2) + '\n');
3127
+ return stamp;
3128
+ }
3129
+
3130
+ export function readVerifyStamp(projectDir) {
3131
+ const stampPath = join(projectDir, VERIFY_STAMP_FILE);
3132
+ if (!existsSync(stampPath)) return null;
3133
+ try {
3134
+ const parsed = JSON.parse(readFileSync(stampPath, 'utf-8'));
3135
+ return parsed && typeof parsed === 'object' ? parsed : null;
3136
+ } catch {
3137
+ return null;
3138
+ }
624
3139
  }