@notis_ai/cli 0.2.0-beta.156.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.
- package/README.md +11 -45
- package/config/notis_app_design_rules.json +135 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +5168 -7271
- package/dist/base-skills/notis-apps/SKILL.md +108 -194
- package/dist/base-skills/notis-cli/SKILL.md +64 -131
- package/package.json +1 -2
- package/skills/notis-apps/cli.md +34 -95
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +1 -1
- package/src/command-specs/apps.js +322 -1560
- package/src/runtime/agent-browser.js +169 -1
- package/src/runtime/app-boundary-validator.js +221 -0
- package/src/runtime/app-platform.js +359 -233
- package/src/runtime/app-test-server.js +292 -0
- package/template/app/page.tsx +45 -44
- package/template/components/page-heading.tsx +23 -0
- package/template/components/ui/badge.tsx +7 -4
- package/template/components/ui/card.tsx +24 -11
- package/template/components/ui/native-select.tsx +24 -0
- package/template/notis.config.ts +0 -1
- package/template/package.json +2 -2
- package/template/packages/sdk/package.json +1 -2
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +20 -7
- package/template/packages/sdk/src/config.ts +0 -2
- package/template/packages/sdk/src/interactions.ts +2 -1
- package/template/packages/sdk/src/styles.css +28 -1
- package/src/runtime/app-dev-build-supervisor.js +0 -47
- package/src/runtime/app-dev-build.js +0 -41
- package/src/runtime/app-dev-consumers.js +0 -154
- package/src/runtime/app-dev-host-lock.js +0 -80
- package/src/runtime/app-dev-process-identity.js +0 -111
- package/src/runtime/app-dev-roots.js +0 -284
- package/src/runtime/app-dev-server.js +0 -1136
- package/src/runtime/app-dev-sessions.js +0 -185
- package/src/runtime/cli-mode.generated.js +0 -5
- 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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
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
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
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
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
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
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
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
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
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
|
-
|
|
3014
|
+
if (files.length === 0 && !existsSync(manifestPath)) return null;
|
|
3015
|
+
return hash.digest('hex');
|
|
2806
3016
|
}
|
|
2807
3017
|
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
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
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
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
|
}
|