@notis_ai/cli 0.2.0-beta.155.1 → 0.2.0-beta.157.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 (47) hide show
  1. package/README.md +11 -45
  2. package/config/notis_app_design_rules.json +135 -0
  3. package/dist/agent-hooks/notis-agent-hook.mjs +5180 -7281
  4. package/dist/base-skills/notis-apps/SKILL.md +141 -224
  5. package/dist/base-skills/notis-cli/SKILL.md +64 -131
  6. package/package.json +1 -2
  7. package/skills/notis-apps/cli.md +34 -95
  8. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +1 -1
  9. package/src/command-specs/apps.js +326 -1562
  10. package/src/runtime/agent-browser.js +169 -1
  11. package/src/runtime/app-boundary-validator.js +221 -0
  12. package/src/runtime/app-platform.js +359 -233
  13. package/src/runtime/app-test-server.js +292 -0
  14. package/template/app/page.tsx +47 -45
  15. package/template/components/page-heading.tsx +23 -0
  16. package/template/components/ui/badge.tsx +7 -4
  17. package/template/components/ui/card.tsx +24 -11
  18. package/template/components/ui/native-select.tsx +24 -0
  19. package/template/notis.config.ts +0 -1
  20. package/template/package.json +2 -2
  21. package/template/packages/sdk/package.json +1 -2
  22. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +62 -8
  23. package/template/packages/sdk/src/components/Skeleton.tsx +24 -0
  24. package/template/packages/sdk/src/config.ts +0 -2
  25. package/template/packages/sdk/src/hooks/useCloudComputer.ts +15 -48
  26. package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +17 -53
  27. package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +2 -2
  28. package/template/packages/sdk/src/hooks/useDocument.ts +12 -47
  29. package/template/packages/sdk/src/hooks/useDocuments.ts +18 -58
  30. package/template/packages/sdk/src/hooks/useQuery.ts +71 -0
  31. package/template/packages/sdk/src/hooks/useToolQuery.ts +12 -0
  32. package/template/packages/sdk/src/hooks/useTopBarSearch.ts +15 -7
  33. package/template/packages/sdk/src/index.ts +8 -0
  34. package/template/packages/sdk/src/interactions.ts +2 -1
  35. package/template/packages/sdk/src/queryCache.ts +162 -0
  36. package/template/packages/sdk/src/runtime.ts +5 -0
  37. package/template/packages/sdk/src/styles.css +28 -1
  38. package/src/runtime/app-dev-build-supervisor.js +0 -47
  39. package/src/runtime/app-dev-build.js +0 -41
  40. package/src/runtime/app-dev-consumers.js +0 -154
  41. package/src/runtime/app-dev-host-lock.js +0 -80
  42. package/src/runtime/app-dev-process-identity.js +0 -111
  43. package/src/runtime/app-dev-roots.js +0 -284
  44. package/src/runtime/app-dev-server.js +0 -1136
  45. package/src/runtime/app-dev-sessions.js +0 -185
  46. package/src/runtime/cli-mode.generated.js +0 -5
  47. package/src/runtime/cli-mode.js +0 -34
@@ -7,17 +7,17 @@
7
7
  */
8
8
 
9
9
  import { spawn } from 'node:child_process';
10
- import { randomUUID } from 'node:crypto';
10
+ import { createHash, randomUUID } from 'node:crypto';
11
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';
12
12
  import { createRequire } from 'node:module';
13
13
  import { homedir } from 'node:os';
14
- import { basename, dirname, join, relative, resolve } from 'node:path';
14
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import { gunzipSync } from 'node:zlib';
17
17
 
18
18
  import { usageError } from './errors.js';
19
19
  import { acquireScaffoldSource, loadScaffoldCatalog } from './app-registry-scaffolds.js';
20
- import { validateArtifactBoundary, validateProjectBoundary } from './app-boundary-validator.js';
20
+ import { validateArtifactBoundary, validateProjectBoundary, validateProjectDesign } from './app-boundary-validator.js';
21
21
  import { CHANGELOG_MERGE_DATE, readAppChangelog } from './app-changelog.js';
22
22
 
23
23
  const NOTIS_DIR = '.notis';
@@ -573,9 +573,9 @@ export function inspectListingReadiness(projectDir, appConfig = null) {
573
573
  // Build
574
574
  // ---------------------------------------------------------------------------
575
575
 
576
- export async function runProjectScript({ projectDir, scriptName, env = {}, stdio = 'inherit' }) {
576
+ export async function runProjectScript({ projectDir, scriptName, args = [], env = {}, stdio = 'inherit' }) {
577
577
  await new Promise((resolvePromise, rejectPromise) => {
578
- const child = spawn('npm', ['run', scriptName], {
578
+ const child = spawn('npm', ['run', scriptName, ...(args.length ? ['--', ...args] : [])], {
579
579
  cwd: projectDir,
580
580
  stdio,
581
581
  env: { ...process.env, ...env },
@@ -775,8 +775,9 @@ function generateEntryFile(projectDir, routes) {
775
775
  return entryPath;
776
776
  }
777
777
 
778
- export async function prepareArtifactBuild(projectDir) {
778
+ export async function prepareArtifactBuild(projectDir, { enforceDesign = true, log = null } = {}) {
779
779
  validateProjectBoundary(projectDir);
780
+ validateProjectDesign(projectDir, { enforce: enforceDesign, log });
780
781
  const appConfig = await loadAppConfig(projectDir);
781
782
  const detectedRoutes = resolveConfiguredRoutes(appConfig, projectDir);
782
783
 
@@ -1077,15 +1078,32 @@ export function resolveConfiguredAppSkills(appConfig, projectDir) {
1077
1078
  * Build the app bundle: generate entry file, run `vite build`, package into .notis/output/.
1078
1079
  */
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));
1080
1089
  await prepareArtifactBuild(projectDir);
1081
1090
 
1082
- // Run Vite build
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
+ : [];
1083
1098
  await runProjectScript({
1084
1099
  projectDir,
1085
1100
  scriptName: 'build',
1101
+ args,
1086
1102
  stdio,
1087
1103
  });
1088
1104
 
1105
+ withAppReleaseWorkspace(projectDir, () => {}, { expected: workspace });
1106
+
1089
1107
  // Verify the canonical `.notis/output/bundle` packaging contract.
1090
1108
  const builtBundleDir = resolveBuiltBundleDir(projectDir);
1091
1109
  if (!builtBundleDir) {
@@ -1100,6 +1118,20 @@ export async function buildArtifact(projectDir, { stdio = 'inherit' } = {}) {
1100
1118
 
1101
1119
  const manifest = readManifest(projectDir);
1102
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 });
1103
1135
  return { manifest, outputDir: join(projectDir, OUTPUT_DIR) };
1104
1136
  }
1105
1137
 
@@ -1141,12 +1173,9 @@ function copyMetadataAssets(projectDir) {
1141
1173
  const PROFILE_LINK_FIELDS = new Set([
1142
1174
  'app_id',
1143
1175
  'linked_at',
1176
+ 'expected_updated_at',
1144
1177
  'deployed_at',
1145
1178
  'version',
1146
- 'dev_app_id',
1147
- 'dev_linked_at',
1148
- 'auto_linked_at',
1149
- 'cloud_computer_shell_consent',
1150
1179
  ]);
1151
1180
 
1152
1181
  function splitLinkedState(state) {
@@ -1476,14 +1505,6 @@ function scaffoldProjectFromDir({ projectDir, appName, fromSlug, templateDir })
1476
1505
  config = config.replace(/'My Notis App'/, displayName);
1477
1506
  }
1478
1507
  }
1479
- if (/devSlug\s*:/.test(config)) {
1480
- config = config.replace(/devSlug\s*:\s*(['"`])[\s\S]*?\1/, `devSlug: ${slugName}`);
1481
- } else {
1482
- config = config.replace(
1483
- /name\s*:\s*(['"`])[\s\S]*?\1/,
1484
- (nameDeclaration) => `${nameDeclaration},\n devSlug: ${slugName}`,
1485
- );
1486
- }
1487
1508
  config = removeConfigArrayProperty(config, 'screenshots');
1488
1509
  writeFileSync(configPath, config);
1489
1510
  }
@@ -1660,6 +1681,14 @@ function normalizeScaffoldPackageScripts(pkg) {
1660
1681
  if (!scripts || typeof scripts !== 'object' || Array.isArray(scripts)) {
1661
1682
  return;
1662
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
+ }
1663
1692
  const generateEntry = String(scripts['generate-entry'] || '').trim();
1664
1693
  if (!/^tsx\s+\.\.\/\.\.\/scripts\/generate-entry\.ts\s+\.$/.test(generateEntry)) {
1665
1694
  return;
@@ -1801,6 +1830,131 @@ function ensureScaffoldLocalSdk(projectDir, pkg) {
1801
1830
  cpSync(TEMPLATE_SDK_DIR, localSdkDir, { recursive: true, dereference: true });
1802
1831
  }
1803
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
+
1804
1958
  // Registry bookkeeping shipped alongside a published app's source: the listing
1805
1959
  // descriptor and the rendered Store gallery describe the published app, so a
1806
1960
  // scaffold copy must not inherit them.
@@ -2006,6 +2160,7 @@ export function collectArtifactFiles(projectDir) {
2006
2160
  function walk(dir, prefix) {
2007
2161
  const entries = readdirSync(dir, { withFileTypes: true });
2008
2162
  for (const entry of entries) {
2163
+ if (entry.name.startsWith('.') || (!prefix && entry.name === 'verify.json')) continue;
2009
2164
  const fullPath = join(dir, entry.name);
2010
2165
  const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
2011
2166
  if (entry.isDirectory()) {
@@ -2389,6 +2544,30 @@ async function withPullTargetLock(targetDir, callback) {
2389
2544
  }
2390
2545
  }
2391
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
+ } };
2568
+ });
2569
+ }
2570
+
2392
2571
  export async function pullAppSource(args) {
2393
2572
  const targetDir = resolve(args.targetDir);
2394
2573
  return withPullTargetLock(
@@ -2415,6 +2594,7 @@ async function pullAppSourceUnlocked({
2415
2594
  version = 'latest',
2416
2595
  force = false,
2417
2596
  profileKey = null,
2597
+ expectedUpdatedAt = null,
2418
2598
  }, assertLockOwnership, assertParentIdentity, initialTargetIdentity) {
2419
2599
  assertLockOwnership();
2420
2600
  assertParentIdentity();
@@ -2580,6 +2760,7 @@ async function pullAppSourceUnlocked({
2580
2760
  writeLinkedState(targetRoot, {
2581
2761
  app_id: appId,
2582
2762
  ...(Number.isFinite(linkedVersion) ? { version: linkedVersion } : {}),
2763
+ ...(expectedUpdatedAt ? { expected_updated_at: expectedUpdatedAt } : {}),
2583
2764
  linked_at: new Date().toISOString(),
2584
2765
  }, profileKey);
2585
2766
  assertPullTargetIdentity(targetDir, activeTargetIdentity);
@@ -2689,233 +2870,178 @@ function readArtifactFiles(projectDir) {
2689
2870
  return files;
2690
2871
  }
2691
2872
 
2692
- // ---------------------------------------------------------------------------
2693
- // Direct deploy (bypasses backend server)
2694
- // ---------------------------------------------------------------------------
2695
-
2696
- /**
2697
- * Resolve Supabase credentials from the server/.env file in the repo workspace.
2698
- * Falls back to environment variables.
2699
- */
2700
- function resolveSupabaseCredentials() {
2701
- const envPaths = [
2702
- resolve(process.cwd(), 'server/.env'),
2703
- resolve(process.cwd(), '../server/.env'),
2704
- resolve(process.cwd(), '../../server/.env'),
2705
- ];
2706
-
2707
- let supabaseUrl = process.env.SUPABASE_URL;
2708
- let supabaseSubdomain = process.env.SUPABASE_SUBDOMAIN;
2709
- let supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
2710
-
2711
- for (const envPath of envPaths) {
2712
- if (existsSync(envPath)) {
2713
- const content = readFileSync(envPath, 'utf-8');
2714
- for (const line of content.split('\n')) {
2715
- const trimmed = line.trim();
2716
- if (trimmed.startsWith('#') || !trimmed.includes('=')) continue;
2717
- const eqIdx = trimmed.indexOf('=');
2718
- const key = trimmed.slice(0, eqIdx).trim();
2719
- let value = trimmed.slice(eqIdx + 1).trim();
2720
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
2721
- value = value.slice(1, -1);
2722
- }
2723
- if (key === 'SUPABASE_URL' && !supabaseUrl) supabaseUrl = value;
2724
- if (key === 'SUPABASE_SUBDOMAIN' && !supabaseSubdomain) supabaseSubdomain = value;
2725
- if ((key === 'SUPABASE_SERVICE_ROLE_KEY' || key === 'SUPABASE_SERVICE_KEY') && !supabaseKey) supabaseKey = value;
2726
- }
2727
- break;
2728
- }
2729
- }
2730
-
2731
- // Derive URL from subdomain if needed
2732
- if (!supabaseUrl && supabaseSubdomain) {
2733
- supabaseUrl = `https://${supabaseSubdomain}.supabase.co`;
2734
- }
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
+ }
2735
2877
 
2736
- if (!supabaseUrl || !supabaseKey) {
2737
- throw usageError(
2738
- 'Cannot resolve Supabase credentials for direct deploy. ' +
2739
- 'Ensure server/.env exists with SUPABASE_SUBDOMAIN (or SUPABASE_URL) and SUPABASE_SERVICE_KEY, ' +
2740
- 'or set them as environment variables.',
2741
- );
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);
2742
2904
  }
2743
-
2744
- return { supabaseUrl, supabaseKey };
2745
2905
  }
2746
2906
 
2747
- /**
2748
- * Upload a file to Supabase Storage with upsert.
2749
- */
2750
- async function uploadToStorage(supabaseUrl, supabaseKey, bucket, storagePath, content, contentType) {
2751
- const url = `${supabaseUrl}/storage/v1/object/${bucket}/${storagePath}`;
2752
- const response = await fetch(url, {
2753
- method: 'POST',
2754
- headers: {
2755
- 'Authorization': `Bearer ${supabaseKey}`,
2756
- 'Content-Type': contentType,
2757
- 'x-upsert': 'true',
2758
- },
2759
- body: content,
2760
- });
2761
-
2762
- if (!response.ok) {
2763
- const text = await response.text().catch(() => '');
2764
- throw new Error(`Storage upload failed (${response.status}): ${text}`);
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;
2765
2989
  }
2766
2990
  }
2767
2991
 
2768
- async function ensureStorageBucket(supabaseUrl, supabaseKey, bucket, options = {}) {
2769
- const encodedBucket = encodeURIComponent(bucket);
2770
- const headers = {
2771
- 'Authorization': `Bearer ${supabaseKey}`,
2772
- 'apikey': supabaseKey,
2773
- };
2774
- const inspectResponse = await fetch(`${supabaseUrl}/storage/v1/bucket/${encodedBucket}`, {
2775
- headers,
2776
- });
2777
- if (inspectResponse.ok) {
2778
- return;
2779
- }
2780
- if (inspectResponse.status !== 404) {
2781
- const text = await inspectResponse.text().catch(() => '');
2782
- throw new Error(`Failed to inspect storage bucket ${bucket} (${inspectResponse.status}): ${text}`);
2783
- }
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');
2784
2998
 
2785
- const createResponse = await fetch(`${supabaseUrl}/storage/v1/bucket`, {
2786
- method: 'POST',
2787
- headers: {
2788
- ...headers,
2789
- 'Content-Type': 'application/json',
2790
- },
2791
- body: JSON.stringify({
2792
- id: bucket,
2793
- name: bucket,
2794
- public: Boolean(options.public),
2795
- }),
2796
- });
2797
- if (createResponse.ok) {
2798
- return;
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');
2799
3008
  }
2800
-
2801
- const text = await createResponse.text().catch(() => '');
2802
- if (createResponse.status === 409 || /already exists|duplicate/i.test(text)) {
2803
- return;
3009
+ const manifestPath = join(projectDir, MANIFEST_FILE);
3010
+ if (existsSync(manifestPath)) {
3011
+ hash.update('manifest.json\0');
3012
+ hash.update(readFileSync(manifestPath));
2804
3013
  }
2805
- throw new Error(`Failed to create storage bucket ${bucket} (${createResponse.status}): ${text}`);
3014
+ if (files.length === 0 && !existsSync(manifestPath)) return null;
3015
+ return hash.digest('hex');
2806
3016
  }
2807
3017
 
2808
- /**
2809
- * Get the current app manifest version from the apps table.
2810
- */
2811
- async function getAppCurrentVersion(supabaseUrl, supabaseKey, appId) {
2812
- const encodedAppId = encodeURIComponent(appId);
2813
- const url = `${supabaseUrl}/rest/v1/apps?id=eq.${encodedAppId}&select=manifest`;
2814
- const response = await fetch(url, {
2815
- headers: {
2816
- 'Authorization': `Bearer ${supabaseKey}`,
2817
- 'apikey': supabaseKey,
2818
- },
2819
- });
2820
- if (!response.ok) throw new Error(`Failed to get app version: ${response.status}`);
2821
- const rows = await response.json();
2822
- if (!rows.length) throw usageError(`App ${appId} not found.`);
2823
- const manifest = rows[0].manifest || {};
2824
- return { currentVersion: manifest.version || 0, manifest };
2825
- }
2826
-
2827
- /**
2828
- * Update the app manifest in the apps table.
2829
- */
2830
- async function updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, manifest) {
2831
- const encodedAppId = encodeURIComponent(appId);
2832
- const url = `${supabaseUrl}/rest/v1/apps?id=eq.${encodedAppId}`;
2833
- const response = await fetch(url, {
2834
- method: 'PATCH',
2835
- headers: {
2836
- 'Authorization': `Bearer ${supabaseKey}`,
2837
- 'apikey': supabaseKey,
2838
- 'Content-Type': 'application/json',
2839
- 'Prefer': 'return=minimal',
2840
- },
2841
- body: JSON.stringify({
2842
- manifest: { ...manifest, version: newVersion },
2843
- ...appRowFieldsFromManifest(manifest),
2844
- updated_at: new Date().toISOString(),
2845
- }),
2846
- });
2847
- if (!response.ok) {
2848
- const text = await response.text().catch(() => '');
2849
- throw new Error(`Failed to update app version: ${response.status} ${text}`);
2850
- }
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
+ })),
3031
+ };
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;
2851
3036
  }
2852
3037
 
2853
- const CONTENT_TYPE_MAP = {
2854
- '.js': 'application/javascript',
2855
- '.css': 'text/css',
2856
- '.json': 'application/json',
2857
- '.html': 'text/html',
2858
- '.png': 'image/png',
2859
- '.jpg': 'image/jpeg',
2860
- '.jpeg': 'image/jpeg',
2861
- };
2862
-
2863
- /**
2864
- * Deploy app bundle directly to Supabase storage, bypassing the backend server.
2865
- */
2866
- export async function directDeploy(projectDir, appId) {
2867
- const { supabaseUrl, supabaseKey } = resolveSupabaseCredentials();
2868
- const manifest = readManifest(projectDir);
2869
- const artifactFiles = readArtifactFiles(projectDir);
2870
- const sourceFiles = readSourceFiles(projectDir);
2871
- validateArtifactBoundary(artifactFiles);
2872
-
2873
- // Get current version and increment
2874
- const { currentVersion } = await getAppCurrentVersion(supabaseUrl, supabaseKey, appId);
2875
- const newVersion = currentVersion + 1;
2876
-
2877
- // Upload all files from the output directory
2878
- const bucket = 'app-code';
2879
- await ensureStorageBucket(supabaseUrl, supabaseKey, bucket, { public: false });
2880
- await ensureStorageBucket(supabaseUrl, supabaseKey, 'app-source', { public: false });
2881
-
2882
- async function uploadDir(dir, prefix) {
2883
- const entries = readdirSync(dir, { withFileTypes: true });
2884
- for (const entry of entries) {
2885
- const fullPath = join(dir, entry.name);
2886
- const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
2887
- if (entry.isDirectory()) {
2888
- await uploadDir(fullPath, relPath);
2889
- } else {
2890
- const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : '';
2891
- const contentType = CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
2892
- const content = readFileSync(fullPath);
2893
- const storagePath = `${appId}/v${newVersion}/${relPath}`;
2894
- await uploadToStorage(supabaseUrl, supabaseKey, bucket, storagePath, content, contentType);
2895
- }
2896
- }
2897
- }
2898
-
2899
- await uploadDir(join(projectDir, OUTPUT_DIR), '');
2900
-
2901
- for (const [relPath, content] of Object.entries(sourceFiles)) {
2902
- const ext = relPath.includes('.') ? `.${relPath.split('.').pop()}` : '';
2903
- const contentType = CONTENT_TYPE_MAP[ext] || 'application/octet-stream';
2904
- const storagePath = `${appId}/v${newVersion}/${relPath}`;
2905
- await uploadToStorage(supabaseUrl, supabaseKey, 'app-source', storagePath, content, contentType);
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;
2906
3046
  }
2907
-
2908
- // Update the app record -- include storage_prefix so the portal can resolve bundle URLs
2909
- const storagePrefix = `${appId}/v${newVersion}/`;
2910
- const deployManifest = {
2911
- ...manifest,
2912
- version: newVersion,
2913
- storage_bucket: bucket,
2914
- storage_prefix: storagePrefix,
2915
- source_storage_bucket: 'app-source',
2916
- source_storage_prefix: storagePrefix,
2917
- };
2918
- await updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, deployManifest);
2919
-
2920
- return { version: newVersion };
2921
3047
  }