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