@notis_ai/cli 0.2.13 → 0.2.15
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 +125 -21
- package/dist/agent-hooks/notis-agent-hook.mjs +19750 -0
- package/{skills → dist/base-skills}/notis-apps/SKILL.md +93 -58
- package/{skills → dist/base-skills}/notis-cli/SKILL.md +9 -9
- package/dist/base-skills/notis-query/SKILL.md +705 -0
- package/dist/skill-sync/index.js +1528 -0
- package/dist/skill-sync/index.js.map +7 -0
- package/package.json +5 -2
- package/skills/notis-apps/cli.md +44 -18
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
- package/skills/notis-onboarding/BRIEF.md +16 -0
- package/src/agent-hook-entry.js +5 -0
- package/src/cli.js +23 -14
- package/src/command-specs/agents.js +392 -0
- package/src/command-specs/apps.js +1090 -418
- package/src/command-specs/auth.js +63 -10
- package/src/command-specs/handover.js +374 -0
- package/src/command-specs/index.js +9 -0
- package/src/command-specs/onboarding.js +69 -5
- package/src/command-specs/skills.js +56 -0
- package/src/command-specs/tools.js +6 -0
- package/src/runtime/agent-memory-state.js +126 -0
- package/src/runtime/agent-setup.js +383 -0
- 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 +229 -30
- package/src/runtime/app-dev-sessions.js +105 -145
- package/src/runtime/app-platform.js +1499 -161
- package/src/runtime/app-registry-scaffolds.js +367 -0
- package/src/runtime/base-skills.d.ts +20 -0
- package/src/runtime/base-skills.js +167 -0
- package/src/runtime/delegated-context.js +68 -0
- package/src/runtime/git.js +233 -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/src/runtime/skill-sync/cloud-client.ts +96 -0
- package/src/runtime/skill-sync/index.ts +644 -0
- package/src/runtime/skill-sync/local-scanner.ts +1046 -0
- package/src/runtime/skill-sync/symlink-manager.ts +383 -0
- package/src/runtime/skill-sync/sync-plan.ts +22 -0
- package/src/runtime/skill-sync/types.ts +103 -0
- package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
- package/src/runtime/store-screenshot.js +6 -1
- package/src/runtime/sync-skills.d.ts +37 -0
- package/src/runtime/sync-skills.js +215 -0
- package/src/runtime/transport.js +19 -2
- package/template/.harness/index.html.tmpl +116 -47
- 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/DocumentEditor.tsx +13 -3
- 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 +71 -0
- package/template/packages/sdk/src/documents.ts +28 -1
- package/template/packages/sdk/src/hooks/useActiveResource.ts +19 -0
- package/template/packages/sdk/src/hooks/useCloudComputer.ts +97 -0
- package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +709 -0
- package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/template/packages/sdk/src/hooks/useHandover.ts +78 -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 +64 -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 +219 -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 -196
- package/dist/scaffolds/notis-database/packages/sdk/src/documents.ts +0 -229
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useBackend.ts +0 -41
- package/dist/scaffolds/notis-database/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
- 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/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 -83
- package/dist/scaffolds/notis-database/packages/sdk/src/provider.tsx +0 -43
- package/dist/scaffolds/notis-database/packages/sdk/src/runtime.ts +0 -220
- 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 -196
- package/dist/scaffolds/notis-journal/packages/sdk/src/documents.ts +0 -229
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useBackend.ts +0 -41
- package/dist/scaffolds/notis-journal/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
- 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/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 -83
- package/dist/scaffolds/notis-journal/packages/sdk/src/provider.tsx +0 -43
- package/dist/scaffolds/notis-journal/packages/sdk/src/runtime.ts +0 -220
- 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 -197
- 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 -196
- package/dist/scaffolds/notis-notes/packages/sdk/src/documents.ts +0 -229
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useBackend.ts +0 -41
- package/dist/scaffolds/notis-notes/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
- 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/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 -83
- package/dist/scaffolds/notis-notes/packages/sdk/src/provider.tsx +0 -43
- package/dist/scaffolds/notis-notes/packages/sdk/src/runtime.ts +0 -220
- 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 -196
- package/dist/scaffolds/notis-random/packages/sdk/src/documents.ts +0 -229
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useBackend.ts +0 -41
- package/dist/scaffolds/notis-random/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
- 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/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 -83
- package/dist/scaffolds/notis-random/packages/sdk/src/provider.tsx +0 -43
- package/dist/scaffolds/notis-random/packages/sdk/src/runtime.ts +0 -220
- 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
- package/template/metadata/screenshot-1.png +0 -0
- package/template/metadata/screenshot-2.png +0 -0
- package/template/metadata/screenshot-3.png +0 -0
|
@@ -7,15 +7,18 @@
|
|
|
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
|
-
import { readAppChangelog } from './app-changelog.js';
|
|
21
|
+
import { CHANGELOG_MERGE_DATE, readAppChangelog } from './app-changelog.js';
|
|
19
22
|
|
|
20
23
|
const NOTIS_DIR = '.notis';
|
|
21
24
|
const STATE_FILE = join(NOTIS_DIR, 'state.json');
|
|
@@ -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',
|
|
@@ -44,21 +43,73 @@ const SOURCE_COPY_EXCLUDES = new Set([
|
|
|
44
43
|
'dist',
|
|
45
44
|
'tsconfig.tsbuildinfo',
|
|
46
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__',
|
|
47
49
|
]);
|
|
50
|
+
const SOURCE_COPY_EXCLUDES_CASEFOLDED = new Set(
|
|
51
|
+
[...SOURCE_COPY_EXCLUDES].map((name) => name.toLocaleLowerCase('en-US')),
|
|
52
|
+
);
|
|
48
53
|
const SCAFFOLD_COPY_EXCLUDES = new Set([
|
|
49
54
|
...SOURCE_COPY_EXCLUDES,
|
|
50
55
|
'coverage',
|
|
51
56
|
'.next',
|
|
52
57
|
'.turbo',
|
|
53
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;
|
|
54
75
|
let appConfigImportNonce = 0;
|
|
55
76
|
|
|
56
77
|
// ---------------------------------------------------------------------------
|
|
57
78
|
// Project directory resolution
|
|
58
79
|
// ---------------------------------------------------------------------------
|
|
59
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
|
+
|
|
60
111
|
export function resolveProjectDir(inputDir = '.') {
|
|
61
|
-
return resolve(process.cwd(), inputDir);
|
|
112
|
+
return resolve(process.cwd(), expandHomePath(inputDir));
|
|
62
113
|
}
|
|
63
114
|
|
|
64
115
|
export function getBundleDir(projectDir) {
|
|
@@ -427,6 +478,39 @@ export function resolveListingScreenshots(projectDir, appConfig = null) {
|
|
|
427
478
|
});
|
|
428
479
|
}
|
|
429
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
|
+
}
|
|
511
|
+
return warnings;
|
|
512
|
+
}
|
|
513
|
+
|
|
430
514
|
export function inspectListingReadiness(projectDir, appConfig = null) {
|
|
431
515
|
const config = appConfig || {};
|
|
432
516
|
const warnings = [];
|
|
@@ -738,7 +822,7 @@ export function generateManifest(appConfig, projectDir) {
|
|
|
738
822
|
const displayTitle = appConfig.title || appConfig.displayName || appConfig.name;
|
|
739
823
|
const skills = (Array.isArray(appConfig.skills) ? appConfig.skills : []).map((skill) => ({
|
|
740
824
|
key: skill.key,
|
|
741
|
-
path:
|
|
825
|
+
path: normalizeAppSkillManifestPath(skill.path),
|
|
742
826
|
name: skill.name,
|
|
743
827
|
description: skill.description || null,
|
|
744
828
|
}));
|
|
@@ -812,11 +896,25 @@ export function generateManifest(appConfig, projectDir) {
|
|
|
812
896
|
databases,
|
|
813
897
|
capabilities: normalizeAppCapabilities(appConfig.capabilities),
|
|
814
898
|
tools: appConfig.tools || [],
|
|
899
|
+
tool_bindings: normalizeAppToolBindings(appConfig.toolBindings),
|
|
815
900
|
skills,
|
|
816
901
|
onboarding: appConfig.onboarding || null,
|
|
817
902
|
};
|
|
818
903
|
}
|
|
819
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
|
+
|
|
820
918
|
/**
|
|
821
919
|
* Keeps only capabilities the platform actually understands, at the exact
|
|
822
920
|
* values it accepts. An unknown key or value is dropped rather than passed
|
|
@@ -830,12 +928,54 @@ export function normalizeAppCapabilities(capabilities) {
|
|
|
830
928
|
if (capabilities.workspaceDatabases === 'read') {
|
|
831
929
|
normalized.workspaceDatabases = 'read';
|
|
832
930
|
}
|
|
931
|
+
if (capabilities.cloudComputer === 'read' || capabilities.cloudComputer === 'shell') {
|
|
932
|
+
normalized.cloudComputer = capabilities.cloudComputer;
|
|
933
|
+
}
|
|
833
934
|
return normalized;
|
|
834
935
|
}
|
|
835
936
|
|
|
937
|
+
/**
|
|
938
|
+
* Manifest form of a declared skill path: no leading `./`, no trailing slash.
|
|
939
|
+
* A directory declaration is the same string as the source-tree prefix the
|
|
940
|
+
* server matches the uploaded source files against.
|
|
941
|
+
*/
|
|
942
|
+
export function normalizeAppSkillManifestPath(sourcePath) {
|
|
943
|
+
return String(sourcePath || '').replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/\/+$/, '');
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Every packageable file under a declared skill directory, relative to that
|
|
948
|
+
* directory, in stable order. Excludes match `readSourceFiles` so the files a
|
|
949
|
+
* dev session sends inline are exactly the ones a deploy uploads as source.
|
|
950
|
+
*/
|
|
951
|
+
function readAppSkillDirectoryFiles(skillDir) {
|
|
952
|
+
const entries = [];
|
|
953
|
+
|
|
954
|
+
function walk(dir, prefix) {
|
|
955
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
956
|
+
if (shouldExcludeSourceEntry(entry.name) || entry.isSymbolicLink()) {
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
const fullPath = join(dir, entry.name);
|
|
960
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
961
|
+
if (entry.isDirectory()) {
|
|
962
|
+
walk(fullPath, relPath);
|
|
963
|
+
} else if (entry.isFile()) {
|
|
964
|
+
entries.push({ path: relPath, absolutePath: fullPath });
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
walk(skillDir, '');
|
|
970
|
+
// Byte order, not locale order: the server sorts the same file set the same
|
|
971
|
+
// way before hashing it, so a dev session and a deploy agree on the hash.
|
|
972
|
+
return entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
973
|
+
}
|
|
974
|
+
|
|
836
975
|
export function resolveConfiguredAppSkills(appConfig, projectDir) {
|
|
837
976
|
const configured = Array.isArray(appConfig.skills) ? appConfig.skills : [];
|
|
838
977
|
const projectRoot = resolve(projectDir);
|
|
978
|
+
const realProjectRoot = realpathSync(projectRoot);
|
|
839
979
|
const seenKeys = new Set();
|
|
840
980
|
|
|
841
981
|
return configured.map((skill, index) => {
|
|
@@ -855,7 +995,49 @@ export function resolveConfiguredAppSkills(appConfig, projectDir) {
|
|
|
855
995
|
if (!relativePath || relativePath.startsWith('../') || relativePath === '..') {
|
|
856
996
|
throw usageError(`App skill path must stay inside the project: ${sourcePath}`);
|
|
857
997
|
}
|
|
858
|
-
if (!existsSync(absolutePath)
|
|
998
|
+
if (!existsSync(absolutePath)) {
|
|
999
|
+
throw usageError(`App skill entrypoint not found: ${sourcePath}`);
|
|
1000
|
+
}
|
|
1001
|
+
if (lstatSync(absolutePath).isSymbolicLink()) {
|
|
1002
|
+
throw usageError(`App skill entrypoint cannot be a symbolic link: ${sourcePath}`);
|
|
1003
|
+
}
|
|
1004
|
+
const realAbsolutePath = realpathSync(absolutePath);
|
|
1005
|
+
const realRelativePath = relative(realProjectRoot, realAbsolutePath).replace(/\\/g, '/');
|
|
1006
|
+
if (!realRelativePath || realRelativePath.startsWith('../') || realRelativePath === '..') {
|
|
1007
|
+
throw usageError(`App skill path must stay inside the project after resolving links: ${sourcePath}`);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const description = typeof skill.description === 'string' ? skill.description.trim() : null;
|
|
1011
|
+
const stats = statSync(absolutePath);
|
|
1012
|
+
if (stats.isDirectory()) {
|
|
1013
|
+
const entries = readAppSkillDirectoryFiles(absolutePath);
|
|
1014
|
+
if (!entries.some((entry) => entry.path === 'SKILL.md')) {
|
|
1015
|
+
throw usageError(`App skill directory must contain SKILL.md: ${sourcePath}`);
|
|
1016
|
+
}
|
|
1017
|
+
const bundleFiles = entries.map((entry) => ({
|
|
1018
|
+
path: entry.path,
|
|
1019
|
+
content: readFileSync(entry.absolutePath),
|
|
1020
|
+
}));
|
|
1021
|
+
const totalBytes = bundleFiles.reduce((total, entry) => total + entry.content.length, 0);
|
|
1022
|
+
if (totalBytes > MAX_APP_SKILL_BUNDLE_BYTES) {
|
|
1023
|
+
throw usageError(
|
|
1024
|
+
`App skill "${key}" bundles ${totalBytes} bytes, above the ${MAX_APP_SKILL_BUNDLE_BYTES} byte limit.`,
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
const skillMd = bundleFiles.find((entry) => entry.path === 'SKILL.md');
|
|
1028
|
+
return {
|
|
1029
|
+
key,
|
|
1030
|
+
path: relativePath,
|
|
1031
|
+
name,
|
|
1032
|
+
description,
|
|
1033
|
+
skill_md: skillMd.content.toString('utf8'),
|
|
1034
|
+
bundle_files: bundleFiles.map((entry) => ({
|
|
1035
|
+
path: entry.path,
|
|
1036
|
+
content_b64: entry.content.toString('base64'),
|
|
1037
|
+
})),
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
if (!stats.isFile()) {
|
|
859
1041
|
throw usageError(`App skill entrypoint not found: ${sourcePath}`);
|
|
860
1042
|
}
|
|
861
1043
|
|
|
@@ -863,7 +1045,7 @@ export function resolveConfiguredAppSkills(appConfig, projectDir) {
|
|
|
863
1045
|
key,
|
|
864
1046
|
path: relativePath,
|
|
865
1047
|
name,
|
|
866
|
-
description
|
|
1048
|
+
description,
|
|
867
1049
|
skill_md: readFileSync(absolutePath, 'utf8'),
|
|
868
1050
|
};
|
|
869
1051
|
});
|
|
@@ -934,21 +1116,212 @@ function copyMetadataAssets(projectDir) {
|
|
|
934
1116
|
// Linking
|
|
935
1117
|
// ---------------------------------------------------------------------------
|
|
936
1118
|
|
|
937
|
-
|
|
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) {
|
|
938
1148
|
const statePath = join(projectDir, STATE_FILE);
|
|
1149
|
+
assertLinkedStatePathSafe(projectDir);
|
|
939
1150
|
if (!existsSync(statePath)) return null;
|
|
940
|
-
|
|
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
|
+
};
|
|
941
1160
|
}
|
|
942
1161
|
|
|
943
|
-
|
|
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;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
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) {
|
|
944
1276
|
const statePath = join(projectDir, STATE_FILE);
|
|
945
|
-
|
|
946
|
-
|
|
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
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
|
|
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
|
+
}
|
|
947
1320
|
}
|
|
948
1321
|
|
|
949
|
-
export function requireLinkedAppId(projectDir, explicitAppId) {
|
|
1322
|
+
export function requireLinkedAppId(projectDir, explicitAppId, profileKey = null) {
|
|
950
1323
|
if (explicitAppId) return explicitAppId;
|
|
951
|
-
const state = readLinkedState(projectDir);
|
|
1324
|
+
const state = readLinkedState(projectDir, profileKey);
|
|
952
1325
|
if (state?.app_id) return state.app_id;
|
|
953
1326
|
throw usageError('This project is not linked to a Notis app. Run "notis apps link <app-id> ." first.');
|
|
954
1327
|
}
|
|
@@ -958,76 +1331,412 @@ export function requireLinkedAppId(projectDir, explicitAppId) {
|
|
|
958
1331
|
// ---------------------------------------------------------------------------
|
|
959
1332
|
|
|
960
1333
|
/**
|
|
961
|
-
* 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.
|
|
962
1336
|
*/
|
|
963
|
-
export function
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
:
|
|
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
|
+
}
|
|
1345
|
+
}
|
|
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();
|
|
970
1355
|
}
|
|
971
|
-
return loadMonorepoScaffoldCatalog();
|
|
972
1356
|
}
|
|
973
1357
|
|
|
974
|
-
|
|
975
|
-
const
|
|
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
|
+
);
|
|
976
1411
|
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
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);
|
|
981
1430
|
}
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
const slugName = jsStringLiteral(safeKebab(appName) || 'my-notis-app');
|
|
1008
|
-
if (fromSlug) {
|
|
1009
|
-
if (/name\s*:/.test(config)) {
|
|
1010
|
-
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
|
+
}
|
|
1011
1456
|
}
|
|
1012
|
-
if (/
|
|
1013
|
-
config = config.replace(/
|
|
1457
|
+
if (/devSlug\s*:/.test(config)) {
|
|
1458
|
+
config = config.replace(/devSlug\s*:\s*(['"`])[\s\S]*?\1/, `devSlug: ${slugName}`);
|
|
1014
1459
|
} else {
|
|
1015
|
-
config = config.replace(
|
|
1460
|
+
config = config.replace(
|
|
1461
|
+
/name\s*:\s*(['"`])[\s\S]*?\1/,
|
|
1462
|
+
(nameDeclaration) => `${nameDeclaration},\n devSlug: ${slugName}`,
|
|
1463
|
+
);
|
|
1016
1464
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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}`);
|
|
1020
1490
|
}
|
|
1021
|
-
if (
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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}`);
|
|
1025
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
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
return { projectDir: requestedProjectDir };
|
|
1634
|
+
}
|
|
1635
|
+
|
|
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
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
/**
|
|
1673
|
+
* Drop `property: [ ... ]` from a notis.config.ts source.
|
|
1674
|
+
*
|
|
1675
|
+
* The scan tracks bracket depth while skipping string literals, so entries
|
|
1676
|
+
* whose text contains a bracket (alt text, selectors) cannot end the array
|
|
1677
|
+
* early. When the array cannot be resolved the source is returned untouched --
|
|
1678
|
+
* a stale screenshots list is a warning at verify time, a broken config is not.
|
|
1679
|
+
*/
|
|
1680
|
+
function removeConfigArrayProperty(source, property) {
|
|
1681
|
+
const start = source.search(new RegExp(`^[ \\t]*${property}[ \\t]*:[ \\t]*\\[`, 'm'));
|
|
1682
|
+
if (start === -1) {
|
|
1683
|
+
return source;
|
|
1684
|
+
}
|
|
1685
|
+
let index = source.indexOf('[', start);
|
|
1686
|
+
let depth = 0;
|
|
1687
|
+
let quote = null;
|
|
1688
|
+
for (; index < source.length; index += 1) {
|
|
1689
|
+
const char = source[index];
|
|
1690
|
+
if (quote) {
|
|
1691
|
+
if (char === '\\') {
|
|
1692
|
+
index += 1;
|
|
1693
|
+
} else if (char === quote) {
|
|
1694
|
+
quote = null;
|
|
1695
|
+
}
|
|
1696
|
+
continue;
|
|
1697
|
+
}
|
|
1698
|
+
if (char === '\'' || char === '"' || char === '`') {
|
|
1699
|
+
quote = char;
|
|
1700
|
+
continue;
|
|
1026
1701
|
}
|
|
1027
|
-
|
|
1702
|
+
if (char === '[') {
|
|
1703
|
+
depth += 1;
|
|
1704
|
+
} else if (char === ']') {
|
|
1705
|
+
depth -= 1;
|
|
1706
|
+
if (depth === 0) {
|
|
1707
|
+
break;
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
if (depth !== 0) {
|
|
1712
|
+
return source;
|
|
1713
|
+
}
|
|
1714
|
+
let end = index + 1;
|
|
1715
|
+
if (source[end] === ',') {
|
|
1716
|
+
end += 1;
|
|
1028
1717
|
}
|
|
1718
|
+
while (end < source.length && (source[end] === ' ' || source[end] === '\t')) {
|
|
1719
|
+
end += 1;
|
|
1720
|
+
}
|
|
1721
|
+
if (source[end] === '\n') {
|
|
1722
|
+
end += 1;
|
|
1723
|
+
}
|
|
1724
|
+
return source.slice(0, start) + source.slice(end);
|
|
1725
|
+
}
|
|
1029
1726
|
|
|
1030
|
-
|
|
1727
|
+
/**
|
|
1728
|
+
* A new project starts its own release history: the scaffold's entries describe
|
|
1729
|
+
* releases of a different app.
|
|
1730
|
+
*/
|
|
1731
|
+
function resetScaffoldChangelog(projectDir, appName) {
|
|
1732
|
+
const changelogPath = join(projectDir, 'CHANGELOG.md');
|
|
1733
|
+
if (!existsSync(changelogPath)) {
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
writeFileSync(
|
|
1737
|
+
changelogPath,
|
|
1738
|
+
`# ${appName} Changelog\n\n## [Initial Release] - ${CHANGELOG_MERGE_DATE}\n\n- First Store release.\n`,
|
|
1739
|
+
);
|
|
1031
1740
|
}
|
|
1032
1741
|
|
|
1033
1742
|
function normalizeScaffoldLockfile(projectDir, pkg) {
|
|
@@ -1070,86 +1779,189 @@ function ensureScaffoldLocalSdk(projectDir, pkg) {
|
|
|
1070
1779
|
cpSync(TEMPLATE_SDK_DIR, localSdkDir, { recursive: true, dereference: true });
|
|
1071
1780
|
}
|
|
1072
1781
|
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
}
|
|
1078
|
-
const monorepoDir = join(MONOREPO_SCAFFOLDS_DIR, fromSlug);
|
|
1079
|
-
if (existsSync(join(monorepoDir, 'notis.config.ts'))) {
|
|
1080
|
-
return monorepoDir;
|
|
1081
|
-
}
|
|
1082
|
-
return bundledDir;
|
|
1083
|
-
}
|
|
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(\/|$))/;
|
|
1084
1786
|
|
|
1085
|
-
function
|
|
1086
|
-
|
|
1087
|
-
|
|
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.`);
|
|
1088
1795
|
}
|
|
1089
|
-
const
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
const description = readTsStringProperty(configSource, 'description') || '';
|
|
1100
|
-
scaffolds.push({
|
|
1101
|
-
slug: entry.name,
|
|
1102
|
-
name: readTsStringProperty(configSource, 'title') || readTsStringProperty(configSource, 'name') || entry.name,
|
|
1103
|
-
description,
|
|
1104
|
-
icon: readTsStringProperty(configSource, 'icon') || 'phosphor:squares-four',
|
|
1105
|
-
categories: readTsStringArrayProperty(configSource, 'categories'),
|
|
1106
|
-
tagline: readTsStringProperty(configSource, 'tagline') || description,
|
|
1107
|
-
});
|
|
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.`);
|
|
1108
1806
|
}
|
|
1109
|
-
return
|
|
1807
|
+
return content;
|
|
1110
1808
|
}
|
|
1111
1809
|
|
|
1112
|
-
function
|
|
1113
|
-
const
|
|
1114
|
-
return
|
|
1810
|
+
function captureDirectoryRevision() {
|
|
1811
|
+
const stat = statSync('.', { bigint: true });
|
|
1812
|
+
return { dev: stat.dev, ino: stat.ino, mtimeNs: stat.mtimeNs, ctimeNs: stat.ctimeNs };
|
|
1115
1813
|
}
|
|
1116
1814
|
|
|
1117
|
-
function
|
|
1118
|
-
const
|
|
1119
|
-
if (
|
|
1120
|
-
|
|
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.`);
|
|
1121
1824
|
}
|
|
1122
|
-
return Array.from(match[1].matchAll(/(['"`])([\s\S]*?)\1/g), (entry) => entry[2].trim()).filter(Boolean);
|
|
1123
1825
|
}
|
|
1124
1826
|
|
|
1125
1827
|
function copyScaffoldSource(sourceDir, targetDir) {
|
|
1126
1828
|
function shouldCopy(path) {
|
|
1127
1829
|
const name = path.split(/[\\/]/).pop();
|
|
1128
1830
|
if (!name) return true;
|
|
1831
|
+
const relPath = relative(sourceDir, path).replace(/\\/g, '/');
|
|
1832
|
+
if (SCAFFOLD_LISTING_MEDIA.test(relPath) || REGISTRY_ARTIFACTS.test(relPath)) {
|
|
1833
|
+
return false;
|
|
1834
|
+
}
|
|
1129
1835
|
return !SCAFFOLD_COPY_EXCLUDES.has(name)
|
|
1836
|
+
&& !name.toLocaleLowerCase('en-US').startsWith('.notis-app-scaffold-')
|
|
1130
1837
|
&& !name.startsWith('.env')
|
|
1131
1838
|
&& !/\.(test|spec)\.[cm]?[jt]sx?$/i.test(name);
|
|
1132
1839
|
}
|
|
1133
1840
|
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
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}`);
|
|
1137
1851
|
}
|
|
1138
|
-
const
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
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);
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
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}`);
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
|
|
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
|
+
}
|
|
1143
1946
|
}
|
|
1144
|
-
return;
|
|
1145
1947
|
}
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
cpSync(src, dest);
|
|
1948
|
+
for (const file of node.files) {
|
|
1949
|
+
writeFileSync(file.name, file.content, { flag: 'wx', mode: file.mode });
|
|
1149
1950
|
}
|
|
1150
1951
|
}
|
|
1151
1952
|
|
|
1152
|
-
|
|
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
|
+
}
|
|
1153
1965
|
}
|
|
1154
1966
|
|
|
1155
1967
|
// ---------------------------------------------------------------------------
|
|
@@ -1187,29 +1999,81 @@ export function collectArtifactFiles(projectDir) {
|
|
|
1187
1999
|
}
|
|
1188
2000
|
|
|
1189
2001
|
function shouldExcludeSourceEntry(name) {
|
|
1190
|
-
|
|
2002
|
+
const casefolded = String(name || '').toLocaleLowerCase('en-US');
|
|
2003
|
+
return (
|
|
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')
|
|
2010
|
+
);
|
|
1191
2011
|
}
|
|
1192
2012
|
|
|
1193
2013
|
function readSourceFiles(projectDir) {
|
|
1194
2014
|
const files = {};
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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)) {
|
|
1200
2028
|
continue;
|
|
1201
2029
|
}
|
|
1202
|
-
const
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
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
|
+
}
|
|
1208
2063
|
}
|
|
1209
2064
|
}
|
|
2065
|
+
assertDirectoryRevisionUnchanged(directoryRevision, `App source directory ${prefix || '.'}`);
|
|
1210
2066
|
}
|
|
1211
2067
|
|
|
1212
|
-
|
|
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
|
+
}
|
|
1213
2077
|
return files;
|
|
1214
2078
|
}
|
|
1215
2079
|
|
|
@@ -1231,9 +2095,55 @@ function cleanTarPath(name) {
|
|
|
1231
2095
|
return parts.join('/');
|
|
1232
2096
|
}
|
|
1233
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
|
+
|
|
1234
2142
|
function extractTarGz(buffer, targetDir) {
|
|
1235
2143
|
const tar = gunzipSync(buffer);
|
|
1236
2144
|
let offset = 0;
|
|
2145
|
+
let pendingPaxPath = null;
|
|
2146
|
+
const extractedPaths = new Map();
|
|
1237
2147
|
while (offset + 512 <= tar.length) {
|
|
1238
2148
|
const header = tar.subarray(offset, offset + 512);
|
|
1239
2149
|
offset += 512;
|
|
@@ -1247,35 +2157,251 @@ function extractTarGz(buffer, targetDir) {
|
|
|
1247
2157
|
const sizeRaw = header.subarray(124, 136).toString('utf-8').replace(/\0.*$/, '').trim();
|
|
1248
2158
|
const size = Number.parseInt(sizeRaw || '0', 8);
|
|
1249
2159
|
const typeFlag = header[156];
|
|
1250
|
-
|
|
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
|
+
}
|
|
1251
2190
|
const outputPath = join(targetDir, relPath);
|
|
1252
2191
|
|
|
1253
2192
|
if (typeFlag === 53) {
|
|
1254
2193
|
mkdirSync(outputPath, { recursive: true });
|
|
1255
|
-
} else {
|
|
2194
|
+
} else if (typeFlag === 0 || typeFlag === 48) {
|
|
1256
2195
|
mkdirSync(dirname(outputPath), { recursive: true });
|
|
1257
|
-
writeFileSync(outputPath,
|
|
2196
|
+
writeFileSync(outputPath, data);
|
|
2197
|
+
} else {
|
|
2198
|
+
throw usageError(`Refusing to extract unsupported source archive entry: ${relPath}`);
|
|
1258
2199
|
}
|
|
1259
2200
|
|
|
1260
2201
|
offset += Math.ceil(size / 512) * 512;
|
|
1261
2202
|
}
|
|
2203
|
+
if (pendingPaxPath !== null) {
|
|
2204
|
+
throw usageError('Refusing to extract a dangling PAX source archive header.');
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
|
|
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;
|
|
1262
2228
|
}
|
|
1263
2229
|
|
|
1264
|
-
|
|
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({
|
|
1265
2389
|
apiBase,
|
|
1266
2390
|
jwt,
|
|
1267
2391
|
appId,
|
|
1268
2392
|
targetDir,
|
|
1269
2393
|
version = 'latest',
|
|
1270
2394
|
force = false,
|
|
1271
|
-
|
|
1272
|
-
|
|
2395
|
+
profileKey = null,
|
|
2396
|
+
}, assertLockOwnership, assertParentIdentity, initialTargetIdentity) {
|
|
2397
|
+
assertLockOwnership();
|
|
2398
|
+
assertParentIdentity();
|
|
2399
|
+
const targetWasNonEmpty = existsSync(targetDir) && readdirSync(targetDir).length > 0;
|
|
2400
|
+
if (targetWasNonEmpty) {
|
|
1273
2401
|
if (!force) {
|
|
1274
2402
|
throw usageError(`Target directory is not empty: ${targetDir}. Pass --force to overwrite it.`);
|
|
1275
2403
|
}
|
|
1276
|
-
rmSync(targetDir, { recursive: true, force: true });
|
|
1277
2404
|
}
|
|
1278
|
-
mkdirSync(targetDir, { recursive: true });
|
|
1279
2405
|
|
|
1280
2406
|
const params = new URLSearchParams({ app_id: appId, version: String(version || 'latest') });
|
|
1281
2407
|
const response = await fetch(`${apiBase.replace(/\/$/, '')}/portal_apps/source?${params.toString()}`, {
|
|
@@ -1289,17 +2415,229 @@ export async function pullAppSource({
|
|
|
1289
2415
|
const contentDisposition = response.headers.get('content-disposition') || '';
|
|
1290
2416
|
const versionMatch = /-v(\d+)\.tar\.gz/i.exec(contentDisposition);
|
|
1291
2417
|
const pulledVersion = versionMatch ? Number.parseInt(versionMatch[1], 10) : null;
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
String(version
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
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
|
+
}
|
|
1303
2641
|
|
|
1304
2642
|
return { projectDir: targetDir, version: pulledVersion || version };
|
|
1305
2643
|
}
|