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