@notis_ai/cli 0.2.0-beta.16.1 → 0.2.0-beta.160.1

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