@notis_ai/cli 0.2.0-beta.139.1 → 0.2.0-beta.144.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 +44 -20
- package/dist/agent-hooks/notis-agent-hook.mjs +4579 -1449
- package/dist/base-skills/notis-apps/SKILL.md +83 -55
- package/dist/base-skills/notis-cli/SKILL.md +8 -8
- package/package.json +2 -2
- package/skills/notis-apps/cli.md +38 -17
- package/src/command-specs/apps.js +871 -387
- package/src/command-specs/auth.js +47 -10
- package/src/command-specs/onboarding.js +11 -4
- package/src/runtime/app-dev-consumers.js +154 -0
- package/src/runtime/app-dev-host-lock.js +80 -0
- package/src/runtime/app-dev-roots.js +284 -0
- package/src/runtime/app-dev-server.js +212 -22
- package/src/runtime/app-dev-sessions.js +105 -145
- package/src/runtime/app-platform.js +1291 -165
- package/src/runtime/app-registry-scaffolds.js +367 -0
- package/src/runtime/login-listener.js +15 -0
- package/src/runtime/oauth.js +1506 -141
- package/src/runtime/output.js +7 -0
- package/src/runtime/ports.js +16 -0
- package/src/runtime/profiles.js +0 -5
- package/template/app/layout.tsx +5 -1
- package/template/notis.config.ts +1 -0
- package/template/packages/sdk/package.json +2 -1
- package/template/packages/sdk/src/components/MarkdownEditor.tsx +121 -0
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +37 -119
- package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +5 -1
- package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +1 -1
- package/template/packages/sdk/src/components/NotisSelectionBoundary.tsx +55 -0
- package/template/packages/sdk/src/components/ShortcutHints.tsx +56 -0
- package/template/packages/sdk/src/config.ts +19 -0
- package/template/packages/sdk/src/hooks/useActiveResource.ts +19 -0
- package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +709 -0
- package/template/packages/sdk/src/hooks/useHandover.ts +3 -0
- package/template/packages/sdk/src/hooks/useMultiSelect.ts +44 -482
- package/template/packages/sdk/src/hooks/useTool.ts +5 -4
- package/template/packages/sdk/src/index.ts +47 -2
- package/template/packages/sdk/src/interactions/actions.ts +46 -0
- package/template/packages/sdk/src/interactions/shortcuts.tsx +628 -0
- package/template/packages/sdk/src/interactions.ts +41 -0
- package/template/packages/sdk/src/provider.tsx +2 -1
- package/template/packages/sdk/src/runtime.ts +85 -3
- package/dist/scaffolds/notis-database/CHANGELOG.md +0 -5
- package/dist/scaffolds/notis-database/app/globals.css +0 -44
- package/dist/scaffolds/notis-database/app/layout.tsx +0 -6
- package/dist/scaffolds/notis-database/app/page.tsx +0 -1088
- package/dist/scaffolds/notis-database/components/ui/badge.tsx +0 -28
- package/dist/scaffolds/notis-database/components/ui/button.tsx +0 -53
- package/dist/scaffolds/notis-database/components/ui/card.tsx +0 -56
- package/dist/scaffolds/notis-database/components/ui/table.tsx +0 -120
- package/dist/scaffolds/notis-database/components.json +0 -20
- package/dist/scaffolds/notis-database/index.html +0 -12
- package/dist/scaffolds/notis-database/lib/types.ts +0 -132
- package/dist/scaffolds/notis-database/lib/utils.ts +0 -6
- package/dist/scaffolds/notis-database/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-4.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-5.png +0 -0
- package/dist/scaffolds/notis-database/metadata/screenshot-fixtures.json +0 -1839
- package/dist/scaffolds/notis-database/notis.config.ts +0 -73
- package/dist/scaffolds/notis-database/package-lock.json +0 -3935
- package/dist/scaffolds/notis-database/package.json +0 -32
- package/dist/scaffolds/notis-database/packages/sdk/package.json +0 -36
- package/dist/scaffolds/notis-database/packages/sdk/src/components/DocumentEditor.tsx +0 -93
- package/dist/scaffolds/notis-database/packages/sdk/src/components/Markdown.tsx +0 -60
- package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
- package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
- package/dist/scaffolds/notis-database/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
- package/dist/scaffolds/notis-database/packages/sdk/src/config.ts +0 -234
- package/dist/scaffolds/notis-database/packages/sdk/src/documents.ts +0 -250
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useBackend.ts +0 -41
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useCloudComputer.ts +0 -97
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSubscription.ts +0 -76
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocument.ts +0 -78
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDocuments.ts +0 -121
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useHandover.ts +0 -75
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useNotis.ts +0 -34
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTool.ts +0 -64
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTools.ts +0 -56
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
- package/dist/scaffolds/notis-database/packages/sdk/src/index.ts +0 -100
- package/dist/scaffolds/notis-database/packages/sdk/src/provider.tsx +0 -43
- package/dist/scaffolds/notis-database/packages/sdk/src/runtime.ts +0 -351
- package/dist/scaffolds/notis-database/packages/sdk/src/styles.css +0 -186
- package/dist/scaffolds/notis-database/packages/sdk/src/ui.ts +0 -15
- package/dist/scaffolds/notis-database/packages/sdk/src/vite.ts +0 -56
- package/dist/scaffolds/notis-database/packages/sdk/tsconfig.json +0 -15
- package/dist/scaffolds/notis-database/postcss.config.mjs +0 -8
- package/dist/scaffolds/notis-database/src/dev-main.tsx +0 -23
- package/dist/scaffolds/notis-database/src/mock-runtime.ts +0 -561
- package/dist/scaffolds/notis-database/tailwind.config.ts +0 -59
- package/dist/scaffolds/notis-database/tsconfig.json +0 -23
- package/dist/scaffolds/notis-database/vite.config.ts +0 -22
- package/dist/scaffolds/notis-journal/CHANGELOG.md +0 -29
- package/dist/scaffolds/notis-journal/app/globals.css +0 -37
- package/dist/scaffolds/notis-journal/app/insights/page.tsx +0 -513
- package/dist/scaffolds/notis-journal/app/journal-core.tsx +0 -362
- package/dist/scaffolds/notis-journal/app/journal-ui.tsx +0 -337
- package/dist/scaffolds/notis-journal/app/layout.tsx +0 -6
- package/dist/scaffolds/notis-journal/app/page.tsx +0 -486
- package/dist/scaffolds/notis-journal/components/ui/badge.tsx +0 -28
- package/dist/scaffolds/notis-journal/components/ui/button.tsx +0 -53
- package/dist/scaffolds/notis-journal/components/ui/card.tsx +0 -56
- package/dist/scaffolds/notis-journal/components.json +0 -20
- package/dist/scaffolds/notis-journal/index.html +0 -12
- package/dist/scaffolds/notis-journal/lib/utils.ts +0 -6
- package/dist/scaffolds/notis-journal/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-4.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-5.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-6.png +0 -0
- package/dist/scaffolds/notis-journal/metadata/screenshot-fixtures.json +0 -132
- package/dist/scaffolds/notis-journal/notis.config.ts +0 -93
- package/dist/scaffolds/notis-journal/package-lock.json +0 -4615
- package/dist/scaffolds/notis-journal/package.json +0 -34
- package/dist/scaffolds/notis-journal/packages/sdk/package.json +0 -36
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/DocumentEditor.tsx +0 -93
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/Markdown.tsx +0 -60
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
- package/dist/scaffolds/notis-journal/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
- package/dist/scaffolds/notis-journal/packages/sdk/src/config.ts +0 -234
- package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +0 -250
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useBackend.ts +0 -41
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useCloudComputer.ts +0 -97
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSubscription.ts +0 -76
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocument.ts +0 -78
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDocuments.ts +0 -121
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useHandover.ts +0 -75
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotis.ts +0 -34
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTool.ts +0 -64
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTools.ts +0 -56
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
- package/dist/scaffolds/notis-journal/packages/sdk/src/index.ts +0 -100
- package/dist/scaffolds/notis-journal/packages/sdk/src/provider.tsx +0 -43
- package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +0 -351
- package/dist/scaffolds/notis-journal/packages/sdk/src/styles.css +0 -186
- package/dist/scaffolds/notis-journal/packages/sdk/src/ui.ts +0 -15
- package/dist/scaffolds/notis-journal/packages/sdk/src/vite.ts +0 -56
- package/dist/scaffolds/notis-journal/packages/sdk/tsconfig.json +0 -15
- package/dist/scaffolds/notis-journal/postcss.config.mjs +0 -8
- package/dist/scaffolds/notis-journal/skills/journal-onboarding/SKILL.md +0 -120
- package/dist/scaffolds/notis-journal/src/dev-main.tsx +0 -58
- package/dist/scaffolds/notis-journal/src/mock-runtime.ts +0 -199
- package/dist/scaffolds/notis-journal/tailwind.config.ts +0 -58
- package/dist/scaffolds/notis-journal/tsconfig.json +0 -23
- package/dist/scaffolds/notis-journal/vite.config.ts +0 -10
- package/dist/scaffolds/notis-notes/CHANGELOG.md +0 -5
- package/dist/scaffolds/notis-notes/app/globals.css +0 -3
- package/dist/scaffolds/notis-notes/app/layout.tsx +0 -6
- package/dist/scaffolds/notis-notes/app/page.tsx +0 -1943
- package/dist/scaffolds/notis-notes/app/phosphor-icons.ts +0 -596
- package/dist/scaffolds/notis-notes/components/ui/badge.tsx +0 -28
- package/dist/scaffolds/notis-notes/components/ui/button.tsx +0 -53
- package/dist/scaffolds/notis-notes/components/ui/card.tsx +0 -56
- package/dist/scaffolds/notis-notes/components.json +0 -20
- package/dist/scaffolds/notis-notes/lib/utils.ts +0 -6
- package/dist/scaffolds/notis-notes/lib/visible-properties.ts +0 -144
- package/dist/scaffolds/notis-notes/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-4.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-5.png +0 -0
- package/dist/scaffolds/notis-notes/metadata/screenshot-fixtures.json +0 -752
- package/dist/scaffolds/notis-notes/notis.config.ts +0 -80
- package/dist/scaffolds/notis-notes/package-lock.json +0 -4636
- package/dist/scaffolds/notis-notes/package.json +0 -35
- package/dist/scaffolds/notis-notes/packages/sdk/package.json +0 -36
- package/dist/scaffolds/notis-notes/packages/sdk/src/components/DocumentEditor.tsx +0 -93
- package/dist/scaffolds/notis-notes/packages/sdk/src/components/Markdown.tsx +0 -60
- package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
- package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
- package/dist/scaffolds/notis-notes/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
- package/dist/scaffolds/notis-notes/packages/sdk/src/config.ts +0 -234
- package/dist/scaffolds/notis-notes/packages/sdk/src/documents.ts +0 -250
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useBackend.ts +0 -41
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useCloudComputer.ts +0 -97
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSubscription.ts +0 -76
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocument.ts +0 -78
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDocuments.ts +0 -121
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useHandover.ts +0 -75
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotis.ts +0 -34
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTool.ts +0 -64
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTools.ts +0 -56
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
- package/dist/scaffolds/notis-notes/packages/sdk/src/index.ts +0 -100
- package/dist/scaffolds/notis-notes/packages/sdk/src/provider.tsx +0 -43
- package/dist/scaffolds/notis-notes/packages/sdk/src/runtime.ts +0 -351
- package/dist/scaffolds/notis-notes/packages/sdk/src/styles.css +0 -186
- package/dist/scaffolds/notis-notes/packages/sdk/src/ui.ts +0 -15
- package/dist/scaffolds/notis-notes/packages/sdk/src/vite.ts +0 -56
- package/dist/scaffolds/notis-notes/packages/sdk/tsconfig.json +0 -15
- package/dist/scaffolds/notis-notes/postcss.config.mjs +0 -8
- package/dist/scaffolds/notis-notes/tailwind.config.ts +0 -58
- package/dist/scaffolds/notis-notes/tsconfig.json +0 -23
- package/dist/scaffolds/notis-notes/vite.config.ts +0 -10
- package/dist/scaffolds/notis-random/CHANGELOG.md +0 -15
- package/dist/scaffolds/notis-random/README.md +0 -33
- package/dist/scaffolds/notis-random/app/globals.css +0 -11
- package/dist/scaffolds/notis-random/app/history/page.tsx +0 -67
- package/dist/scaffolds/notis-random/app/layout.tsx +0 -7
- package/dist/scaffolds/notis-random/app/page.tsx +0 -289
- package/dist/scaffolds/notis-random/components/ui/button.tsx +0 -50
- package/dist/scaffolds/notis-random/components/ui/card.tsx +0 -16
- package/dist/scaffolds/notis-random/components/ui/input.tsx +0 -23
- package/dist/scaffolds/notis-random/components.json +0 -20
- package/dist/scaffolds/notis-random/index.html +0 -12
- package/dist/scaffolds/notis-random/lib/notis-tools.ts +0 -128
- package/dist/scaffolds/notis-random/lib/rng.ts +0 -202
- package/dist/scaffolds/notis-random/lib/roll-record.ts +0 -189
- package/dist/scaffolds/notis-random/lib/utils.ts +0 -25
- package/dist/scaffolds/notis-random/metadata/screenshot-1.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-2.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-3.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-4.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-5.png +0 -0
- package/dist/scaffolds/notis-random/metadata/screenshot-fixtures.json +0 -753
- package/dist/scaffolds/notis-random/notis.config.ts +0 -86
- package/dist/scaffolds/notis-random/package-lock.json +0 -4513
- package/dist/scaffolds/notis-random/package.json +0 -36
- package/dist/scaffolds/notis-random/packages/sdk/package.json +0 -36
- package/dist/scaffolds/notis-random/packages/sdk/src/components/DocumentEditor.tsx +0 -93
- package/dist/scaffolds/notis-random/packages/sdk/src/components/Markdown.tsx +0 -60
- package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectActionBar.tsx +0 -278
- package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectCheckbox.tsx +0 -91
- package/dist/scaffolds/notis-random/packages/sdk/src/components/MultiSelectDragOverlay.tsx +0 -39
- package/dist/scaffolds/notis-random/packages/sdk/src/config.ts +0 -234
- package/dist/scaffolds/notis-random/packages/sdk/src/documents.ts +0 -250
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useBackend.ts +0 -41
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useCloudComputer.ts +0 -97
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSubscription.ts +0 -76
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocument.ts +0 -78
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDocuments.ts +0 -121
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useHandover.ts +0 -75
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useMultiSelect.ts +0 -539
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotis.ts +0 -34
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useNotisNavigation.ts +0 -49
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTool.ts +0 -64
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTools.ts +0 -56
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useUpsertDocument.ts +0 -95
- package/dist/scaffolds/notis-random/packages/sdk/src/index.ts +0 -100
- package/dist/scaffolds/notis-random/packages/sdk/src/provider.tsx +0 -43
- package/dist/scaffolds/notis-random/packages/sdk/src/runtime.ts +0 -351
- package/dist/scaffolds/notis-random/packages/sdk/src/styles.css +0 -186
- package/dist/scaffolds/notis-random/packages/sdk/src/ui.ts +0 -15
- package/dist/scaffolds/notis-random/packages/sdk/src/vite.ts +0 -56
- package/dist/scaffolds/notis-random/packages/sdk/tsconfig.json +0 -15
- package/dist/scaffolds/notis-random/postcss.config.mjs +0 -6
- package/dist/scaffolds/notis-random/src/dev-main.tsx +0 -70
- package/dist/scaffolds/notis-random/src/mock-runtime.ts +0 -129
- package/dist/scaffolds/notis-random/tailwind.config.ts +0 -50
- package/dist/scaffolds/notis-random/tsconfig.json +0 -23
- package/dist/scaffolds/notis-random/vite.config.ts +0 -11
- package/dist/scaffolds.json +0 -49
|
@@ -7,13 +7,16 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { spawn } from 'node:child_process';
|
|
10
|
-
import {
|
|
10
|
+
import { 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 } from 'node:path';
|
|
13
15
|
import { fileURLToPath } from 'node:url';
|
|
14
16
|
import { gunzipSync } from 'node:zlib';
|
|
15
17
|
|
|
16
18
|
import { usageError } from './errors.js';
|
|
19
|
+
import { acquireScaffoldSource, loadScaffoldCatalog } from './app-registry-scaffolds.js';
|
|
17
20
|
import { validateArtifactBoundary, validateProjectBoundary } from './app-boundary-validator.js';
|
|
18
21
|
import { CHANGELOG_MERGE_DATE, readAppChangelog } from './app-changelog.js';
|
|
19
22
|
|
|
@@ -24,10 +27,6 @@ const BUNDLE_DIR = join(OUTPUT_DIR, 'bundle');
|
|
|
24
27
|
const MANIFEST_FILE = join(OUTPUT_DIR, 'manifest.json');
|
|
25
28
|
const METADATA_DIR = 'metadata';
|
|
26
29
|
const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
27
|
-
const MONOREPO_SCAFFOLDS_DIR = resolve(CLI_ROOT, '../..', 'scaffolds');
|
|
28
|
-
const DIST_DIR = join(CLI_ROOT, 'dist');
|
|
29
|
-
const SCAFFOLD_CATALOG_FILE = join(DIST_DIR, 'scaffolds.json');
|
|
30
|
-
const SCAFFOLD_SOURCE_DIR = join(DIST_DIR, 'scaffolds');
|
|
31
30
|
const TEMPLATE_SDK_DIR = join(CLI_ROOT, 'template', 'packages', 'sdk');
|
|
32
31
|
export const NOTIS_APP_CATEGORIES = [
|
|
33
32
|
'Productivity',
|
|
@@ -48,6 +47,9 @@ const SOURCE_COPY_EXCLUDES = new Set([
|
|
|
48
47
|
// scripts/ directory must not ship version-specific bytecode in the bundle.
|
|
49
48
|
'__pycache__',
|
|
50
49
|
]);
|
|
50
|
+
const SOURCE_COPY_EXCLUDES_CASEFOLDED = new Set(
|
|
51
|
+
[...SOURCE_COPY_EXCLUDES].map((name) => name.toLocaleLowerCase('en-US')),
|
|
52
|
+
);
|
|
51
53
|
const SCAFFOLD_COPY_EXCLUDES = new Set([
|
|
52
54
|
...SOURCE_COPY_EXCLUDES,
|
|
53
55
|
'coverage',
|
|
@@ -60,6 +62,11 @@ const SCAFFOLD_COPY_EXCLUDES = new Set([
|
|
|
60
62
|
// not listing media, it is the stub data the dev harness serves, and dropping
|
|
61
63
|
// it would make every route of a fresh project render its empty state.
|
|
62
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));
|
|
63
70
|
// A directory-declared skill ships every supporting file to the sandbox, so it
|
|
64
71
|
// needs a ceiling of its own. Kept well above the 512 KB SKILL.md limit so a
|
|
65
72
|
// handful of scripts always fits, and far below the bundle machinery's own
|
|
@@ -71,8 +78,38 @@ let appConfigImportNonce = 0;
|
|
|
71
78
|
// Project directory resolution
|
|
72
79
|
// ---------------------------------------------------------------------------
|
|
73
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
|
+
|
|
74
111
|
export function resolveProjectDir(inputDir = '.') {
|
|
75
|
-
return resolve(process.cwd(), inputDir);
|
|
112
|
+
return resolve(process.cwd(), expandHomePath(inputDir));
|
|
76
113
|
}
|
|
77
114
|
|
|
78
115
|
export function getBundleDir(projectDir) {
|
|
@@ -859,11 +896,25 @@ export function generateManifest(appConfig, projectDir) {
|
|
|
859
896
|
databases,
|
|
860
897
|
capabilities: normalizeAppCapabilities(appConfig.capabilities),
|
|
861
898
|
tools: appConfig.tools || [],
|
|
899
|
+
tool_bindings: normalizeAppToolBindings(appConfig.toolBindings),
|
|
862
900
|
skills,
|
|
863
901
|
onboarding: appConfig.onboarding || null,
|
|
864
902
|
};
|
|
865
903
|
}
|
|
866
904
|
|
|
905
|
+
export function normalizeAppToolBindings(bindings) {
|
|
906
|
+
return (Array.isArray(bindings) ? bindings : [])
|
|
907
|
+
.map((binding) => {
|
|
908
|
+
const name = typeof binding?.name === 'string' ? binding.name.trim() : '';
|
|
909
|
+
const providerToolName =
|
|
910
|
+
typeof binding?.providerToolName === 'string' ? binding.providerToolName.trim() : '';
|
|
911
|
+
return name && providerToolName
|
|
912
|
+
? { name, provider_tool_name: providerToolName }
|
|
913
|
+
: null;
|
|
914
|
+
})
|
|
915
|
+
.filter(Boolean);
|
|
916
|
+
}
|
|
917
|
+
|
|
867
918
|
/**
|
|
868
919
|
* Keeps only capabilities the platform actually understands, at the exact
|
|
869
920
|
* values it accepts. An unknown key or value is dropped rather than passed
|
|
@@ -1065,21 +1116,212 @@ function copyMetadataAssets(projectDir) {
|
|
|
1065
1116
|
// Linking
|
|
1066
1117
|
// ---------------------------------------------------------------------------
|
|
1067
1118
|
|
|
1068
|
-
|
|
1119
|
+
const PROFILE_LINK_FIELDS = new Set([
|
|
1120
|
+
'app_id',
|
|
1121
|
+
'linked_at',
|
|
1122
|
+
'deployed_at',
|
|
1123
|
+
'version',
|
|
1124
|
+
'dev_app_id',
|
|
1125
|
+
'dev_linked_at',
|
|
1126
|
+
'auto_linked_at',
|
|
1127
|
+
'cloud_computer_shell_consent',
|
|
1128
|
+
]);
|
|
1129
|
+
|
|
1130
|
+
function splitLinkedState(state) {
|
|
1131
|
+
const base = {};
|
|
1132
|
+
const link = {};
|
|
1133
|
+
for (const [key, value] of Object.entries(state || {})) {
|
|
1134
|
+
if (key === 'profiles') continue;
|
|
1135
|
+
(PROFILE_LINK_FIELDS.has(key) ? link : base)[key] = value;
|
|
1136
|
+
}
|
|
1137
|
+
return { base, link };
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
export function appLinkedStateProfileKey({ apiBase, userId }) {
|
|
1141
|
+
const normalizedApi = String(apiBase || '').trim().replace(/\/$/, '');
|
|
1142
|
+
const normalizedUser = String(userId || '').trim();
|
|
1143
|
+
if (!normalizedApi || !normalizedUser) return null;
|
|
1144
|
+
return Buffer.from(`${normalizedApi}\0${normalizedUser}`).toString('base64url');
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
export function readLinkedState(projectDir, profileKey = null) {
|
|
1069
1148
|
const statePath = join(projectDir, STATE_FILE);
|
|
1149
|
+
assertLinkedStatePathSafe(projectDir);
|
|
1070
1150
|
if (!existsSync(statePath)) return null;
|
|
1071
|
-
|
|
1151
|
+
const raw = JSON.parse(readFileSync(statePath, 'utf-8'));
|
|
1152
|
+
if (!profileKey) return raw;
|
|
1153
|
+
const { base, link: legacyLink } = splitLinkedState(raw);
|
|
1154
|
+
const profiles = raw?.profiles && typeof raw.profiles === 'object' ? raw.profiles : {};
|
|
1155
|
+
const scoped = profiles[profileKey];
|
|
1156
|
+
return {
|
|
1157
|
+
...base,
|
|
1158
|
+
...(scoped && typeof scoped === 'object' ? scoped : legacyLink),
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function readStateWriteLockOwner(lockPath) {
|
|
1163
|
+
try {
|
|
1164
|
+
return readFileSync(join(lockPath, 'owner'), 'utf8').trim();
|
|
1165
|
+
} catch {
|
|
1166
|
+
return null;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function reclaimStaleStateWriteLock(lockPath, observedOwner, observedMtimeMs) {
|
|
1171
|
+
const quarantinePath = `${lockPath}.stale-${process.pid}-${randomUUID()}`;
|
|
1172
|
+
try {
|
|
1173
|
+
renameSync(lockPath, quarantinePath);
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
if (error?.code === 'ENOENT') return false;
|
|
1176
|
+
throw error;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
let unchanged = false;
|
|
1180
|
+
try {
|
|
1181
|
+
const quarantinedStat = lstatSync(quarantinePath);
|
|
1182
|
+
unchanged = readStateWriteLockOwner(quarantinePath) === observedOwner
|
|
1183
|
+
&& quarantinedStat.mtimeMs === observedMtimeMs;
|
|
1184
|
+
} catch (error) {
|
|
1185
|
+
try {
|
|
1186
|
+
renameSync(quarantinePath, lockPath);
|
|
1187
|
+
} catch {
|
|
1188
|
+
// Fail closed below if the quarantined lock cannot be restored.
|
|
1189
|
+
}
|
|
1190
|
+
throw error;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
if (!unchanged) {
|
|
1194
|
+
try {
|
|
1195
|
+
renameSync(quarantinePath, lockPath);
|
|
1196
|
+
} catch {
|
|
1197
|
+
throw usageError(`Notis app state lock changed while it was being recovered: ${lockPath}`);
|
|
1198
|
+
}
|
|
1199
|
+
return false;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
rmSync(quarantinePath, { recursive: true, force: true });
|
|
1203
|
+
return true;
|
|
1072
1204
|
}
|
|
1073
1205
|
|
|
1074
|
-
|
|
1206
|
+
function withStateWriteLock(statePath, callback) {
|
|
1207
|
+
mkdirSync(dirname(statePath), { recursive: true, mode: 0o700 });
|
|
1208
|
+
const lockPath = `${statePath}.write-lock`;
|
|
1209
|
+
const ownerId = `${process.pid}.${randomUUID()}`;
|
|
1210
|
+
const startedAt = Date.now();
|
|
1211
|
+
while (true) {
|
|
1212
|
+
try {
|
|
1213
|
+
mkdirSync(lockPath, { mode: 0o700 });
|
|
1214
|
+
try {
|
|
1215
|
+
writeFileSync(join(lockPath, 'owner'), ownerId, { mode: 0o600, flag: 'wx' });
|
|
1216
|
+
} catch (error) {
|
|
1217
|
+
// A stale-lock recovery may have moved this candidate while the owner
|
|
1218
|
+
// record was being written. Never remove a replacement writer's lock.
|
|
1219
|
+
if (readStateWriteLockOwner(lockPath) === ownerId) {
|
|
1220
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
1221
|
+
}
|
|
1222
|
+
throw error;
|
|
1223
|
+
}
|
|
1224
|
+
break;
|
|
1225
|
+
} catch (error) {
|
|
1226
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
1227
|
+
try {
|
|
1228
|
+
const lockStat = lstatSync(lockPath);
|
|
1229
|
+
if (lockStat.isSymbolicLink() || !lockStat.isDirectory()) {
|
|
1230
|
+
throw usageError(`Refusing unsafe Notis app state lock: ${lockPath}`);
|
|
1231
|
+
}
|
|
1232
|
+
const observedOwner = readStateWriteLockOwner(lockPath);
|
|
1233
|
+
const ownerPidText = String(observedOwner || '').split('.')[0];
|
|
1234
|
+
const ownerPid = /^\d+$/.test(ownerPidText) ? Number.parseInt(ownerPidText, 10) : null;
|
|
1235
|
+
// PID liveness alone is insufficient: operating systems can reuse a
|
|
1236
|
+
// crashed writer's PID for an unrelated process. A linked-state write
|
|
1237
|
+
// is synchronous and normally holds this lock only for milliseconds,
|
|
1238
|
+
// so an aged lock is stale even when that numeric PID now exists.
|
|
1239
|
+
let stale = Date.now() - lockStat.mtimeMs >= STATE_WRITE_LOCK_STALE_MS;
|
|
1240
|
+
if (!stale && Number.isInteger(ownerPid) && ownerPid > 0) {
|
|
1241
|
+
try {
|
|
1242
|
+
process.kill(ownerPid, 0);
|
|
1243
|
+
} catch (ownerError) {
|
|
1244
|
+
if (ownerError?.code === 'ESRCH') {
|
|
1245
|
+
stale = true;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
if (stale && reclaimStaleStateWriteLock(lockPath, observedOwner, lockStat.mtimeMs)) {
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1252
|
+
} catch (statError) {
|
|
1253
|
+
if (statError?.code === 'ENOENT') continue;
|
|
1254
|
+
throw statError;
|
|
1255
|
+
}
|
|
1256
|
+
if (Date.now() - startedAt >= STATE_WRITE_LOCK_TIMEOUT_MS) {
|
|
1257
|
+
throw usageError(`Timed out waiting to update Notis app state: ${statePath}`);
|
|
1258
|
+
}
|
|
1259
|
+
Atomics.wait(stateWriteLockWait, 0, 0, 10);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
try {
|
|
1263
|
+
return callback();
|
|
1264
|
+
} finally {
|
|
1265
|
+
try {
|
|
1266
|
+
if (readStateWriteLockOwner(lockPath) === ownerId) {
|
|
1267
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
1268
|
+
}
|
|
1269
|
+
} catch {
|
|
1270
|
+
// Never remove a lock that no longer belongs to this writer.
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
export function writeLinkedState(projectDir, state, profileKey = null) {
|
|
1075
1276
|
const statePath = join(projectDir, STATE_FILE);
|
|
1076
|
-
|
|
1077
|
-
|
|
1277
|
+
assertLinkedStatePathSafe(projectDir);
|
|
1278
|
+
return withStateWriteLock(statePath, () => {
|
|
1279
|
+
let nextState = state;
|
|
1280
|
+
if (profileKey) {
|
|
1281
|
+
let current = {};
|
|
1282
|
+
if (existsSync(statePath)) {
|
|
1283
|
+
current = JSON.parse(readFileSync(statePath, 'utf-8'));
|
|
1284
|
+
}
|
|
1285
|
+
const { base: currentBase } = splitLinkedState(current);
|
|
1286
|
+
const { base } = splitLinkedState(state);
|
|
1287
|
+
const { link } = splitLinkedState(state);
|
|
1288
|
+
const profiles = current?.profiles && typeof current.profiles === 'object'
|
|
1289
|
+
? { ...current.profiles }
|
|
1290
|
+
: {};
|
|
1291
|
+
profiles[profileKey] = link;
|
|
1292
|
+
nextState = { ...currentBase, ...base, profiles };
|
|
1293
|
+
}
|
|
1294
|
+
const temporary = `${statePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
1295
|
+
writeFileSync(temporary, JSON.stringify(nextState, null, 2) + '\n');
|
|
1296
|
+
renameSync(temporary, statePath);
|
|
1297
|
+
});
|
|
1078
1298
|
}
|
|
1079
1299
|
|
|
1080
|
-
|
|
1300
|
+
function assertLinkedStatePathSafe(projectDir) {
|
|
1301
|
+
const notisDir = join(projectDir, NOTIS_DIR);
|
|
1302
|
+
try {
|
|
1303
|
+
const directoryStat = lstatSync(notisDir);
|
|
1304
|
+
if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
|
|
1305
|
+
throw usageError(`Refusing to use unsafe Notis state directory: ${notisDir}`);
|
|
1306
|
+
}
|
|
1307
|
+
} catch (error) {
|
|
1308
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
const statePath = join(projectDir, STATE_FILE);
|
|
1312
|
+
try {
|
|
1313
|
+
const stateStat = lstatSync(statePath);
|
|
1314
|
+
if (stateStat.isSymbolicLink() || !stateStat.isFile()) {
|
|
1315
|
+
throw usageError(`Refusing to use unsafe Notis state file: ${statePath}`);
|
|
1316
|
+
}
|
|
1317
|
+
} catch (error) {
|
|
1318
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
export function requireLinkedAppId(projectDir, explicitAppId, profileKey = null) {
|
|
1081
1323
|
if (explicitAppId) return explicitAppId;
|
|
1082
|
-
const state = readLinkedState(projectDir);
|
|
1324
|
+
const state = readLinkedState(projectDir, profileKey);
|
|
1083
1325
|
if (state?.app_id) return state.app_id;
|
|
1084
1326
|
throw usageError('This project is not linked to a Notis app. Run "notis apps link <app-id> ." first.');
|
|
1085
1327
|
}
|
|
@@ -1089,79 +1331,342 @@ export function requireLinkedAppId(projectDir, explicitAppId) {
|
|
|
1089
1331
|
// ---------------------------------------------------------------------------
|
|
1090
1332
|
|
|
1091
1333
|
/**
|
|
1092
|
-
* Scaffold a new Notis app project from the SDK template
|
|
1334
|
+
* Scaffold a new Notis app project from the SDK template or from a published
|
|
1335
|
+
* Store app downloaded from the public registry repository.
|
|
1093
1336
|
*/
|
|
1094
|
-
export function
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
:
|
|
1337
|
+
export async function scaffoldProject({ projectDir, appName, fromSlug = null }) {
|
|
1338
|
+
let scaffoldSource = null;
|
|
1339
|
+
if (fromSlug) {
|
|
1340
|
+
scaffoldSource = await acquireScaffoldSource(fromSlug);
|
|
1341
|
+
if (!scaffoldSource) {
|
|
1342
|
+
const known = (await loadScaffoldCatalog()).map((entry) => entry.slug).join(', ') || 'none';
|
|
1343
|
+
throw usageError(`Unknown scaffold "${fromSlug}". Available scaffolds: ${known}.`);
|
|
1344
|
+
}
|
|
1101
1345
|
}
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1346
|
+
try {
|
|
1347
|
+
return scaffoldProjectFromDir({
|
|
1348
|
+
projectDir,
|
|
1349
|
+
appName,
|
|
1350
|
+
fromSlug,
|
|
1351
|
+
templateDir: scaffoldSource ? scaffoldSource.dir : join(CLI_ROOT, 'template'),
|
|
1352
|
+
});
|
|
1353
|
+
} finally {
|
|
1354
|
+
scaffoldSource?.cleanup();
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function scaffoldProjectFromDir({ projectDir, appName, fromSlug, templateDir }) {
|
|
1359
|
+
const sourceDir = resolve(templateDir);
|
|
1360
|
+
const requestedProjectDir = resolve(projectDir);
|
|
1361
|
+
if (!existsSync(sourceDir)) {
|
|
1362
|
+
throw usageError(`SDK template not found at ${sourceDir}. Ensure @notis_ai/cli is installed correctly.`);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
const parentDir = dirname(requestedProjectDir);
|
|
1366
|
+
const targetName = basename(requestedProjectDir);
|
|
1367
|
+
mkdirSync(parentDir, { recursive: true });
|
|
1368
|
+
const canonicalParent = realpathSync(parentDir);
|
|
1369
|
+
const parentIdentity = capturePullTargetIdentity(canonicalParent);
|
|
1370
|
+
const initialTargetIdentity = captureScaffoldTargetIdentity(requestedProjectDir);
|
|
1371
|
+
const previousCwd = process.cwd();
|
|
1372
|
+
const previousCwdIdentity = capturePullTargetIdentity(previousCwd);
|
|
1373
|
+
let cwdPinned = false;
|
|
1374
|
+
let stageName = null;
|
|
1375
|
+
let cleanupStage = true;
|
|
1376
|
+
let activeTargetIdentity = null;
|
|
1377
|
+
const scaffoldLockName = '.notis-app-scaffold-lock';
|
|
1378
|
+
const scaffoldLockOwnerId = `${process.pid}.${randomUUID()}`;
|
|
1379
|
+
try {
|
|
1380
|
+
process.chdir(canonicalParent);
|
|
1381
|
+
cwdPinned = true;
|
|
1382
|
+
assertPullTargetIdentity('.', parentIdentity);
|
|
1383
|
+
const lockedTargetIdentity = captureScaffoldTargetIdentity(targetName);
|
|
1384
|
+
if (!pullTargetIdentitiesMatch(initialTargetIdentity, lockedTargetIdentity)) {
|
|
1385
|
+
throw usageError(`App scaffold target changed before copying: ${requestedProjectDir}`);
|
|
1386
|
+
}
|
|
1387
|
+
if (!lockedTargetIdentity.exists) {
|
|
1388
|
+
try {
|
|
1389
|
+
mkdirSync(targetName);
|
|
1390
|
+
} catch (error) {
|
|
1391
|
+
if (error?.code === 'EEXIST') {
|
|
1392
|
+
throw usageError(`App scaffold target changed before copying: ${requestedProjectDir}`);
|
|
1393
|
+
}
|
|
1394
|
+
throw error;
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
activeTargetIdentity = captureScaffoldTargetIdentity(targetName);
|
|
1398
|
+
process.chdir(targetName);
|
|
1399
|
+
if (!pullTargetIdentitiesMatch(
|
|
1400
|
+
activeTargetIdentity,
|
|
1401
|
+
captureScaffoldTargetIdentity('.'),
|
|
1402
|
+
)) {
|
|
1403
|
+
throw usageError(`App scaffold target changed before copying: ${requestedProjectDir}`);
|
|
1404
|
+
}
|
|
1405
|
+
mkdirSync(scaffoldLockName, { mode: 0o700 });
|
|
1406
|
+
writeFileSync(
|
|
1407
|
+
join(scaffoldLockName, 'owner'),
|
|
1408
|
+
JSON.stringify({ id: scaffoldLockOwnerId }),
|
|
1409
|
+
{ mode: 0o600 },
|
|
1410
|
+
);
|
|
1107
1411
|
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1412
|
+
stageName = basename(mkdtempSync(join('.', '.notis-app-scaffold-')));
|
|
1413
|
+
projectDir = stageName;
|
|
1414
|
+
|
|
1415
|
+
copyScaffoldSource(sourceDir, projectDir);
|
|
1416
|
+
|
|
1417
|
+
// Update package.json with the app name
|
|
1418
|
+
const pkgPath = join(projectDir, 'package.json');
|
|
1419
|
+
if (existsSync(pkgPath)) {
|
|
1420
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
1421
|
+
pkg.name = appName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
|
|
1422
|
+
// A scaffold creates a new app identity, even when its source comes from a
|
|
1423
|
+
// versioned Store example. New apps must therefore start their own release
|
|
1424
|
+
// history instead of inheriting the example app's registry version.
|
|
1425
|
+
pkg.notisAppVersion = '0.1.0';
|
|
1426
|
+
normalizeScaffoldPackageScripts(pkg);
|
|
1427
|
+
ensureScaffoldLocalSdk(projectDir, pkg);
|
|
1428
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
1429
|
+
normalizeScaffoldLockfile(projectDir, pkg);
|
|
1112
1430
|
}
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
const slugName = jsStringLiteral(safeKebab(appName) || 'my-notis-app');
|
|
1139
|
-
if (fromSlug) {
|
|
1140
|
-
if (/name\s*:/.test(config)) {
|
|
1141
|
-
config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName}`);
|
|
1431
|
+
|
|
1432
|
+
// Update notis.config.ts with the app name
|
|
1433
|
+
const configPath = join(projectDir, 'notis.config.ts');
|
|
1434
|
+
if (existsSync(configPath)) {
|
|
1435
|
+
let config = readFileSync(configPath, 'utf-8');
|
|
1436
|
+
const displayName = jsStringLiteral(appName);
|
|
1437
|
+
const slugName = jsStringLiteral(safeKebab(appName) || 'my-notis-app');
|
|
1438
|
+
if (fromSlug) {
|
|
1439
|
+
if (/name\s*:/.test(config)) {
|
|
1440
|
+
config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName}`);
|
|
1441
|
+
}
|
|
1442
|
+
if (/title\s*:/.test(config)) {
|
|
1443
|
+
config = config.replace(/title\s*:\s*(['"`])[\s\S]*?\1/, `title: ${displayName}`);
|
|
1444
|
+
} else {
|
|
1445
|
+
config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName},\n title: ${displayName}`);
|
|
1446
|
+
}
|
|
1447
|
+
} else {
|
|
1448
|
+
if (/name\s*:/.test(config)) {
|
|
1449
|
+
config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName}`);
|
|
1450
|
+
}
|
|
1451
|
+
if (/title\s*:/.test(config)) {
|
|
1452
|
+
config = config.replace(/title\s*:\s*(['"`])[\s\S]*?\1/, `title: ${displayName}`);
|
|
1453
|
+
} else {
|
|
1454
|
+
config = config.replace(/'My Notis App'/, displayName);
|
|
1455
|
+
}
|
|
1142
1456
|
}
|
|
1143
|
-
if (/
|
|
1144
|
-
config = config.replace(/
|
|
1457
|
+
if (/devSlug\s*:/.test(config)) {
|
|
1458
|
+
config = config.replace(/devSlug\s*:\s*(['"`])[\s\S]*?\1/, `devSlug: ${slugName}`);
|
|
1145
1459
|
} else {
|
|
1146
|
-
config = config.replace(
|
|
1460
|
+
config = config.replace(
|
|
1461
|
+
/name\s*:\s*(['"`])[\s\S]*?\1/,
|
|
1462
|
+
(nameDeclaration) => `${nameDeclaration},\n devSlug: ${slugName}`,
|
|
1463
|
+
);
|
|
1147
1464
|
}
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1465
|
+
config = removeConfigArrayProperty(config, 'screenshots');
|
|
1466
|
+
writeFileSync(configPath, config);
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
resetScaffoldChangelog(projectDir, appName);
|
|
1470
|
+
|
|
1471
|
+
assertPullParentIdentity(parentDir, canonicalParent, parentIdentity);
|
|
1472
|
+
assertPullTargetIdentity(requestedProjectDir, activeTargetIdentity);
|
|
1473
|
+
const stageRoot = realpathSync(stageName);
|
|
1474
|
+
const installedEntries = [];
|
|
1475
|
+
const installedDirectoryIdentities = new Map([['', activeTargetIdentity]]);
|
|
1476
|
+
const captureInstalledEntry = (entryPath) => {
|
|
1477
|
+
const entryStat = lstatSync(entryPath, { bigint: true });
|
|
1478
|
+
return {
|
|
1479
|
+
exists: true,
|
|
1480
|
+
path: entryPath,
|
|
1481
|
+
dev: entryStat.dev,
|
|
1482
|
+
ino: entryStat.ino,
|
|
1483
|
+
directory: entryStat.isDirectory(),
|
|
1484
|
+
};
|
|
1485
|
+
};
|
|
1486
|
+
const installExclusive = (sourcePath, destinationName, relativePath, parentIdentity) => {
|
|
1487
|
+
const sourceStat = lstatSync(sourcePath);
|
|
1488
|
+
if (sourceStat.isSymbolicLink()) {
|
|
1489
|
+
throw usageError(`Refusing symlinked scaffold source entry: ${sourcePath}`);
|
|
1151
1490
|
}
|
|
1152
|
-
if (
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1491
|
+
if (sourceStat.isDirectory()) {
|
|
1492
|
+
mkdirSync(destinationName, { mode: sourceStat.mode & 0o777 });
|
|
1493
|
+
const installedDirectory = captureInstalledEntry(destinationName);
|
|
1494
|
+
installedDirectory.path = relativePath;
|
|
1495
|
+
installedEntries.push(installedDirectory);
|
|
1496
|
+
installedDirectoryIdentities.set(relativePath, installedDirectory);
|
|
1497
|
+
process.chdir(destinationName);
|
|
1498
|
+
if (!pullTargetIdentitiesMatch(
|
|
1499
|
+
installedDirectory,
|
|
1500
|
+
capturePullTargetIdentity('.'),
|
|
1501
|
+
)) {
|
|
1502
|
+
throw usageError(`App scaffold destination changed during installation: ${relativePath}`);
|
|
1503
|
+
}
|
|
1504
|
+
try {
|
|
1505
|
+
for (const name of readdirSync(sourcePath)) {
|
|
1506
|
+
installExclusive(
|
|
1507
|
+
join(sourcePath, name),
|
|
1508
|
+
name,
|
|
1509
|
+
`${relativePath}/${name}`,
|
|
1510
|
+
installedDirectory,
|
|
1511
|
+
);
|
|
1512
|
+
}
|
|
1513
|
+
} finally {
|
|
1514
|
+
const parentRelativePath = dirname(relativePath) === '.' ? '' : dirname(relativePath);
|
|
1515
|
+
process.chdir(parentRelativePath
|
|
1516
|
+
? join(requestedProjectDir, parentRelativePath)
|
|
1517
|
+
: requestedProjectDir);
|
|
1518
|
+
if (!pullTargetIdentitiesMatch(
|
|
1519
|
+
parentIdentity,
|
|
1520
|
+
capturePullTargetIdentity('.'),
|
|
1521
|
+
)) {
|
|
1522
|
+
throw usageError(`App scaffold destination changed during installation: ${parentRelativePath || '.'}`);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
if (!sourceStat.isFile()) {
|
|
1528
|
+
throw usageError(`Refusing unsupported scaffold source entry: ${sourcePath}`);
|
|
1529
|
+
}
|
|
1530
|
+
copyFileSync(sourcePath, destinationName, fsConstants.COPYFILE_EXCL);
|
|
1531
|
+
const installedFile = captureInstalledEntry(destinationName);
|
|
1532
|
+
installedFile.path = relativePath;
|
|
1533
|
+
installedEntries.push(installedFile);
|
|
1534
|
+
};
|
|
1535
|
+
try {
|
|
1536
|
+
for (const name of readdirSync(stageName)) {
|
|
1537
|
+
installExclusive(join(stageRoot, name), name, name, activeTargetIdentity);
|
|
1538
|
+
}
|
|
1539
|
+
assertPullParentIdentity(parentDir, canonicalParent, parentIdentity);
|
|
1540
|
+
assertPullTargetIdentity(requestedProjectDir, activeTargetIdentity);
|
|
1541
|
+
} catch (error) {
|
|
1542
|
+
try {
|
|
1543
|
+
for (const installed of installedEntries.reverse()) {
|
|
1544
|
+
const parentRelativePath = dirname(installed.path) === '.' ? '' : dirname(installed.path);
|
|
1545
|
+
const parentPath = parentRelativePath
|
|
1546
|
+
? join(requestedProjectDir, parentRelativePath)
|
|
1547
|
+
: requestedProjectDir;
|
|
1548
|
+
const expectedParentIdentity = installedDirectoryIdentities.get(parentRelativePath);
|
|
1549
|
+
process.chdir(parentPath);
|
|
1550
|
+
if (!expectedParentIdentity || !pullTargetIdentitiesMatch(
|
|
1551
|
+
expectedParentIdentity,
|
|
1552
|
+
capturePullTargetIdentity('.'),
|
|
1553
|
+
)) {
|
|
1554
|
+
throw usageError(
|
|
1555
|
+
`Refusing to roll back a scaffold directory that changed concurrently: ${parentPath}`,
|
|
1556
|
+
);
|
|
1557
|
+
}
|
|
1558
|
+
const entryName = basename(installed.path);
|
|
1559
|
+
const current = captureInstalledEntry(entryName);
|
|
1560
|
+
if (
|
|
1561
|
+
current.dev !== installed.dev
|
|
1562
|
+
|| current.ino !== installed.ino
|
|
1563
|
+
|| current.directory !== installed.directory
|
|
1564
|
+
) {
|
|
1565
|
+
throw usageError(
|
|
1566
|
+
`Refusing to roll back a scaffold entry that changed concurrently: ${installed.path}`,
|
|
1567
|
+
);
|
|
1568
|
+
}
|
|
1569
|
+
if (installed.directory) {
|
|
1570
|
+
rmdirSync(entryName);
|
|
1571
|
+
} else {
|
|
1572
|
+
unlinkSync(entryName);
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
} catch (rollbackError) {
|
|
1576
|
+
cleanupStage = false;
|
|
1577
|
+
throw usageError(
|
|
1578
|
+
`${error instanceof Error ? error.message : String(error)} `
|
|
1579
|
+
+ `Scaffold rollback failed; recovery files were retained at `
|
|
1580
|
+
+ `${join(requestedProjectDir, stageName)}: `
|
|
1581
|
+
+ `${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
1584
|
+
throw error;
|
|
1585
|
+
}
|
|
1586
|
+
try {
|
|
1587
|
+
rmSync(stageName, { recursive: true, force: true });
|
|
1588
|
+
stageName = null;
|
|
1589
|
+
} catch (error) {
|
|
1590
|
+
cleanupStage = false;
|
|
1591
|
+
throw usageError(
|
|
1592
|
+
`App scaffold was installed, but staging cleanup failed; recovery files were retained at `
|
|
1593
|
+
+ `${join(requestedProjectDir, stageName)}: `
|
|
1594
|
+
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
1595
|
+
);
|
|
1596
|
+
}
|
|
1597
|
+
} finally {
|
|
1598
|
+
try {
|
|
1599
|
+
let targetPinnedForCleanup = false;
|
|
1600
|
+
if (activeTargetIdentity) {
|
|
1601
|
+
try {
|
|
1602
|
+
process.chdir(requestedProjectDir);
|
|
1603
|
+
targetPinnedForCleanup = pullTargetIdentitiesMatch(
|
|
1604
|
+
activeTargetIdentity,
|
|
1605
|
+
capturePullTargetIdentity('.'),
|
|
1606
|
+
);
|
|
1607
|
+
} catch {
|
|
1608
|
+
targetPinnedForCleanup = false;
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
if (targetPinnedForCleanup && cleanupStage && stageName) {
|
|
1612
|
+
rmSync(stageName, { recursive: true, force: true });
|
|
1613
|
+
}
|
|
1614
|
+
if (
|
|
1615
|
+
targetPinnedForCleanup
|
|
1616
|
+
&& readPullLockOwner(scaffoldLockName)?.id === scaffoldLockOwnerId
|
|
1617
|
+
) {
|
|
1618
|
+
rmSync(scaffoldLockName, { recursive: true, force: true });
|
|
1619
|
+
}
|
|
1620
|
+
} finally {
|
|
1621
|
+
if (cwdPinned) {
|
|
1622
|
+
const cwdMatchesPrevious = (
|
|
1623
|
+
previousCwdIdentity.exists
|
|
1624
|
+
&& pullTargetIdentitiesMatch(previousCwdIdentity, capturePullTargetIdentity('.'))
|
|
1625
|
+
);
|
|
1626
|
+
if (!cwdMatchesPrevious) {
|
|
1627
|
+
process.chdir(previousCwd);
|
|
1628
|
+
}
|
|
1156
1629
|
}
|
|
1157
1630
|
}
|
|
1158
|
-
config = removeConfigArrayProperty(config, 'screenshots');
|
|
1159
|
-
writeFileSync(configPath, config);
|
|
1160
1631
|
}
|
|
1161
1632
|
|
|
1162
|
-
|
|
1633
|
+
return { projectDir: requestedProjectDir };
|
|
1634
|
+
}
|
|
1163
1635
|
|
|
1164
|
-
|
|
1636
|
+
function normalizeScaffoldPackageScripts(pkg) {
|
|
1637
|
+
const scripts = pkg?.scripts;
|
|
1638
|
+
if (!scripts || typeof scripts !== 'object' || Array.isArray(scripts)) {
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
const generateEntry = String(scripts['generate-entry'] || '').trim();
|
|
1642
|
+
if (!/^tsx\s+\.\.\/\.\.\/scripts\/generate-entry\.ts\s+\.$/.test(generateEntry)) {
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
for (const [name, command] of Object.entries(scripts)) {
|
|
1646
|
+
if (name === 'generate-entry' || typeof command !== 'string') {
|
|
1647
|
+
continue;
|
|
1648
|
+
}
|
|
1649
|
+
if (command.trim() === 'npm run generate-entry') {
|
|
1650
|
+
delete scripts[name];
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
scripts['generate-entry'] = 'node -e ""';
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
function captureScaffoldTargetIdentity(targetDir) {
|
|
1657
|
+
try {
|
|
1658
|
+
const targetStat = lstatSync(targetDir, { bigint: true });
|
|
1659
|
+
if (targetStat.isSymbolicLink() || !targetStat.isDirectory()) {
|
|
1660
|
+
throw usageError(`Refusing to scaffold through an unsafe target: ${targetDir}`);
|
|
1661
|
+
}
|
|
1662
|
+
if (readdirSync(targetDir).length > 0) {
|
|
1663
|
+
throw usageError(`App scaffold target must be empty: ${targetDir}`);
|
|
1664
|
+
}
|
|
1665
|
+
return { exists: true, dev: targetStat.dev, ino: targetStat.ino };
|
|
1666
|
+
} catch (error) {
|
|
1667
|
+
if (error?.code === 'ENOENT') return { exists: false, dev: null, ino: null };
|
|
1668
|
+
throw error;
|
|
1669
|
+
}
|
|
1165
1670
|
}
|
|
1166
1671
|
|
|
1167
1672
|
/**
|
|
@@ -1274,89 +1779,189 @@ function ensureScaffoldLocalSdk(projectDir, pkg) {
|
|
|
1274
1779
|
cpSync(TEMPLATE_SDK_DIR, localSdkDir, { recursive: true, dereference: true });
|
|
1275
1780
|
}
|
|
1276
1781
|
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
}
|
|
1282
|
-
const monorepoDir = join(MONOREPO_SCAFFOLDS_DIR, fromSlug);
|
|
1283
|
-
if (existsSync(join(monorepoDir, 'notis.config.ts'))) {
|
|
1284
|
-
return monorepoDir;
|
|
1285
|
-
}
|
|
1286
|
-
return bundledDir;
|
|
1287
|
-
}
|
|
1782
|
+
// Registry bookkeeping shipped alongside a published app's source: the listing
|
|
1783
|
+
// descriptor and the rendered Store gallery describe the published app, so a
|
|
1784
|
+
// scaffold copy must not inherit them.
|
|
1785
|
+
const REGISTRY_ARTIFACTS = /^(notis-listing\.json$|screenshots(\/|$))/;
|
|
1288
1786
|
|
|
1289
|
-
function
|
|
1290
|
-
|
|
1291
|
-
|
|
1787
|
+
function readStablePinnedFile(descriptor, expectedStat, label) {
|
|
1788
|
+
const before = fstatSync(descriptor, { bigint: true });
|
|
1789
|
+
if (
|
|
1790
|
+
!before.isFile()
|
|
1791
|
+
|| before.dev !== expectedStat.dev
|
|
1792
|
+
|| before.ino !== expectedStat.ino
|
|
1793
|
+
) {
|
|
1794
|
+
throw usageError(`${label} changed before it could be read.`);
|
|
1292
1795
|
}
|
|
1293
|
-
const
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
const description = readTsStringProperty(configSource, 'description') || '';
|
|
1304
|
-
scaffolds.push({
|
|
1305
|
-
slug: entry.name,
|
|
1306
|
-
name: readTsStringProperty(configSource, 'title') || readTsStringProperty(configSource, 'name') || entry.name,
|
|
1307
|
-
description,
|
|
1308
|
-
icon: readTsStringProperty(configSource, 'icon') || 'phosphor:squares-four',
|
|
1309
|
-
categories: readTsStringArrayProperty(configSource, 'categories'),
|
|
1310
|
-
tagline: readTsStringProperty(configSource, 'tagline') || description,
|
|
1311
|
-
});
|
|
1796
|
+
const content = readFileSync(descriptor);
|
|
1797
|
+
const after = fstatSync(descriptor, { bigint: true });
|
|
1798
|
+
if (
|
|
1799
|
+
after.dev !== before.dev
|
|
1800
|
+
|| after.ino !== before.ino
|
|
1801
|
+
|| after.size !== before.size
|
|
1802
|
+
|| after.mtimeNs !== before.mtimeNs
|
|
1803
|
+
|| after.ctimeNs !== before.ctimeNs
|
|
1804
|
+
) {
|
|
1805
|
+
throw usageError(`${label} changed while it was being read.`);
|
|
1312
1806
|
}
|
|
1313
|
-
return
|
|
1807
|
+
return content;
|
|
1314
1808
|
}
|
|
1315
1809
|
|
|
1316
|
-
function
|
|
1317
|
-
const
|
|
1318
|
-
return
|
|
1810
|
+
function captureDirectoryRevision() {
|
|
1811
|
+
const stat = statSync('.', { bigint: true });
|
|
1812
|
+
return { dev: stat.dev, ino: stat.ino, mtimeNs: stat.mtimeNs, ctimeNs: stat.ctimeNs };
|
|
1319
1813
|
}
|
|
1320
1814
|
|
|
1321
|
-
function
|
|
1322
|
-
const
|
|
1323
|
-
if (
|
|
1324
|
-
|
|
1815
|
+
function assertDirectoryRevisionUnchanged(before, label) {
|
|
1816
|
+
const after = captureDirectoryRevision();
|
|
1817
|
+
if (
|
|
1818
|
+
after.dev !== before.dev
|
|
1819
|
+
|| after.ino !== before.ino
|
|
1820
|
+
|| after.mtimeNs !== before.mtimeNs
|
|
1821
|
+
|| after.ctimeNs !== before.ctimeNs
|
|
1822
|
+
) {
|
|
1823
|
+
throw usageError(`${label} changed while it was being read.`);
|
|
1325
1824
|
}
|
|
1326
|
-
return Array.from(match[1].matchAll(/(['"`])([\s\S]*?)\1/g), (entry) => entry[2].trim()).filter(Boolean);
|
|
1327
1825
|
}
|
|
1328
1826
|
|
|
1329
1827
|
function copyScaffoldSource(sourceDir, targetDir) {
|
|
1330
1828
|
function shouldCopy(path) {
|
|
1331
1829
|
const name = path.split(/[\\/]/).pop();
|
|
1332
1830
|
if (!name) return true;
|
|
1333
|
-
|
|
1831
|
+
const relPath = relative(sourceDir, path).replace(/\\/g, '/');
|
|
1832
|
+
if (SCAFFOLD_LISTING_MEDIA.test(relPath) || REGISTRY_ARTIFACTS.test(relPath)) {
|
|
1334
1833
|
return false;
|
|
1335
1834
|
}
|
|
1336
1835
|
return !SCAFFOLD_COPY_EXCLUDES.has(name)
|
|
1836
|
+
&& !name.toLocaleLowerCase('en-US').startsWith('.notis-app-scaffold-')
|
|
1337
1837
|
&& !name.startsWith('.env')
|
|
1338
1838
|
&& !/\.(test|spec)\.[cm]?[jt]sx?$/i.test(name);
|
|
1339
1839
|
}
|
|
1340
1840
|
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1841
|
+
const canonicalSource = realpathSync(sourceDir);
|
|
1842
|
+
const sourceIdentity = capturePullTargetIdentity(canonicalSource);
|
|
1843
|
+
const previousCwd = process.cwd();
|
|
1844
|
+
const previousCwdIdentity = capturePullTargetIdentity(previousCwd);
|
|
1845
|
+
const destinationIdentity = capturePullTargetIdentity(targetDir);
|
|
1846
|
+
const snapshot = { directories: [], files: [] };
|
|
1847
|
+
|
|
1848
|
+
function snapshotCurrent(sourcePath, node, expectedDirectoryIdentity) {
|
|
1849
|
+
if (!pullTargetIdentitiesMatch(expectedDirectoryIdentity, capturePullTargetIdentity('.'))) {
|
|
1850
|
+
throw usageError(`Scaffold source directory changed while copying: ${sourcePath}`);
|
|
1344
1851
|
}
|
|
1345
|
-
const
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1852
|
+
const directoryRevision = captureDirectoryRevision();
|
|
1853
|
+
const parentIdentity = expectedDirectoryIdentity;
|
|
1854
|
+
for (const name of readdirSync('.')) {
|
|
1855
|
+
const logicalSourcePath = join(sourcePath, name);
|
|
1856
|
+
if (!shouldCopy(logicalSourcePath)) {
|
|
1857
|
+
continue;
|
|
1858
|
+
}
|
|
1859
|
+
const sourceStat = lstatSync(name, { bigint: true });
|
|
1860
|
+
if (sourceStat.isSymbolicLink()) {
|
|
1861
|
+
throw usageError(`Refusing symlinked scaffold source entry: ${logicalSourcePath}`);
|
|
1862
|
+
}
|
|
1863
|
+
if (sourceStat.isDirectory()) {
|
|
1864
|
+
const childNode = {
|
|
1865
|
+
name,
|
|
1866
|
+
mode: Number(sourceStat.mode & 0o777n),
|
|
1867
|
+
directories: [],
|
|
1868
|
+
files: [],
|
|
1869
|
+
};
|
|
1870
|
+
node.directories.push(childNode);
|
|
1871
|
+
process.chdir(name);
|
|
1872
|
+
const childIdentity = capturePullTargetIdentity('.');
|
|
1873
|
+
if (!pullTargetIdentitiesMatch(
|
|
1874
|
+
{ exists: true, dev: sourceStat.dev, ino: sourceStat.ino },
|
|
1875
|
+
childIdentity,
|
|
1876
|
+
)) {
|
|
1877
|
+
throw usageError(`Scaffold source directory changed while copying: ${logicalSourcePath}`);
|
|
1878
|
+
}
|
|
1879
|
+
try {
|
|
1880
|
+
snapshotCurrent(logicalSourcePath, childNode, childIdentity);
|
|
1881
|
+
} finally {
|
|
1882
|
+
process.chdir('..');
|
|
1883
|
+
if (!pullTargetIdentitiesMatch(parentIdentity, capturePullTargetIdentity('.'))) {
|
|
1884
|
+
throw usageError(`Scaffold source directory changed while copying: ${sourcePath}`);
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
continue;
|
|
1888
|
+
}
|
|
1889
|
+
if (!sourceStat.isFile()) {
|
|
1890
|
+
throw usageError(`Refusing unsupported scaffold source entry: ${logicalSourcePath}`);
|
|
1891
|
+
}
|
|
1892
|
+
const descriptor = openSync(name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
1893
|
+
try {
|
|
1894
|
+
node.files.push({
|
|
1895
|
+
name,
|
|
1896
|
+
mode: Number(sourceStat.mode & 0o777n),
|
|
1897
|
+
content: readStablePinnedFile(
|
|
1898
|
+
descriptor,
|
|
1899
|
+
sourceStat,
|
|
1900
|
+
`Scaffold source file ${logicalSourcePath}`,
|
|
1901
|
+
),
|
|
1902
|
+
});
|
|
1903
|
+
} finally {
|
|
1904
|
+
closeSync(descriptor);
|
|
1350
1905
|
}
|
|
1351
|
-
return;
|
|
1352
1906
|
}
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1907
|
+
assertDirectoryRevisionUnchanged(directoryRevision, `Scaffold source directory ${sourcePath}`);
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
try {
|
|
1911
|
+
process.chdir(canonicalSource);
|
|
1912
|
+
snapshotCurrent(canonicalSource, snapshot, sourceIdentity);
|
|
1913
|
+
} finally {
|
|
1914
|
+
process.chdir(previousCwd);
|
|
1915
|
+
if (!pullTargetIdentitiesMatch(previousCwdIdentity, capturePullTargetIdentity('.'))) {
|
|
1916
|
+
throw usageError(`Working directory changed while copying scaffold source: ${previousCwd}`);
|
|
1356
1917
|
}
|
|
1357
1918
|
}
|
|
1358
1919
|
|
|
1359
|
-
|
|
1920
|
+
function writeSnapshot(node, logicalPath, expectedDirectoryIdentity) {
|
|
1921
|
+
if (!pullTargetIdentitiesMatch(expectedDirectoryIdentity, capturePullTargetIdentity('.'))) {
|
|
1922
|
+
throw usageError(`Scaffold destination changed while copying: ${logicalPath || '.'}`);
|
|
1923
|
+
}
|
|
1924
|
+
const parentIdentity = expectedDirectoryIdentity;
|
|
1925
|
+
for (const directory of node.directories) {
|
|
1926
|
+
mkdirSync(directory.name, { mode: directory.mode });
|
|
1927
|
+
const directoryStat = lstatSync(directory.name, { bigint: true });
|
|
1928
|
+
if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
|
|
1929
|
+
throw usageError(`Refusing unsafe scaffold destination: ${join(logicalPath, directory.name)}`);
|
|
1930
|
+
}
|
|
1931
|
+
process.chdir(directory.name);
|
|
1932
|
+
const childIdentity = capturePullTargetIdentity('.');
|
|
1933
|
+
if (!pullTargetIdentitiesMatch(
|
|
1934
|
+
{ exists: true, dev: directoryStat.dev, ino: directoryStat.ino },
|
|
1935
|
+
childIdentity,
|
|
1936
|
+
)) {
|
|
1937
|
+
throw usageError(`Scaffold destination changed while copying: ${join(logicalPath, directory.name)}`);
|
|
1938
|
+
}
|
|
1939
|
+
try {
|
|
1940
|
+
writeSnapshot(directory, join(logicalPath, directory.name), childIdentity);
|
|
1941
|
+
} finally {
|
|
1942
|
+
process.chdir('..');
|
|
1943
|
+
if (!pullTargetIdentitiesMatch(parentIdentity, capturePullTargetIdentity('.'))) {
|
|
1944
|
+
throw usageError(`Scaffold destination changed while copying: ${logicalPath || '.'}`);
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
for (const file of node.files) {
|
|
1949
|
+
writeFileSync(file.name, file.content, { flag: 'wx', mode: file.mode });
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
try {
|
|
1954
|
+
process.chdir(targetDir);
|
|
1955
|
+
if (!pullTargetIdentitiesMatch(destinationIdentity, capturePullTargetIdentity('.'))) {
|
|
1956
|
+
throw usageError(`Scaffold destination changed while copying: ${targetDir}`);
|
|
1957
|
+
}
|
|
1958
|
+
writeSnapshot(snapshot, '', destinationIdentity);
|
|
1959
|
+
} finally {
|
|
1960
|
+
process.chdir(previousCwd);
|
|
1961
|
+
if (!pullTargetIdentitiesMatch(previousCwdIdentity, capturePullTargetIdentity('.'))) {
|
|
1962
|
+
throw usageError(`Working directory changed while copying scaffold source: ${previousCwd}`);
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1360
1965
|
}
|
|
1361
1966
|
|
|
1362
1967
|
// ---------------------------------------------------------------------------
|
|
@@ -1394,34 +1999,81 @@ export function collectArtifactFiles(projectDir) {
|
|
|
1394
1999
|
}
|
|
1395
2000
|
|
|
1396
2001
|
function shouldExcludeSourceEntry(name) {
|
|
2002
|
+
const casefolded = String(name || '').toLocaleLowerCase('en-US');
|
|
1397
2003
|
return (
|
|
1398
|
-
|
|
1399
|
-
||
|
|
1400
|
-
||
|
|
1401
|
-
||
|
|
2004
|
+
SOURCE_COPY_EXCLUDES_CASEFOLDED.has(casefolded)
|
|
2005
|
+
|| casefolded.startsWith('.notis-app-pull-')
|
|
2006
|
+
|| casefolded.startsWith('.notis-app-scaffold-')
|
|
2007
|
+
|| casefolded.startsWith('.env')
|
|
2008
|
+
|| casefolded.endsWith('.pyc')
|
|
2009
|
+
|| casefolded.endsWith('.pyo')
|
|
1402
2010
|
);
|
|
1403
2011
|
}
|
|
1404
2012
|
|
|
1405
2013
|
function readSourceFiles(projectDir) {
|
|
1406
2014
|
const files = {};
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
2015
|
+
const canonicalProjectDir = realpathSync(projectDir);
|
|
2016
|
+
const projectIdentity = capturePullTargetIdentity(canonicalProjectDir);
|
|
2017
|
+
const previousCwd = process.cwd();
|
|
2018
|
+
const previousCwdIdentity = capturePullTargetIdentity(previousCwd);
|
|
2019
|
+
|
|
2020
|
+
function walkCurrent(prefix, expectedDirectoryIdentity) {
|
|
2021
|
+
if (!pullTargetIdentitiesMatch(expectedDirectoryIdentity, capturePullTargetIdentity('.'))) {
|
|
2022
|
+
throw usageError(`App source directory changed while packaging: ${prefix || '.'}`);
|
|
2023
|
+
}
|
|
2024
|
+
const directoryRevision = captureDirectoryRevision();
|
|
2025
|
+
const parentIdentity = expectedDirectoryIdentity;
|
|
2026
|
+
for (const name of readdirSync('.')) {
|
|
2027
|
+
if (shouldExcludeSourceEntry(name)) {
|
|
1412
2028
|
continue;
|
|
1413
2029
|
}
|
|
1414
|
-
const
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
2030
|
+
const entryStat = lstatSync(name, { bigint: true });
|
|
2031
|
+
if (entryStat.isSymbolicLink()) {
|
|
2032
|
+
continue;
|
|
2033
|
+
}
|
|
2034
|
+
const relPath = prefix ? `${prefix}/${name}` : name;
|
|
2035
|
+
if (entryStat.isDirectory()) {
|
|
2036
|
+
process.chdir(name);
|
|
2037
|
+
const childIdentity = capturePullTargetIdentity('.');
|
|
2038
|
+
if (!pullTargetIdentitiesMatch(
|
|
2039
|
+
{ exists: true, dev: entryStat.dev, ino: entryStat.ino },
|
|
2040
|
+
childIdentity,
|
|
2041
|
+
)) {
|
|
2042
|
+
throw usageError(`App source directory changed while packaging: ${relPath}`);
|
|
2043
|
+
}
|
|
2044
|
+
try {
|
|
2045
|
+
walkCurrent(relPath, childIdentity);
|
|
2046
|
+
} finally {
|
|
2047
|
+
process.chdir('..');
|
|
2048
|
+
if (!pullTargetIdentitiesMatch(parentIdentity, capturePullTargetIdentity('.'))) {
|
|
2049
|
+
throw usageError(`App source directory changed while packaging: ${prefix || '.'}`);
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
} else if (entryStat.isFile()) {
|
|
2053
|
+
const descriptor = openSync(name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
2054
|
+
try {
|
|
2055
|
+
files[relPath] = readStablePinnedFile(
|
|
2056
|
+
descriptor,
|
|
2057
|
+
entryStat,
|
|
2058
|
+
`App source file ${relPath}`,
|
|
2059
|
+
);
|
|
2060
|
+
} finally {
|
|
2061
|
+
closeSync(descriptor);
|
|
2062
|
+
}
|
|
1420
2063
|
}
|
|
1421
2064
|
}
|
|
2065
|
+
assertDirectoryRevisionUnchanged(directoryRevision, `App source directory ${prefix || '.'}`);
|
|
1422
2066
|
}
|
|
1423
2067
|
|
|
1424
|
-
|
|
2068
|
+
try {
|
|
2069
|
+
process.chdir(canonicalProjectDir);
|
|
2070
|
+
walkCurrent('', projectIdentity);
|
|
2071
|
+
} finally {
|
|
2072
|
+
process.chdir(previousCwd);
|
|
2073
|
+
if (!pullTargetIdentitiesMatch(previousCwdIdentity, capturePullTargetIdentity('.'))) {
|
|
2074
|
+
throw usageError(`Working directory changed while packaging app source: ${previousCwd}`);
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
1425
2077
|
return files;
|
|
1426
2078
|
}
|
|
1427
2079
|
|
|
@@ -1443,9 +2095,55 @@ function cleanTarPath(name) {
|
|
|
1443
2095
|
return parts.join('/');
|
|
1444
2096
|
}
|
|
1445
2097
|
|
|
2098
|
+
function parsePaxPath(data) {
|
|
2099
|
+
if (data.length === 0 || data.length > 64 * 1024) {
|
|
2100
|
+
throw usageError('Refusing to extract an invalid PAX source archive header.');
|
|
2101
|
+
}
|
|
2102
|
+
let offset = 0;
|
|
2103
|
+
let path = null;
|
|
2104
|
+
while (offset < data.length) {
|
|
2105
|
+
const space = data.indexOf(0x20, offset);
|
|
2106
|
+
if (space <= offset) {
|
|
2107
|
+
throw usageError('Refusing to extract a malformed PAX source archive header.');
|
|
2108
|
+
}
|
|
2109
|
+
const lengthText = data.subarray(offset, space).toString('ascii');
|
|
2110
|
+
if (!/^\d+$/.test(lengthText)) {
|
|
2111
|
+
throw usageError('Refusing to extract a malformed PAX source archive header.');
|
|
2112
|
+
}
|
|
2113
|
+
const recordLength = Number.parseInt(lengthText, 10);
|
|
2114
|
+
const recordEnd = offset + recordLength;
|
|
2115
|
+
if (recordLength <= space - offset + 3 || recordEnd > data.length || data[recordEnd - 1] !== 0x0a) {
|
|
2116
|
+
throw usageError('Refusing to extract a malformed PAX source archive header.');
|
|
2117
|
+
}
|
|
2118
|
+
const record = data.subarray(space + 1, recordEnd - 1);
|
|
2119
|
+
const equals = record.indexOf(0x3d);
|
|
2120
|
+
if (equals <= 0) {
|
|
2121
|
+
throw usageError('Refusing to extract a malformed PAX source archive header.');
|
|
2122
|
+
}
|
|
2123
|
+
const key = record.subarray(0, equals).toString('ascii');
|
|
2124
|
+
if (key === 'path') {
|
|
2125
|
+
try {
|
|
2126
|
+
path = new TextDecoder('utf-8', { fatal: true }).decode(record.subarray(equals + 1));
|
|
2127
|
+
} catch {
|
|
2128
|
+
throw usageError('Refusing to extract a PAX source path with invalid UTF-8.');
|
|
2129
|
+
}
|
|
2130
|
+
if (!path || Buffer.byteLength(path, 'utf-8') > 4096) {
|
|
2131
|
+
throw usageError('Refusing to extract an invalid PAX source path.');
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
offset = recordEnd;
|
|
2135
|
+
}
|
|
2136
|
+
if (!path) {
|
|
2137
|
+
throw usageError('Refusing to extract a PAX source header without a path.');
|
|
2138
|
+
}
|
|
2139
|
+
return path;
|
|
2140
|
+
}
|
|
2141
|
+
|
|
1446
2142
|
function extractTarGz(buffer, targetDir) {
|
|
1447
2143
|
const tar = gunzipSync(buffer);
|
|
1448
2144
|
let offset = 0;
|
|
2145
|
+
let pendingPaxPath = null;
|
|
2146
|
+
const extractedPaths = new Map();
|
|
1449
2147
|
while (offset + 512 <= tar.length) {
|
|
1450
2148
|
const header = tar.subarray(offset, offset + 512);
|
|
1451
2149
|
offset += 512;
|
|
@@ -1459,35 +2157,251 @@ function extractTarGz(buffer, targetDir) {
|
|
|
1459
2157
|
const sizeRaw = header.subarray(124, 136).toString('utf-8').replace(/\0.*$/, '').trim();
|
|
1460
2158
|
const size = Number.parseInt(sizeRaw || '0', 8);
|
|
1461
2159
|
const typeFlag = header[156];
|
|
1462
|
-
|
|
2160
|
+
if (!Number.isSafeInteger(size) || size < 0 || offset + size > tar.length) {
|
|
2161
|
+
throw usageError('Refusing to extract a malformed source archive entry size.');
|
|
2162
|
+
}
|
|
2163
|
+
const data = tar.subarray(offset, offset + size);
|
|
2164
|
+
if (typeFlag === 120) {
|
|
2165
|
+
if (pendingPaxPath !== null) {
|
|
2166
|
+
throw usageError('Refusing to extract stacked PAX source archive headers.');
|
|
2167
|
+
}
|
|
2168
|
+
pendingPaxPath = parsePaxPath(data);
|
|
2169
|
+
offset += Math.ceil(size / 512) * 512;
|
|
2170
|
+
continue;
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
const relPath = cleanTarPath(pendingPaxPath || fullName);
|
|
2174
|
+
pendingPaxPath = null;
|
|
2175
|
+
const parts = relPath.split('/');
|
|
2176
|
+
for (let index = 1; index <= parts.length; index += 1) {
|
|
2177
|
+
const pathPrefix = parts.slice(0, index).join('/');
|
|
2178
|
+
const casefoldedPath = pathPrefix.toLocaleLowerCase('en-US');
|
|
2179
|
+
const priorPath = extractedPaths.get(casefoldedPath);
|
|
2180
|
+
if (priorPath && priorPath !== pathPrefix) {
|
|
2181
|
+
throw usageError(
|
|
2182
|
+
`Refusing to extract case-colliding source archive paths: ${priorPath} and ${pathPrefix}`,
|
|
2183
|
+
);
|
|
2184
|
+
}
|
|
2185
|
+
extractedPaths.set(casefoldedPath, pathPrefix);
|
|
2186
|
+
}
|
|
2187
|
+
if (relPath.split('/').some((part) => shouldExcludeSourceEntry(part))) {
|
|
2188
|
+
throw usageError(`Refusing to extract local-only path from source archive: ${relPath}`);
|
|
2189
|
+
}
|
|
1463
2190
|
const outputPath = join(targetDir, relPath);
|
|
1464
2191
|
|
|
1465
2192
|
if (typeFlag === 53) {
|
|
1466
2193
|
mkdirSync(outputPath, { recursive: true });
|
|
1467
|
-
} else {
|
|
2194
|
+
} else if (typeFlag === 0 || typeFlag === 48) {
|
|
1468
2195
|
mkdirSync(dirname(outputPath), { recursive: true });
|
|
1469
|
-
writeFileSync(outputPath,
|
|
2196
|
+
writeFileSync(outputPath, data);
|
|
2197
|
+
} else {
|
|
2198
|
+
throw usageError(`Refusing to extract unsupported source archive entry: ${relPath}`);
|
|
1470
2199
|
}
|
|
1471
2200
|
|
|
1472
2201
|
offset += Math.ceil(size / 512) * 512;
|
|
1473
2202
|
}
|
|
2203
|
+
if (pendingPaxPath !== null) {
|
|
2204
|
+
throw usageError('Refusing to extract a dangling PAX source archive header.');
|
|
2205
|
+
}
|
|
1474
2206
|
}
|
|
1475
2207
|
|
|
1476
|
-
|
|
2208
|
+
function collectPullPreservedPaths(targetDir) {
|
|
2209
|
+
const paths = [];
|
|
2210
|
+
|
|
2211
|
+
function walk(dir, prefix) {
|
|
2212
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
2213
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
2214
|
+
if (entry.isSymbolicLink() || shouldExcludeSourceEntry(entry.name)) {
|
|
2215
|
+
paths.push(relPath);
|
|
2216
|
+
continue;
|
|
2217
|
+
}
|
|
2218
|
+
if (entry.isDirectory()) {
|
|
2219
|
+
walk(join(dir, entry.name), relPath);
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
if (existsSync(targetDir)) {
|
|
2225
|
+
walk(targetDir, '');
|
|
2226
|
+
}
|
|
2227
|
+
return paths;
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
function readPullLockOwner(lockDirectory) {
|
|
2231
|
+
try {
|
|
2232
|
+
return JSON.parse(readFileSync(join(lockDirectory, 'owner'), 'utf-8'));
|
|
2233
|
+
} catch {
|
|
2234
|
+
return null;
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
function capturePullTargetIdentity(targetDir) {
|
|
2239
|
+
try {
|
|
2240
|
+
const targetStat = lstatSync(targetDir, { bigint: true });
|
|
2241
|
+
if (targetStat.isSymbolicLink() || !targetStat.isDirectory()) {
|
|
2242
|
+
throw usageError(`Refusing to pull app source through an unsafe target: ${targetDir}`);
|
|
2243
|
+
}
|
|
2244
|
+
return { exists: true, dev: targetStat.dev, ino: targetStat.ino };
|
|
2245
|
+
} catch (error) {
|
|
2246
|
+
if (error?.code === 'ENOENT') return { exists: false, dev: null, ino: null };
|
|
2247
|
+
throw error;
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
function assertPullTargetIdentity(targetDir, expected) {
|
|
2252
|
+
const current = capturePullTargetIdentity(targetDir);
|
|
2253
|
+
if (!pullTargetIdentitiesMatch(current, expected)) {
|
|
2254
|
+
throw usageError(`App source pull target changed while the download was in progress: ${targetDir}`);
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
function pullTargetIdentitiesMatch(left, right) {
|
|
2259
|
+
return (
|
|
2260
|
+
left.exists === right.exists
|
|
2261
|
+
&& (!left.exists || (left.dev === right.dev && left.ino === right.ino))
|
|
2262
|
+
);
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
function assertPullParentIdentity(parentDir, canonicalParent, expectedIdentity) {
|
|
2266
|
+
let currentCanonicalParent;
|
|
2267
|
+
try {
|
|
2268
|
+
currentCanonicalParent = realpathSync(parentDir);
|
|
2269
|
+
} catch {
|
|
2270
|
+
throw usageError(`App source pull parent changed while the download was in progress: ${parentDir}`);
|
|
2271
|
+
}
|
|
2272
|
+
if (
|
|
2273
|
+
currentCanonicalParent !== canonicalParent
|
|
2274
|
+
|| !pullTargetIdentitiesMatch(
|
|
2275
|
+
capturePullTargetIdentity(canonicalParent),
|
|
2276
|
+
expectedIdentity,
|
|
2277
|
+
)
|
|
2278
|
+
) {
|
|
2279
|
+
throw usageError(`App source pull parent changed while the download was in progress: ${parentDir}`);
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
async function withPullTargetLock(targetDir, callback) {
|
|
2284
|
+
const requestedTarget = resolve(targetDir);
|
|
2285
|
+
const parentDir = dirname(requestedTarget);
|
|
2286
|
+
mkdirSync(parentDir, { recursive: true });
|
|
2287
|
+
const canonicalParent = realpathSync(parentDir);
|
|
2288
|
+
const canonicalParentIdentity = capturePullTargetIdentity(canonicalParent);
|
|
2289
|
+
const initialRequestedIdentity = capturePullTargetIdentity(requestedTarget);
|
|
2290
|
+
const canonicalTarget = initialRequestedIdentity.exists
|
|
2291
|
+
? realpathSync(requestedTarget)
|
|
2292
|
+
: join(canonicalParent, basename(requestedTarget));
|
|
2293
|
+
if (
|
|
2294
|
+
initialRequestedIdentity.exists
|
|
2295
|
+
&& !pullTargetIdentitiesMatch(
|
|
2296
|
+
initialRequestedIdentity,
|
|
2297
|
+
capturePullTargetIdentity(canonicalTarget),
|
|
2298
|
+
)
|
|
2299
|
+
) {
|
|
2300
|
+
throw usageError(`App source pull target changed before locking: ${requestedTarget}`);
|
|
2301
|
+
}
|
|
2302
|
+
const lockDirectory = join(canonicalParent, `.${basename(canonicalTarget)}.notis-pull-lock`);
|
|
2303
|
+
const ownerId = `${process.pid}.${randomUUID()}`;
|
|
2304
|
+
const deadline = Date.now() + PULL_LOCK_TIMEOUT_MS;
|
|
2305
|
+
|
|
2306
|
+
for (;;) {
|
|
2307
|
+
try {
|
|
2308
|
+
mkdirSync(lockDirectory, { mode: 0o700 });
|
|
2309
|
+
writeFileSync(
|
|
2310
|
+
join(lockDirectory, 'owner'),
|
|
2311
|
+
JSON.stringify({ id: ownerId, pid: process.pid, at: Date.now() }),
|
|
2312
|
+
{ mode: 0o600 },
|
|
2313
|
+
);
|
|
2314
|
+
break;
|
|
2315
|
+
} catch (error) {
|
|
2316
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
2317
|
+
let lockStat;
|
|
2318
|
+
try {
|
|
2319
|
+
lockStat = lstatSync(lockDirectory);
|
|
2320
|
+
} catch (statError) {
|
|
2321
|
+
if (statError?.code === 'ENOENT') continue;
|
|
2322
|
+
throw statError;
|
|
2323
|
+
}
|
|
2324
|
+
if (lockStat.isSymbolicLink() || !lockStat.isDirectory()) {
|
|
2325
|
+
throw usageError(`Refusing to use unsafe app source pull lock: ${lockDirectory}`);
|
|
2326
|
+
}
|
|
2327
|
+
if (Date.now() >= deadline) {
|
|
2328
|
+
throw usageError(
|
|
2329
|
+
`Timed out waiting to pull app source into ${targetDir}. `
|
|
2330
|
+
+ `If no pull process is running, remove the orphaned lock: ${lockDirectory}`,
|
|
2331
|
+
);
|
|
2332
|
+
}
|
|
2333
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, PULL_LOCK_POLL_MS));
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
try {
|
|
2338
|
+
const assertOwnership = () => {
|
|
2339
|
+
if (readPullLockOwner(lockDirectory)?.id !== ownerId) {
|
|
2340
|
+
throw usageError(`Lost the app source pull lock for ${targetDir}.`);
|
|
2341
|
+
}
|
|
2342
|
+
};
|
|
2343
|
+
const assertParentIdentity = () => assertPullParentIdentity(
|
|
2344
|
+
parentDir,
|
|
2345
|
+
canonicalParent,
|
|
2346
|
+
canonicalParentIdentity,
|
|
2347
|
+
);
|
|
2348
|
+
assertParentIdentity();
|
|
2349
|
+
const lockedTargetIdentity = capturePullTargetIdentity(canonicalTarget);
|
|
2350
|
+
if (!pullTargetIdentitiesMatch(initialRequestedIdentity, lockedTargetIdentity)) {
|
|
2351
|
+
throw usageError(`App source pull target changed before locking: ${requestedTarget}`);
|
|
2352
|
+
}
|
|
2353
|
+
return await callback(
|
|
2354
|
+
assertOwnership,
|
|
2355
|
+
assertParentIdentity,
|
|
2356
|
+
canonicalTarget,
|
|
2357
|
+
lockedTargetIdentity,
|
|
2358
|
+
);
|
|
2359
|
+
} finally {
|
|
2360
|
+
try {
|
|
2361
|
+
if (readPullLockOwner(lockDirectory)?.id === ownerId) {
|
|
2362
|
+
rmSync(lockDirectory, { recursive: true, force: true });
|
|
2363
|
+
}
|
|
2364
|
+
} catch {
|
|
2365
|
+
// A reclaimed lock belongs to its new owner and must not be removed.
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
export async function pullAppSource(args) {
|
|
2371
|
+
const targetDir = resolve(args.targetDir);
|
|
2372
|
+
return withPullTargetLock(
|
|
2373
|
+
targetDir,
|
|
2374
|
+
(
|
|
2375
|
+
assertLockOwnership,
|
|
2376
|
+
assertParentIdentity,
|
|
2377
|
+
canonicalTarget,
|
|
2378
|
+
targetIdentity,
|
|
2379
|
+
) => pullAppSourceUnlocked(
|
|
2380
|
+
{ ...args, targetDir: canonicalTarget },
|
|
2381
|
+
assertLockOwnership,
|
|
2382
|
+
assertParentIdentity,
|
|
2383
|
+
targetIdentity,
|
|
2384
|
+
),
|
|
2385
|
+
);
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
async function pullAppSourceUnlocked({
|
|
1477
2389
|
apiBase,
|
|
1478
2390
|
jwt,
|
|
1479
2391
|
appId,
|
|
1480
2392
|
targetDir,
|
|
1481
2393
|
version = 'latest',
|
|
1482
2394
|
force = false,
|
|
1483
|
-
|
|
1484
|
-
|
|
2395
|
+
profileKey = null,
|
|
2396
|
+
}, assertLockOwnership, assertParentIdentity, initialTargetIdentity) {
|
|
2397
|
+
assertLockOwnership();
|
|
2398
|
+
assertParentIdentity();
|
|
2399
|
+
const targetWasNonEmpty = existsSync(targetDir) && readdirSync(targetDir).length > 0;
|
|
2400
|
+
if (targetWasNonEmpty) {
|
|
1485
2401
|
if (!force) {
|
|
1486
2402
|
throw usageError(`Target directory is not empty: ${targetDir}. Pass --force to overwrite it.`);
|
|
1487
2403
|
}
|
|
1488
|
-
rmSync(targetDir, { recursive: true, force: true });
|
|
1489
2404
|
}
|
|
1490
|
-
mkdirSync(targetDir, { recursive: true });
|
|
1491
2405
|
|
|
1492
2406
|
const params = new URLSearchParams({ app_id: appId, version: String(version || 'latest') });
|
|
1493
2407
|
const response = await fetch(`${apiBase.replace(/\/$/, '')}/portal_apps/source?${params.toString()}`, {
|
|
@@ -1501,17 +2415,229 @@ export async function pullAppSource({
|
|
|
1501
2415
|
const contentDisposition = response.headers.get('content-disposition') || '';
|
|
1502
2416
|
const versionMatch = /-v(\d+)\.tar\.gz/i.exec(contentDisposition);
|
|
1503
2417
|
const pulledVersion = versionMatch ? Number.parseInt(versionMatch[1], 10) : null;
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
String(version
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
2418
|
+
const requestedVersion = String(version || 'latest') === 'latest'
|
|
2419
|
+
? null
|
|
2420
|
+
: Number.parseInt(String(version), 10);
|
|
2421
|
+
if (!Number.isInteger(pulledVersion) || pulledVersion <= 0) {
|
|
2422
|
+
throw usageError('The app source response did not identify a valid positive version.');
|
|
2423
|
+
}
|
|
2424
|
+
if (requestedVersion !== null && (!Number.isInteger(requestedVersion) || requestedVersion <= 0)) {
|
|
2425
|
+
throw usageError(`Invalid app source version: ${version}`);
|
|
2426
|
+
}
|
|
2427
|
+
if (
|
|
2428
|
+
requestedVersion !== null
|
|
2429
|
+
&& pulledVersion !== requestedVersion
|
|
2430
|
+
) {
|
|
2431
|
+
throw usageError(
|
|
2432
|
+
`Requested app source version ${requestedVersion}, but the server returned version ${pulledVersion}.`,
|
|
2433
|
+
);
|
|
2434
|
+
}
|
|
2435
|
+
const linkedVersion = pulledVersion;
|
|
2436
|
+
const archiveBuffer = Buffer.from(await response.arrayBuffer());
|
|
2437
|
+
assertParentIdentity();
|
|
2438
|
+
assertLockOwnership();
|
|
2439
|
+
|
|
2440
|
+
// A failed download or malformed archive must never damage an existing
|
|
2441
|
+
// checkout. Extract into a transaction directory under the pinned target,
|
|
2442
|
+
// then replace
|
|
2443
|
+
// only source-managed entries. Local-only state such as .git, .env files,
|
|
2444
|
+
// dependencies, symlinks, build output, and .notis runtime directories is
|
|
2445
|
+
// moved aside recursively and restored into the new source tree.
|
|
2446
|
+
let cleanupTransaction = true;
|
|
2447
|
+
let cwdPinned = false;
|
|
2448
|
+
let transactionDir = null;
|
|
2449
|
+
let transactionDisplayPath = null;
|
|
2450
|
+
let operationError = null;
|
|
2451
|
+
const previousCwd = process.cwd();
|
|
2452
|
+
const previousCwdIdentity = capturePullTargetIdentity(previousCwd);
|
|
2453
|
+
try {
|
|
2454
|
+
const parentDir = dirname(targetDir);
|
|
2455
|
+
const targetName = basename(targetDir);
|
|
2456
|
+
const parentIdentity = capturePullTargetIdentity(parentDir);
|
|
2457
|
+
assertParentIdentity();
|
|
2458
|
+
process.chdir(parentDir);
|
|
2459
|
+
cwdPinned = true;
|
|
2460
|
+
assertPullTargetIdentity('.', parentIdentity);
|
|
2461
|
+
|
|
2462
|
+
assertParentIdentity();
|
|
2463
|
+
assertPullTargetIdentity(targetName, initialTargetIdentity);
|
|
2464
|
+
if (!initialTargetIdentity.exists) {
|
|
2465
|
+
try {
|
|
2466
|
+
mkdirSync(targetName);
|
|
2467
|
+
} catch (error) {
|
|
2468
|
+
if (error?.code === 'EEXIST') {
|
|
2469
|
+
throw usageError(`App source pull target changed while the download was in progress: ${targetDir}`);
|
|
2470
|
+
}
|
|
2471
|
+
throw error;
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
const activeTargetIdentity = capturePullTargetIdentity(targetName);
|
|
2475
|
+
if (
|
|
2476
|
+
initialTargetIdentity.exists
|
|
2477
|
+
&& (
|
|
2478
|
+
activeTargetIdentity.dev !== initialTargetIdentity.dev
|
|
2479
|
+
|| activeTargetIdentity.ino !== initialTargetIdentity.ino
|
|
2480
|
+
)
|
|
2481
|
+
) {
|
|
2482
|
+
throw usageError(`App source pull target changed while the download was in progress: ${targetDir}`);
|
|
2483
|
+
}
|
|
2484
|
+
assertLockOwnership();
|
|
2485
|
+
process.chdir(targetName);
|
|
2486
|
+
assertPullTargetIdentity('.', activeTargetIdentity);
|
|
2487
|
+
const targetRoot = '.';
|
|
2488
|
+
assertLinkedStatePathSafe(targetRoot);
|
|
2489
|
+
const preservedPaths = collectPullPreservedPaths(targetRoot);
|
|
2490
|
+
|
|
2491
|
+
const transactionName = basename(mkdtempSync(join('.', '.notis-app-pull-')));
|
|
2492
|
+
transactionDir = transactionName;
|
|
2493
|
+
transactionDisplayPath = join(targetDir, transactionName);
|
|
2494
|
+
const stageDir = join(transactionDir, 'stage');
|
|
2495
|
+
const backupDir = join(transactionDir, 'backup');
|
|
2496
|
+
const preservedDir = join(transactionDir, 'preserved');
|
|
2497
|
+
mkdirSync(stageDir, { recursive: true });
|
|
2498
|
+
mkdirSync(backupDir, { recursive: true });
|
|
2499
|
+
mkdirSync(preservedDir, { recursive: true });
|
|
2500
|
+
|
|
2501
|
+
extractTarGz(archiveBuffer, stageDir);
|
|
2502
|
+
const stagedEntries = readdirSync(stageDir);
|
|
2503
|
+
if (stagedEntries.length === 0) {
|
|
2504
|
+
throw usageError('The app source archive did not contain any editable source files.');
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
const previousStatePath = join(targetRoot, STATE_FILE);
|
|
2508
|
+
const backupStatePath = join(backupDir, STATE_FILE);
|
|
2509
|
+
const hadPreviousState = existsSync(previousStatePath);
|
|
2510
|
+
if (hadPreviousState) {
|
|
2511
|
+
mkdirSync(dirname(backupStatePath), { recursive: true });
|
|
2512
|
+
cpSync(previousStatePath, backupStatePath);
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
const movedPreserved = [];
|
|
2516
|
+
const movedOriginal = [];
|
|
2517
|
+
const movedStaged = [];
|
|
2518
|
+
const restoredPreserved = [];
|
|
2519
|
+
try {
|
|
2520
|
+
for (const relPath of preservedPaths) {
|
|
2521
|
+
const preservedPath = join(preservedDir, relPath);
|
|
2522
|
+
mkdirSync(dirname(preservedPath), { recursive: true });
|
|
2523
|
+
renameSync(join(targetRoot, relPath), preservedPath);
|
|
2524
|
+
movedPreserved.push(relPath);
|
|
2525
|
+
}
|
|
2526
|
+
assertPullTargetIdentity(targetDir, activeTargetIdentity);
|
|
2527
|
+
assertLockOwnership();
|
|
2528
|
+
const managedTargetEntries = readdirSync(targetRoot)
|
|
2529
|
+
.filter((name) => name !== transactionName);
|
|
2530
|
+
for (const name of managedTargetEntries) {
|
|
2531
|
+
renameSync(join(targetRoot, name), join(backupDir, name));
|
|
2532
|
+
movedOriginal.push(name);
|
|
2533
|
+
}
|
|
2534
|
+
assertPullTargetIdentity(targetDir, activeTargetIdentity);
|
|
2535
|
+
assertLockOwnership();
|
|
2536
|
+
for (const name of stagedEntries) {
|
|
2537
|
+
renameSync(join(stageDir, name), join(targetRoot, name));
|
|
2538
|
+
movedStaged.push(name);
|
|
2539
|
+
}
|
|
2540
|
+
assertPullTargetIdentity(targetDir, activeTargetIdentity);
|
|
2541
|
+
assertLockOwnership();
|
|
2542
|
+
for (const relPath of movedPreserved) {
|
|
2543
|
+
const targetPath = join(targetRoot, relPath);
|
|
2544
|
+
try {
|
|
2545
|
+
lstatSync(targetPath);
|
|
2546
|
+
throw usageError(
|
|
2547
|
+
`Local-only path conflicts with pulled source: ${relPath}. Move it aside and retry.`,
|
|
2548
|
+
);
|
|
2549
|
+
} catch (error) {
|
|
2550
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
2551
|
+
}
|
|
2552
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
2553
|
+
renameSync(join(preservedDir, relPath), targetPath);
|
|
2554
|
+
restoredPreserved.push(relPath);
|
|
2555
|
+
}
|
|
2556
|
+
assertPullTargetIdentity(targetDir, activeTargetIdentity);
|
|
2557
|
+
assertLockOwnership();
|
|
2558
|
+
writeLinkedState(targetRoot, {
|
|
2559
|
+
app_id: appId,
|
|
2560
|
+
...(Number.isFinite(linkedVersion) ? { version: linkedVersion } : {}),
|
|
2561
|
+
linked_at: new Date().toISOString(),
|
|
2562
|
+
}, profileKey);
|
|
2563
|
+
assertPullTargetIdentity(targetDir, activeTargetIdentity);
|
|
2564
|
+
assertLockOwnership();
|
|
2565
|
+
} catch (error) {
|
|
2566
|
+
try {
|
|
2567
|
+
for (const relPath of restoredPreserved.reverse()) {
|
|
2568
|
+
const preservedPath = join(preservedDir, relPath);
|
|
2569
|
+
mkdirSync(dirname(preservedPath), { recursive: true });
|
|
2570
|
+
renameSync(join(targetRoot, relPath), preservedPath);
|
|
2571
|
+
}
|
|
2572
|
+
for (const name of movedStaged.reverse()) {
|
|
2573
|
+
renameSync(join(targetRoot, name), join(stageDir, name));
|
|
2574
|
+
}
|
|
2575
|
+
for (const name of movedOriginal.reverse()) {
|
|
2576
|
+
renameSync(join(backupDir, name), join(targetRoot, name));
|
|
2577
|
+
}
|
|
2578
|
+
for (const relPath of movedPreserved.reverse()) {
|
|
2579
|
+
const targetPath = join(targetRoot, relPath);
|
|
2580
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
2581
|
+
renameSync(join(preservedDir, relPath), targetPath);
|
|
2582
|
+
}
|
|
2583
|
+
if (hadPreviousState) {
|
|
2584
|
+
cpSync(backupStatePath, previousStatePath);
|
|
2585
|
+
} else {
|
|
2586
|
+
rmSync(previousStatePath, { force: true });
|
|
2587
|
+
}
|
|
2588
|
+
} catch (rollbackError) {
|
|
2589
|
+
cleanupTransaction = false;
|
|
2590
|
+
throw usageError(
|
|
2591
|
+
`${error instanceof Error ? error.message : String(error)} `
|
|
2592
|
+
+ `Rollback failed; recovery files were retained at ${transactionDisplayPath}: `
|
|
2593
|
+
+ `${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
|
|
2594
|
+
);
|
|
2595
|
+
}
|
|
2596
|
+
throw error;
|
|
2597
|
+
}
|
|
2598
|
+
} catch (error) {
|
|
2599
|
+
operationError = error;
|
|
2600
|
+
throw error;
|
|
2601
|
+
} finally {
|
|
2602
|
+
let cleanupError = null;
|
|
2603
|
+
try {
|
|
2604
|
+
if (cleanupTransaction && transactionDir) {
|
|
2605
|
+
rmSync(transactionDir, { recursive: true, force: true });
|
|
2606
|
+
}
|
|
2607
|
+
} catch (error) {
|
|
2608
|
+
cleanupError = error;
|
|
2609
|
+
} finally {
|
|
2610
|
+
if (cwdPinned) {
|
|
2611
|
+
const cwdMatchesTarget = (
|
|
2612
|
+
previousCwdIdentity.exists
|
|
2613
|
+
&& pullTargetIdentitiesMatch(previousCwdIdentity, capturePullTargetIdentity('.'))
|
|
2614
|
+
);
|
|
2615
|
+
if (!cwdMatchesTarget) {
|
|
2616
|
+
try {
|
|
2617
|
+
process.chdir(previousCwd);
|
|
2618
|
+
} catch {
|
|
2619
|
+
// The command is about to return; never trade recovered source for a cwd error.
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
if (cleanupError) {
|
|
2625
|
+
const cleanupMessage = cleanupError instanceof Error
|
|
2626
|
+
? cleanupError.message
|
|
2627
|
+
: String(cleanupError);
|
|
2628
|
+
if (operationError) {
|
|
2629
|
+
throw usageError(
|
|
2630
|
+
`${operationError instanceof Error ? operationError.message : String(operationError)} `
|
|
2631
|
+
+ `Transaction cleanup also failed; recovery files were retained at `
|
|
2632
|
+
+ `${transactionDisplayPath}: ${cleanupMessage}`,
|
|
2633
|
+
);
|
|
2634
|
+
}
|
|
2635
|
+
throw usageError(
|
|
2636
|
+
`App source was updated, but transaction cleanup failed; recovery files were retained at `
|
|
2637
|
+
+ `${transactionDisplayPath}: ${cleanupMessage}`,
|
|
2638
|
+
);
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
1515
2641
|
|
|
1516
2642
|
return { projectDir: targetDir, version: pulledVersion || version };
|
|
1517
2643
|
}
|