@notis_ai/cli 0.2.18 → 0.2.20
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 +542 -146
- package/bin/check-runtime.js +15 -0
- package/bin/notis.js +2 -0
- package/config/notis_app_design_rules.json +320 -0
- package/config/portal-route-roots.json +45 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +42325 -18273
- package/dist/base-skills/notis-cli/.notis-base-skill.json +1 -0
- package/dist/base-skills/notis-cli/SKILL.md +51 -280
- package/dist/base-skills/notis-cli/references/app-delivery.md +17 -0
- package/dist/base-skills/notis-cli/references/intelligence.md +94 -0
- package/dist/base-skills/notis-cli/references/native-databases.md +21 -0
- package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
- package/dist/base-skills/notis-cli/references/troubleshooting.md +67 -0
- package/dist/base-skills/notis-query/.notis-base-skill.json +1 -0
- package/dist/base-skills/notis-query/SKILL.md +35 -699
- package/dist/base-skills/notis-query/references/database-discovery.md +59 -0
- package/dist/base-skills/notis-query/references/documents.md +74 -0
- package/dist/base-skills/notis-query/references/query.md +76 -0
- package/dist/base-skills/notis-spaces/.notis-base-skill.json +1 -0
- package/dist/base-skills/notis-spaces/SKILL.md +218 -0
- package/dist/base-skills/notis-spaces/references/architecture.md +116 -0
- package/dist/base-skills/notis-spaces/references/context.md +81 -0
- package/dist/base-skills/notis-spaces/references/design.md +141 -0
- package/dist/base-skills/notis-spaces/references/fixtures.md +29 -0
- package/dist/base-skills/notis-spaces/references/memory.md +58 -0
- package/dist/base-skills/notis-spaces/references/reading.md +73 -0
- package/dist/base-skills/notis-spaces/references/release.md +120 -0
- package/dist/base-skills/notis-spaces/references/reports.md +77 -0
- package/dist/base-skills/notis-spaces/references/sdk.md +273 -0
- package/dist/base-skills/notis-spaces/references/store.md +118 -0
- package/dist/base-skills/notis-spaces/references/troubleshooting.md +28 -0
- package/dist/base-skills/notis-spaces/references/views.md +229 -0
- package/{template/packages → dist}/sdk/package.json +9 -10
- package/dist/sdk/src/agentContext.ts +36 -0
- package/dist/sdk/src/bundleModule.ts +175 -0
- package/dist/sdk/src/components/Dialog.tsx +103 -0
- package/dist/sdk/src/components/DocumentEditor.tsx +113 -0
- package/dist/sdk/src/components/DocumentPage.tsx +184 -0
- package/{template/packages → dist}/sdk/src/components/Markdown.tsx +9 -3
- package/{template/packages → dist}/sdk/src/components/MarkdownEditor.tsx +26 -5
- package/{template/packages → dist}/sdk/src/components/MultiSelectActionBar.tsx +95 -12
- package/{template/packages → dist}/sdk/src/components/MultiSelectCheckbox.tsx +4 -2
- package/dist/sdk/src/components/NotisCommentBoundary.tsx +172 -0
- package/{template/packages → dist}/sdk/src/components/NotisSelectionBoundary.tsx +9 -5
- package/dist/sdk/src/components/RecordComponents.tsx +111 -0
- package/dist/sdk/src/components/Skeleton.tsx +38 -0
- package/dist/sdk/src/config.ts +31 -0
- package/{template/packages → dist}/sdk/src/documents.ts +21 -6
- package/dist/sdk/src/hooks/useAction.ts +28 -0
- package/dist/sdk/src/hooks/useAgentContext.ts +23 -0
- package/{template/packages → dist}/sdk/src/hooks/useBackend.ts +3 -2
- package/dist/sdk/src/hooks/useCloudComputer.ts +94 -0
- package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
- package/{template/packages → dist}/sdk/src/hooks/useCollectionInteractions.ts +157 -147
- package/dist/sdk/src/hooks/useDatabaseSchema.ts +50 -0
- package/{template/packages → dist}/sdk/src/hooks/useDatabaseSubscription.ts +2 -2
- package/dist/sdk/src/hooks/useDocument.ts +51 -0
- package/{template/packages → dist}/sdk/src/hooks/useDocuments.ts +25 -59
- package/{template/packages → dist}/sdk/src/hooks/useHandover.ts +7 -7
- package/dist/sdk/src/hooks/useLongPressSelection.ts +79 -0
- package/dist/sdk/src/hooks/useMarqueeSelection.ts +194 -0
- package/{template/packages → dist}/sdk/src/hooks/useMultiSelect.ts +2 -8
- package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
- package/dist/sdk/src/hooks/useNativeDocuments.ts +143 -0
- package/{template/packages → dist}/sdk/src/hooks/useNotis.ts +6 -1
- package/dist/sdk/src/hooks/useNotisNavigation.ts +70 -0
- package/dist/sdk/src/hooks/useQuery.ts +81 -0
- package/dist/sdk/src/hooks/useToolQuery.ts +12 -0
- package/dist/sdk/src/hooks/useTopBarSearch.ts +102 -0
- package/dist/sdk/src/hooks/useTopBarTitleLeaf.ts +75 -0
- package/{template/packages → dist}/sdk/src/hooks/useUpsertDocument.ts +1 -1
- package/dist/sdk/src/hooks/useViewParams.ts +195 -0
- package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
- package/{template/packages → dist}/sdk/src/index.ts +64 -1
- package/{template/packages → dist}/sdk/src/interactions/actions.ts +14 -1
- package/{template/packages → dist}/sdk/src/interactions/shortcuts.tsx +129 -23
- package/dist/sdk/src/interactions/visibility.ts +13 -0
- package/{template/packages → dist}/sdk/src/interactions.ts +5 -1
- package/dist/sdk/src/nativeData.ts +39 -0
- package/{template/packages → dist}/sdk/src/provider.tsx +9 -4
- package/dist/sdk/src/queryCache.ts +292 -0
- package/dist/sdk/src/queryErrors.ts +51 -0
- package/dist/sdk/src/runtime.ts +675 -0
- package/dist/sdk/src/runtimeModules.ts +33 -0
- package/dist/sdk/src/space.ts +442 -0
- package/dist/sdk/src/standardViewParams.ts +36 -0
- package/{template/packages → dist}/sdk/src/styles.css +81 -16
- package/dist/sdk/src/tailwind.ts +66 -0
- package/dist/sdk/src/ui/charts/RenderChartData.tsx +25 -0
- package/dist/sdk/src/ui/engine/core.ts +392 -0
- package/dist/sdk/src/ui/engine/dom.ts +183 -0
- package/dist/sdk/src/ui/engine/hooks.ts +195 -0
- package/dist/sdk/src/ui/engine/instance.ts +63 -0
- package/dist/sdk/src/ui/engine/keycapContext.ts +2 -0
- package/dist/sdk/src/ui/engine/registry.ts +262 -0
- package/dist/sdk/src/ui/engine/types.ts +62 -0
- package/dist/sdk/src/ui/icons.tsx +91 -0
- package/dist/sdk/src/ui/util.ts +83 -0
- package/dist/sdk/src/viewPeriod.ts +26 -0
- package/dist/sdk/src/vite.ts +92 -0
- package/dist/sdk/src/words/actions.ts +295 -0
- package/dist/sdk/src/words/clashes.ts +158 -0
- package/dist/sdk/src/words/format.ts +136 -0
- package/dist/sdk/src/words/index.ts +13 -0
- package/dist/sdk/src/words/keymap.ts +72 -0
- package/dist/sdk/src/words/keys.ts +297 -0
- package/dist/sdk/src/words/shell.ts +46 -0
- package/dist/sdk/src/words/types.ts +65 -0
- package/dist/skill-sync/index.js +832 -151
- package/dist/skill-sync/index.js.map +4 -4
- package/dist/skill-sync-worker.mjs +3941 -0
- package/dist/space-harness/host.js +1460 -0
- package/dist/space-sdk/dist/chunk-ZSN57ATH.js +5 -0
- package/dist/space-sdk/dist/index.js +15 -0
- package/dist/space-sdk/dist/ui.js +18 -0
- package/dist/space-sdk/package.json +33 -0
- package/dist/space-sdk/src/agentContext.d.ts +36 -0
- package/dist/space-sdk/src/components/Dialog.d.ts +20 -0
- package/dist/space-sdk/src/components/DocumentEditor.d.ts +46 -0
- package/dist/space-sdk/src/components/DocumentPage.d.ts +53 -0
- package/dist/space-sdk/src/components/Markdown.d.ts +18 -0
- package/dist/space-sdk/src/components/MarkdownEditor.d.ts +11 -0
- package/dist/space-sdk/src/components/MultiSelectActionBar.d.ts +31 -0
- package/dist/space-sdk/src/components/MultiSelectCheckbox.d.ts +14 -0
- package/dist/space-sdk/src/components/MultiSelectDragOverlay.d.ts +8 -0
- package/dist/space-sdk/src/components/NotisCommentBoundary.d.ts +36 -0
- package/dist/space-sdk/src/components/NotisSelectionBoundary.d.ts +16 -0
- package/dist/space-sdk/src/components/RecordComponents.d.ts +64 -0
- package/dist/space-sdk/src/components/ShortcutHints.d.ts +12 -0
- package/dist/space-sdk/src/components/Skeleton.d.ts +23 -0
- package/dist/space-sdk/src/config.ts +31 -0
- package/dist/space-sdk/src/documents.d.ts +59 -0
- package/dist/space-sdk/src/hooks/useAction.d.ts +8 -0
- package/dist/space-sdk/src/hooks/useActiveResource.d.ts +3 -0
- package/dist/space-sdk/src/hooks/useAgentContext.d.ts +7 -0
- package/dist/space-sdk/src/hooks/useBackend.d.ts +23 -0
- package/dist/space-sdk/src/hooks/useCloudComputer.d.ts +45 -0
- package/dist/space-sdk/src/hooks/useCloudComputerShell.d.ts +49 -0
- package/dist/space-sdk/src/hooks/useCollectionInteractions.d.ts +118 -0
- package/dist/space-sdk/src/hooks/useDatabaseSchema.d.ts +14 -0
- package/dist/space-sdk/src/hooks/useDatabaseSubscription.d.ts +26 -0
- package/dist/space-sdk/src/hooks/useDocument.d.ts +22 -0
- package/dist/space-sdk/src/hooks/useDocuments.d.ts +31 -0
- package/dist/space-sdk/src/hooks/useHandover.d.ts +43 -0
- package/dist/space-sdk/src/hooks/useLongPressSelection.d.ts +9 -0
- package/dist/space-sdk/src/hooks/useMarqueeSelection.d.ts +57 -0
- package/dist/space-sdk/src/hooks/useMultiSelect.d.ts +51 -0
- package/dist/space-sdk/src/hooks/useNativeDocumentBody.d.ts +24 -0
- package/dist/space-sdk/src/hooks/useNativeDocuments.d.ts +55 -0
- package/dist/space-sdk/src/hooks/useNotis.d.ts +25 -0
- package/dist/space-sdk/src/hooks/useNotisNavigation.d.ts +33 -0
- package/dist/space-sdk/src/hooks/useQuery.d.ts +22 -0
- package/dist/space-sdk/src/hooks/useTool.d.ts +20 -0
- package/dist/space-sdk/src/hooks/useToolQuery.d.ts +3 -0
- package/dist/space-sdk/src/hooks/useTools.d.ts +15 -0
- package/dist/space-sdk/src/hooks/useTopBarSearch.d.ts +48 -0
- package/dist/space-sdk/src/hooks/useTopBarTitleLeaf.d.ts +24 -0
- package/dist/space-sdk/src/hooks/useUpsertDocument.d.ts +27 -0
- package/dist/space-sdk/src/hooks/useViewParams.d.ts +76 -0
- package/dist/space-sdk/src/hooks/useViewerReads.d.ts +38 -0
- package/dist/space-sdk/src/index.d.ts +98 -0
- package/dist/space-sdk/src/interactions/actions.d.ts +33 -0
- package/dist/space-sdk/src/interactions/shortcuts.d.ts +42 -0
- package/dist/space-sdk/src/interactions/visibility.d.ts +2 -0
- package/dist/space-sdk/src/nativeData.d.ts +66 -0
- package/dist/space-sdk/src/provider.d.ts +17 -0
- package/dist/space-sdk/src/queryCache.d.ts +45 -0
- package/dist/space-sdk/src/queryErrors.ts +51 -0
- package/dist/space-sdk/src/runtime.d.ts +615 -0
- package/dist/space-sdk/src/runtimeModules.ts +33 -0
- package/dist/space-sdk/src/space.ts +442 -0
- package/dist/space-sdk/src/standardViewParams.d.ts +15 -0
- package/dist/space-sdk/src/tailwind-candidates.d.ts +3418 -0
- package/dist/space-sdk/src/tailwind.ts +66 -0
- package/dist/space-sdk/src/ui/actions/ActionToast.d.ts +15 -0
- package/dist/space-sdk/src/ui/actions/actionWords.d.ts +16 -0
- package/dist/space-sdk/src/ui/actions/index.d.ts +7 -0
- package/dist/space-sdk/src/ui/actions/useActions.d.ts +69 -0
- package/dist/space-sdk/src/ui/actions/useToast.d.ts +41 -0
- package/dist/space-sdk/src/ui/blocks/Tabs.d.ts +139 -0
- package/dist/space-sdk/src/ui/blocks/blocks.d.ts +217 -0
- package/dist/space-sdk/src/ui/blocks/index.d.ts +4 -0
- package/dist/space-sdk/src/ui/board/BoardView.d.ts +36 -0
- package/dist/space-sdk/src/ui/board/boardModel.d.ts +46 -0
- package/dist/space-sdk/src/ui/board/boardWords.d.ts +9 -0
- package/dist/space-sdk/src/ui/board/index.d.ts +5 -0
- package/dist/space-sdk/src/ui/calendar/CalendarView.d.ts +49 -0
- package/dist/space-sdk/src/ui/calendar/DayPicker.d.ts +23 -0
- package/dist/space-sdk/src/ui/calendar/calendarMath.d.ts +81 -0
- package/dist/space-sdk/src/ui/charts/Chart.d.ts +203 -0
- package/dist/space-sdk/src/ui/charts/ChartTable.d.ts +37 -0
- package/dist/space-sdk/src/ui/charts/RenderChartData.d.ts +16 -0
- package/dist/space-sdk/src/ui/charts/StatRow.d.ts +59 -0
- package/dist/space-sdk/src/ui/charts/chartData.d.ts +84 -0
- package/dist/space-sdk/src/ui/comments/CommentPane.d.ts +58 -0
- package/dist/space-sdk/src/ui/comments/commentModel.d.ts +157 -0
- package/dist/space-sdk/src/ui/comments/commentWords.d.ts +60 -0
- package/dist/space-sdk/src/ui/comments/index.d.ts +4 -0
- package/dist/space-sdk/src/ui/comments/useComments.d.ts +146 -0
- package/dist/space-sdk/src/ui/engine/HelpSheet.d.ts +12 -0
- package/dist/space-sdk/src/ui/engine/KeyboardEngine.d.ts +27 -0
- package/dist/space-sdk/src/ui/engine/core.d.ts +73 -0
- package/dist/space-sdk/src/ui/engine/hooks.d.ts +64 -0
- package/dist/space-sdk/src/ui/engine/index.d.ts +14 -0
- package/dist/space-sdk/src/ui/engine/instance.d.ts +21 -0
- package/dist/space-sdk/src/ui/engine/keycapContext.d.ts +2 -0
- package/dist/space-sdk/src/ui/engine/registry.d.ts +77 -0
- package/dist/space-sdk/src/ui/engine/step.d.ts +11 -0
- package/dist/space-sdk/src/ui/engine/types.d.ts +54 -0
- package/dist/space-sdk/src/ui/fields/fieldWords.d.ts +56 -0
- package/dist/space-sdk/src/ui/fields/fields.d.ts +162 -0
- package/dist/space-sdk/src/ui/fields/index.d.ts +5 -0
- package/dist/space-sdk/src/ui/fields/useFieldDraft.d.ts +22 -0
- package/dist/space-sdk/src/ui/gallery/AttachmentGallery.d.ts +53 -0
- package/dist/space-sdk/src/ui/gallery/ImageViewer.d.ts +27 -0
- package/dist/space-sdk/src/ui/gallery/galleryWords.d.ts +95 -0
- package/dist/space-sdk/src/ui/gallery/index.d.ts +5 -0
- package/dist/space-sdk/src/ui/icons.d.ts +13 -0
- package/dist/space-sdk/src/ui/index.d.ts +54 -0
- package/dist/space-sdk/src/ui/keycap/Keycap.d.ts +22 -0
- package/dist/space-sdk/src/ui/keycap/index.d.ts +2 -0
- package/dist/space-sdk/src/ui/layout/Panels.d.ts +50 -0
- package/dist/space-sdk/src/ui/layout/chatPanelEdge.d.ts +77 -0
- package/dist/space-sdk/src/ui/layout/index.d.ts +5 -0
- package/dist/space-sdk/src/ui/layout/panelMath.d.ts +91 -0
- package/dist/space-sdk/src/ui/list/ListReader.d.ts +380 -0
- package/dist/space-sdk/src/ui/list/Row.d.ts +145 -0
- package/dist/space-sdk/src/ui/list/SelectionBar.d.ts +65 -0
- package/dist/space-sdk/src/ui/list/index.d.ts +14 -0
- package/dist/space-sdk/src/ui/list/rowState.d.ts +22 -0
- package/dist/space-sdk/src/ui/list/useListKeys.d.ts +89 -0
- package/dist/space-sdk/src/ui/list/useReorder.d.ts +86 -0
- package/dist/space-sdk/src/ui/list/useSelection.d.ts +90 -0
- package/dist/space-sdk/src/ui/open/OpenAsMenu.d.ts +15 -0
- package/dist/space-sdk/src/ui/open/index.d.ts +6 -0
- package/dist/space-sdk/src/ui/open/openModeStore.d.ts +39 -0
- package/dist/space-sdk/src/ui/open/useOpenMode.d.ts +17 -0
- package/dist/space-sdk/src/ui/overlay/Menu.d.ts +88 -0
- package/dist/space-sdk/src/ui/overlay/Popover.d.ts +28 -0
- package/dist/space-sdk/src/ui/overlay/Tooltip.d.ts +19 -0
- package/dist/space-sdk/src/ui/overlay/index.d.ts +9 -0
- package/dist/space-sdk/src/ui/overlay/layerRoot.d.ts +24 -0
- package/dist/space-sdk/src/ui/overlay/position.d.ts +31 -0
- package/dist/space-sdk/src/ui/period/PeriodPicker.d.ts +30 -0
- package/dist/space-sdk/src/ui/period/dateFormat.d.ts +50 -0
- package/dist/space-sdk/src/ui/period/dateWords.d.ts +88 -0
- package/dist/space-sdk/src/ui/period/periods.d.ts +70 -0
- package/dist/space-sdk/src/ui/period/usePeriod.d.ts +23 -0
- package/dist/space-sdk/src/ui/present/index.d.ts +3 -0
- package/dist/space-sdk/src/ui/present/presentWords.d.ts +27 -0
- package/dist/space-sdk/src/ui/present/screens.d.ts +36 -0
- package/dist/space-sdk/src/ui/present/useAudienceScreen.d.ts +38 -0
- package/dist/space-sdk/src/ui/styles.d.ts +21 -0
- package/dist/space-sdk/src/ui/timeline/TimelineView.d.ts +44 -0
- package/dist/space-sdk/src/ui/timeline/timelineMath.d.ts +123 -0
- package/dist/space-sdk/src/viewPeriod.d.ts +8 -0
- package/dist/space-sdk/src/vite.ts +92 -0
- package/dist/space-sdk/src/words/actions.ts +295 -0
- package/dist/space-sdk/src/words/clashes.ts +158 -0
- package/dist/space-sdk/src/words/format.ts +136 -0
- package/dist/space-sdk/src/words/index.ts +13 -0
- package/dist/space-sdk/src/words/keymap.ts +72 -0
- package/dist/space-sdk/src/words/keys.ts +297 -0
- package/dist/space-sdk/src/words/shell.ts +46 -0
- package/dist/space-sdk/src/words/types.ts +65 -0
- package/dist/view-renderer/dist/host.css +1 -0
- package/dist/view-renderer/dist/host.js +2374 -0
- package/dist/view-renderer/dist/theme.css +73 -0
- package/dist/view-renderer/src/dom.js +212 -0
- package/dist/view-renderer/src/errors.js +8 -0
- package/dist/view-renderer/src/host.tsx +250 -0
- package/dist/view-renderer/src/index.js +4 -0
- package/dist/view-renderer/src/png.js +213 -0
- package/dist/view-renderer/src/pool.js +110 -0
- package/dist/view-renderer/src/remote-broker.js +59 -0
- package/dist/view-renderer/src/renderer.js +279 -0
- package/package.json +21 -11
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +63 -12
- package/skills/notis-onboarding/BRIEF.md +14 -9
- package/skills/notis-query/cli.md +1 -1
- package/skills/notis-spaces/cli.md +540 -0
- package/src/cli.js +8 -8
- package/src/command-specs/agents.js +121 -8
- package/src/command-specs/auth.js +3 -3
- package/src/command-specs/diagnostics.js +97 -20
- package/src/command-specs/documents.js +158 -0
- package/src/command-specs/files.js +29 -0
- package/src/command-specs/handover.js +331 -79
- package/src/command-specs/helpers.js +50 -40
- package/src/command-specs/index.js +14 -5
- package/src/command-specs/meta.js +38 -9
- package/src/command-specs/onboarding.js +1 -1
- package/src/command-specs/profile.js +42 -1
- package/src/command-specs/skills.js +314 -9
- package/src/command-specs/smoke.js +16 -4
- package/src/command-specs/spaces.js +783 -0
- package/src/command-specs/tools.js +118 -21
- package/src/command-specs/version-history.js +20 -0
- package/src/command-specs/views.js +69 -0
- package/src/runtime/agent-browser.js +178 -267
- package/src/runtime/agent-setup.js +172 -13
- package/src/runtime/app-boundary-validator.js +338 -2
- package/src/runtime/app-data-literals.js +82 -0
- package/src/runtime/app-platform.js +750 -1570
- package/src/runtime/auth-recovery.js +17 -0
- package/src/runtime/base-skills.d.ts +11 -0
- package/src/runtime/base-skills.js +100 -3
- package/src/runtime/computer-identity.d.ts +30 -0
- package/src/runtime/computer-identity.js +151 -0
- package/src/runtime/config-lock.js +135 -0
- package/src/runtime/confined-space-build.js +74 -0
- package/src/runtime/confined-space-verify.js +49 -0
- package/src/runtime/delegated-context.js +5 -21
- package/src/runtime/native-record-files.js +47 -0
- package/src/runtime/oauth.js +342 -31
- package/src/runtime/profiles.js +19 -112
- package/src/runtime/retired-sdk-files.js +24 -0
- package/src/runtime/retry.js +150 -0
- package/src/runtime/skill-sync/cloud-client.ts +33 -8
- package/src/runtime/skill-sync/index.ts +443 -106
- package/src/runtime/skill-sync/local-scanner.ts +248 -32
- package/src/runtime/skill-sync/native-identity.ts +102 -0
- package/src/runtime/skill-sync/native-plan.ts +70 -0
- package/src/runtime/skill-sync/symlink-manager.ts +66 -16
- package/src/runtime/skill-sync/sync-plan.ts +34 -2
- package/src/runtime/skill-sync/types.ts +54 -0
- package/src/runtime/skill-sync/write-cloud-skill.ts +4 -4
- package/src/runtime/skill-sync-service.js +109 -0
- package/src/runtime/source-root.js +32 -0
- package/src/runtime/space-collection-scaffold.js +107 -0
- package/src/runtime/space-database-structures.js +229 -0
- package/src/runtime/space-fixture-broker.js +68 -0
- package/src/runtime/space-lock.js +337 -0
- package/src/runtime/space-platform.js +133 -0
- package/src/runtime/space-source.js +290 -0
- package/src/runtime/space-test-server.js +354 -0
- package/src/runtime/space-verification.js +94 -0
- package/src/runtime/space-verify-browser-worker.js +51 -0
- package/src/runtime/space-view-manifest.js +368 -0
- package/src/runtime/sync-skills.d.ts +5 -1
- package/src/runtime/sync-skills.js +11 -2
- package/src/runtime/tool-confirmation.js +138 -0
- package/src/runtime/transport.js +118 -24
- package/src/runtime/upsell.js +219 -0
- package/src/runtime/view-render.js +49 -0
- package/src/skill-sync-worker-entry.js +2 -0
- package/src/skill-sync-worker.js +114 -0
- package/src/space-harness-host.ts +122 -0
- package/template/.harness/index.html.tmpl +9 -352
- package/template/app/globals.css +29 -3
- package/template/app/layout.tsx +2 -6
- package/template/components/ui/badge.tsx +7 -4
- package/template/components/ui/button.tsx +1 -1
- package/template/components/ui/native-select.tsx +24 -0
- package/template/notis.config.ts +4 -36
- package/template/package-lock.json +1800 -1867
- package/template/package.json +16 -14
- package/template/postcss.config.mjs +1 -1
- package/template/tailwind.config.ts +1 -6
- package/template/tsconfig.json +1 -0
- package/template/vite.config.ts +2 -2
- package/dist/base-skills/notis-apps/SKILL.md +0 -613
- package/skills/notis-apps/cli.md +0 -285
- package/src/command-specs/apps.js +0 -3011
- package/src/runtime/app-changelog.js +0 -79
- package/src/runtime/app-dev-consumers.js +0 -154
- package/src/runtime/app-dev-host-lock.js +0 -80
- package/src/runtime/app-dev-process-identity.js +0 -111
- package/src/runtime/app-dev-roots.js +0 -284
- package/src/runtime/app-dev-server.js +0 -1140
- package/src/runtime/app-dev-sessions.js +0 -185
- package/src/runtime/app-registry-scaffolds.js +0 -367
- package/src/runtime/assets/store-screenshot-dark.png +0 -0
- package/src/runtime/cli-mode.generated.js +0 -5
- package/src/runtime/cli-mode.js +0 -34
- package/src/runtime/store-screenshot.js +0 -143
- package/template/CHANGELOG.md +0 -5
- package/template/app/page.tsx +0 -60
- package/template/components/ui/card.tsx +0 -56
- package/template/packages/sdk/src/components/DocumentEditor.tsx +0 -103
- package/template/packages/sdk/src/config.ts +0 -259
- package/template/packages/sdk/src/hooks/useCloudComputer.ts +0 -97
- package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +0 -85
- package/template/packages/sdk/src/hooks/useDocument.ts +0 -78
- package/template/packages/sdk/src/hooks/useNotisNavigation.ts +0 -52
- package/template/packages/sdk/src/hooks/useTopBarSearch.ts +0 -73
- package/template/packages/sdk/src/runtime.ts +0 -439
- package/template/packages/sdk/src/vite.ts +0 -56
- package/template/packages/sdk/tsconfig.json +0 -15
- /package/{template/packages → dist}/sdk/src/components/MultiSelectDragOverlay.tsx +0 -0
- /package/{template/packages → dist}/sdk/src/components/ShortcutHints.tsx +0 -0
- /package/{template/packages → dist}/sdk/src/hooks/useActiveResource.ts +0 -0
- /package/{template/packages → dist}/sdk/src/hooks/useTool.ts +0 -0
- /package/{template/packages → dist}/sdk/src/hooks/useTools.ts +0 -0
- /package/{template/packages → dist}/sdk/src/ui.ts +0 -0
|
@@ -1,45 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* App platform utilities for the Notis CLI.
|
|
3
3
|
*
|
|
4
|
-
* Handles project scaffolding, validation, building, and
|
|
5
|
-
* CLI-side counterpart to @notis/sdk -- it reads notis.config.ts,
|
|
6
|
-
* Vite build, and packages the bundle for deployment.
|
|
4
|
+
* Handles project scaffolding, validation, building, and Space source pulls.
|
|
5
|
+
* This is the CLI-side counterpart to @notis/sdk -- it reads notis.config.ts,
|
|
6
|
+
* runs the Vite build, and packages the bundle for deployment.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { spawn } from 'node:child_process';
|
|
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';
|
|
9
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
10
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
11
|
+
import { closeSync, constants as fsConstants, copyFileSync, cpSync, existsSync, fstatSync, linkSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
12
12
|
import { createRequire } from 'node:module';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
|
-
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
14
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
16
|
import { gunzipSync } from 'node:zlib';
|
|
17
17
|
|
|
18
18
|
import { usageError } from './errors.js';
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
19
|
+
import { validateArtifactBoundary } from './app-boundary-validator.js';
|
|
20
|
+
import { HOST_ONLY_SDK_FILES, RETIRED_SDK_FILES } from './retired-sdk-files.js';
|
|
21
|
+
import { assertSourceRoot } from './source-root.js';
|
|
22
22
|
|
|
23
23
|
const NOTIS_DIR = '.notis';
|
|
24
|
-
const
|
|
24
|
+
const SPACE_SOURCE_FILE = join(NOTIS_DIR, 'space-source.json');
|
|
25
25
|
const OUTPUT_DIR = join(NOTIS_DIR, 'output');
|
|
26
26
|
const BUNDLE_DIR = join(OUTPUT_DIR, 'bundle');
|
|
27
27
|
const MANIFEST_FILE = join(OUTPUT_DIR, 'manifest.json');
|
|
28
|
-
const METADATA_DIR = 'metadata';
|
|
29
28
|
const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
30
|
-
const TEMPLATE_SDK_DIR = join(CLI_ROOT, '
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
'Operations',
|
|
35
|
-
'Product & Engineering',
|
|
36
|
-
'Personal',
|
|
37
|
-
];
|
|
38
|
-
export const MIN_LISTING_SCREENSHOTS = 3;
|
|
29
|
+
const TEMPLATE_SDK_DIR = join(CLI_ROOT, 'dist', 'sdk');
|
|
30
|
+
// Spaces install only types and build-time entries; the host provides the SDK
|
|
31
|
+
// at runtime (scripts/build-space-sdk.js).
|
|
32
|
+
const SPACE_SDK_DIR = join(CLI_ROOT, 'dist', 'space-sdk');
|
|
39
33
|
const SOURCE_COPY_EXCLUDES = new Set([
|
|
40
34
|
'node_modules',
|
|
41
35
|
'.notis',
|
|
42
36
|
'.git',
|
|
37
|
+
'.context',
|
|
43
38
|
'dist',
|
|
44
39
|
'tsconfig.tsbuildinfo',
|
|
45
40
|
'.DS_Store',
|
|
@@ -55,45 +50,17 @@ const SCAFFOLD_COPY_EXCLUDES = new Set([
|
|
|
55
50
|
'coverage',
|
|
56
51
|
'.next',
|
|
57
52
|
'.turbo',
|
|
53
|
+
// The CLI reads its own template/.harness theme; projects never use a copy.
|
|
54
|
+
'.harness',
|
|
58
55
|
]);
|
|
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
56
|
const PULL_LOCK_TIMEOUT_MS = 30_000;
|
|
66
57
|
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;
|
|
75
58
|
let appConfigImportNonce = 0;
|
|
76
59
|
|
|
77
60
|
// ---------------------------------------------------------------------------
|
|
78
61
|
// Project directory resolution
|
|
79
62
|
// ---------------------------------------------------------------------------
|
|
80
63
|
|
|
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
64
|
// A `~/...` path only expands when a shell is involved. Agents routinely spawn
|
|
98
65
|
// the CLI without one, and the documented default home is written with a tilde,
|
|
99
66
|
// so expand it here instead of creating a literal "~" directory.
|
|
@@ -124,17 +91,72 @@ export function resolveBuiltBundleDir(projectDir) {
|
|
|
124
91
|
return null;
|
|
125
92
|
}
|
|
126
93
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
94
|
+
/**
|
|
95
|
+
* Same-length copy of `css` in which comments become spaces and quoted strings
|
|
96
|
+
* and attribute selectors become word characters, so selector matching never
|
|
97
|
+
* sees text inside them and a word written there still blocks a match after it.
|
|
98
|
+
*/
|
|
99
|
+
function maskCssForSelectorMatching(css) {
|
|
100
|
+
let masked = '';
|
|
101
|
+
let index = 0;
|
|
102
|
+
const skipString = (from) => {
|
|
103
|
+
const quote = css[from];
|
|
104
|
+
let end = from + 1;
|
|
105
|
+
while (end < css.length && css[end] !== quote && css[end] !== '\n') end += css[end] === '\\' ? 2 : 1;
|
|
106
|
+
return Math.min(end + 1, css.length);
|
|
107
|
+
};
|
|
108
|
+
while (index < css.length) {
|
|
109
|
+
const char = css[index];
|
|
110
|
+
if (char === '\\') {
|
|
111
|
+
// An escaped character (`.w-\\[100px\\]`) is part of a name, never a string or attribute opener.
|
|
112
|
+
masked += css.slice(index, index + 2);
|
|
113
|
+
index += 2;
|
|
114
|
+
} else if (char === '/' && css[index + 1] === '*') {
|
|
115
|
+
const close = css.indexOf('*/', index + 2);
|
|
116
|
+
const end = close === -1 ? css.length : close + 2;
|
|
117
|
+
masked += css.slice(index, end).replace(/[^\n]/g, ' ');
|
|
118
|
+
index = end;
|
|
119
|
+
} else if (char === '"' || char === "'") {
|
|
120
|
+
const end = skipString(index);
|
|
121
|
+
masked += 'x'.repeat(end - index);
|
|
122
|
+
index = end;
|
|
123
|
+
} else if (char === '[') {
|
|
124
|
+
let end = index + 1;
|
|
125
|
+
while (end < css.length && css[end] !== ']') {
|
|
126
|
+
end = css[end] === '"' || css[end] === "'" ? skipString(end) : end + (css[end] === '\\' ? 2 : 1);
|
|
127
|
+
}
|
|
128
|
+
end = Math.min(end + 1, css.length);
|
|
129
|
+
masked += 'x'.repeat(end - index);
|
|
130
|
+
index = end;
|
|
131
|
+
} else {
|
|
132
|
+
masked += char;
|
|
133
|
+
index += 1;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return masked;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Standalone `html`, `:root` and `body` type selectors right before a block.
|
|
140
|
+
// A name inside a class, id, custom property or escaped utility (`.sl-kind-html`,
|
|
141
|
+
// `#body`, `--html`, `.md\:root`) never matches, nor does text in a comment,
|
|
142
|
+
// string or attribute selector.
|
|
143
|
+
const SHADOW_GLOBAL_SELECTOR = /(?:(?<![\w\-.#:\\])html|(?<![\w\-\\]):root)\s*(?:,\s*:host\s*)?\{|(?<![\w\-.#:\\])body\s*\{/g;
|
|
144
|
+
|
|
145
|
+
export function normalizeShadowScopedCss(css) {
|
|
146
|
+
const masked = maskCssForSelectorMatching(css);
|
|
147
|
+
let output = '';
|
|
148
|
+
let last = 0;
|
|
149
|
+
for (const match of masked.matchAll(SHADOW_GLOBAL_SELECTOR)) {
|
|
150
|
+
output += css.slice(last, match.index);
|
|
151
|
+
// Keep defaults on both the Portal shadow host and the app root used
|
|
152
|
+
// by the standalone harness. Removing :root erases Tailwind v4 theme
|
|
153
|
+
// variables in previews, collapsing spacing, type sizes and controls.
|
|
135
154
|
// Shadow trees do not contain a body element. Route those defaults to the
|
|
136
155
|
// app root contract instead so authors still get the expected reset.
|
|
137
|
-
.
|
|
156
|
+
output += match[0].startsWith('body') ? '[data-notis-app-root]{' : '[data-notis-app-root],:host{';
|
|
157
|
+
last = match.index + match[0].length;
|
|
158
|
+
}
|
|
159
|
+
return output + css.slice(last);
|
|
138
160
|
}
|
|
139
161
|
|
|
140
162
|
function normalizeBundleStylesheets(projectDir) {
|
|
@@ -165,35 +187,38 @@ function normalizeBundleStylesheets(projectDir) {
|
|
|
165
187
|
function stripNotisSdkImports(source) {
|
|
166
188
|
return source
|
|
167
189
|
.replace(/import\s+type\s+[\s\S]*?from\s+['"][^'"]*['"]\s*;?/g, '')
|
|
168
|
-
.replace(/import\s*{[
|
|
169
|
-
|
|
190
|
+
.replace(/import\s*{([^}]+)}\s+from\s+['"]@notis\/sdk\/config['"]\s*;?/g, (_statement, names) => {
|
|
191
|
+
const declarations = [];
|
|
192
|
+
for (const imported of names.split(',').map(value => value.trim()).filter(Boolean)) {
|
|
193
|
+
if (imported.startsWith('type ')) continue;
|
|
194
|
+
const match = imported.match(/^(defineNotisApp|defineSpace|defineSpaces)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
|
|
195
|
+
if (!match) throw usageError(`Unsupported configuration helper: ${imported}`);
|
|
196
|
+
declarations.push(`const ${match[2] || match[1]} = (value) => value;`);
|
|
197
|
+
}
|
|
198
|
+
return declarations.join('\n');
|
|
199
|
+
});
|
|
170
200
|
}
|
|
171
201
|
|
|
172
202
|
function transpileTsConfigSource(source, configPath) {
|
|
173
203
|
// Strip the @notis/sdk/config import unconditionally. The SDK package often
|
|
174
204
|
// points its `exports` at raw .ts source, which Node cannot import from the
|
|
175
|
-
// temp .mjs file we emit below.
|
|
176
|
-
// so
|
|
177
|
-
//
|
|
205
|
+
// temp .mjs file we emit below. The config helpers are identity functions,
|
|
206
|
+
// so replacing the import with local identity functions preserves the
|
|
207
|
+
// config value without ever resolving the SDK.
|
|
178
208
|
const stripped = stripNotisSdkImports(source);
|
|
179
|
-
const requireFromConfig = createRequire(`file://${configPath}`);
|
|
180
209
|
try {
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
target: ts.ScriptTarget.ES2020,
|
|
186
|
-
},
|
|
187
|
-
fileName: configPath,
|
|
188
|
-
});
|
|
189
|
-
return transpiled.outputText;
|
|
190
|
-
} catch {
|
|
191
|
-
return stripped;
|
|
210
|
+
const { transformSync } = createRequire(import.meta.url)('esbuild');
|
|
211
|
+
return transformSync(stripped, { loader: 'ts', format: 'esm', target: 'es2020', sourcefile: configPath }).code;
|
|
212
|
+
} catch (error) {
|
|
213
|
+
throw usageError(`Failed to parse ${configPath}: ${error.message}`);
|
|
192
214
|
}
|
|
193
215
|
}
|
|
194
216
|
|
|
195
|
-
export async function loadAppConfig(projectDir) {
|
|
196
|
-
const configPaths = ['notis.config.ts', 'notis.config.js', 'notis.config.mjs'];
|
|
217
|
+
export async function loadAppConfig(projectDir, { file = null } = {}) {
|
|
218
|
+
const configPaths = file ? [file] : ['notis.config.ts', 'notis.config.js', 'notis.config.mjs'];
|
|
219
|
+
if (file && (!Object.hasOwn(collectSourceFiles(projectDir), file) || !/\.(?:ts|js|mjs)$/.test(file))) {
|
|
220
|
+
throw usageError('Choose an existing configuration file inside this source project.');
|
|
221
|
+
}
|
|
197
222
|
let configPath = null;
|
|
198
223
|
|
|
199
224
|
for (const name of configPaths) {
|
|
@@ -208,12 +233,13 @@ export async function loadAppConfig(projectDir) {
|
|
|
208
233
|
throw usageError('No notis.config.ts found in project directory.');
|
|
209
234
|
}
|
|
210
235
|
|
|
211
|
-
//
|
|
212
|
-
|
|
236
|
+
// Identity config helpers work for every extension without asking Node to
|
|
237
|
+
// execute raw TypeScript from an embedded SDK package.
|
|
238
|
+
{
|
|
213
239
|
const source = readFileSync(configPath, 'utf-8');
|
|
214
|
-
const jsSource = transpileTsConfigSource(source, configPath);
|
|
240
|
+
const jsSource = configPath.endsWith('.ts') ? transpileTsConfigSource(source, configPath) : stripNotisSdkImports(source);
|
|
215
241
|
|
|
216
|
-
const tmpPath = join(dirname(configPath),
|
|
242
|
+
const tmpPath = join(dirname(configPath), `._notis_config_${randomUUID()}.mjs`);
|
|
217
243
|
mkdirSync(dirname(tmpPath), { recursive: true });
|
|
218
244
|
writeFileSync(tmpPath, jsSource);
|
|
219
245
|
try {
|
|
@@ -235,347 +261,15 @@ export async function loadAppConfig(projectDir) {
|
|
|
235
261
|
}
|
|
236
262
|
}
|
|
237
263
|
|
|
238
|
-
const mod = await import(`file://${configPath}`);
|
|
239
|
-
return mod.default || mod;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// ---------------------------------------------------------------------------
|
|
243
|
-
// Project validation
|
|
244
|
-
// ---------------------------------------------------------------------------
|
|
245
|
-
|
|
246
|
-
export function detectProjectProblems(projectDir) {
|
|
247
|
-
const problems = [];
|
|
248
|
-
|
|
249
|
-
if (!existsSync(join(projectDir, 'package.json'))) {
|
|
250
|
-
problems.push('Missing package.json');
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const hasViteConfig = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']
|
|
254
|
-
.some((name) => existsSync(join(projectDir, name)));
|
|
255
|
-
if (!hasViteConfig) {
|
|
256
|
-
problems.push('Missing vite.config file');
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
if (!existsSync(join(projectDir, 'app'))) {
|
|
260
|
-
problems.push('Missing app/ directory');
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const hasNotisConfig = ['notis.config.ts', 'notis.config.js', 'notis.config.mjs']
|
|
264
|
-
.some((name) => existsSync(join(projectDir, name)));
|
|
265
|
-
if (!hasNotisConfig) {
|
|
266
|
-
problems.push('Missing notis.config.ts');
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
return problems;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
export function detectProjectWarnings(projectDir, appConfig = null) {
|
|
273
|
-
const warnings = [];
|
|
274
|
-
|
|
275
|
-
if (!existsSync(join(projectDir, 'components.json'))) {
|
|
276
|
-
warnings.push('Missing components.json -- shadcn is not configured.');
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const hasTailwind = ['tailwind.config.ts', 'tailwind.config.js', 'tailwind.config.cjs']
|
|
280
|
-
.some((name) => existsSync(join(projectDir, name)));
|
|
281
|
-
if (!hasTailwind) {
|
|
282
|
-
warnings.push('Missing Tailwind config.');
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
const readsWorkspaceDatabases =
|
|
286
|
-
appConfig?.capabilities?.workspaceDatabases === 'read';
|
|
287
|
-
if (
|
|
288
|
-
appConfig
|
|
289
|
-
&& !readsWorkspaceDatabases
|
|
290
|
-
&& (!Array.isArray(appConfig.databases) || appConfig.databases.length === 0)
|
|
291
|
-
) {
|
|
292
|
-
warnings.push('No database references declared in notis.config.ts.');
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
if (appConfig) {
|
|
296
|
-
try {
|
|
297
|
-
const listing = inspectListingReadiness(projectDir, appConfig);
|
|
298
|
-
warnings.push(...listing.errors, ...listing.warnings);
|
|
299
|
-
} catch (error) {
|
|
300
|
-
warnings.push(error instanceof Error ? error.message : String(error));
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
return warnings;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
function safeKebab(value) {
|
|
308
|
-
return String(value || '')
|
|
309
|
-
.trim()
|
|
310
|
-
.toLowerCase()
|
|
311
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
312
|
-
.replace(/(^-|-$)+/g, '');
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
function jsStringLiteral(value) {
|
|
316
|
-
return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
function normalizeCategories(categories = []) {
|
|
320
|
-
if (!Array.isArray(categories)) {
|
|
321
|
-
return [];
|
|
322
|
-
}
|
|
323
|
-
const allowed = new Set(NOTIS_APP_CATEGORIES);
|
|
324
|
-
const normalized = [];
|
|
325
|
-
for (const category of categories) {
|
|
326
|
-
if (typeof category !== 'string' || !category.trim()) {
|
|
327
|
-
continue;
|
|
328
|
-
}
|
|
329
|
-
const trimmed = category.trim();
|
|
330
|
-
if (!allowed.has(trimmed)) {
|
|
331
|
-
throw usageError(
|
|
332
|
-
`Invalid Notis app category "${trimmed}". Use one of: ${NOTIS_APP_CATEGORIES.join(', ')}.`,
|
|
333
|
-
);
|
|
334
|
-
}
|
|
335
|
-
if (!normalized.includes(trimmed)) {
|
|
336
|
-
normalized.push(trimmed);
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
return normalized;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
function pngDimensions(buffer) {
|
|
343
|
-
const signature = '89504e470d0a1a0a';
|
|
344
|
-
if (!Buffer.isBuffer(buffer) || buffer.length < 24 || buffer.subarray(0, 8).toString('hex') !== signature) {
|
|
345
|
-
return null;
|
|
346
|
-
}
|
|
347
|
-
return {
|
|
348
|
-
width: buffer.readUInt32BE(16),
|
|
349
|
-
height: buffer.readUInt32BE(20),
|
|
350
|
-
};
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function imageAssetMeta(projectDir, relPath, { maxBytes }) {
|
|
354
|
-
const fullPath = join(projectDir, relPath);
|
|
355
|
-
const content = readFileSync(fullPath);
|
|
356
|
-
const dimensions = pngDimensions(content);
|
|
357
|
-
const errors = [];
|
|
358
|
-
const warnings = [];
|
|
359
|
-
|
|
360
|
-
if (!relPath.toLowerCase().endsWith('.png')) {
|
|
361
|
-
errors.push(`${relPath} must be a PNG file.`);
|
|
362
|
-
}
|
|
363
|
-
if (content.length > maxBytes) {
|
|
364
|
-
errors.push(`${relPath} must be ${Math.round(maxBytes / 1024 / 1024)} MB or smaller.`);
|
|
365
|
-
}
|
|
366
|
-
if (!dimensions) {
|
|
367
|
-
errors.push(`${relPath} is not a valid PNG file.`);
|
|
368
|
-
} else if (dimensions.width !== 2000 || dimensions.height !== 1250) {
|
|
369
|
-
errors.push(`${relPath} must be exactly 2000x1250 pixels.`);
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
return {
|
|
373
|
-
path: relPath,
|
|
374
|
-
content_type: 'image/png',
|
|
375
|
-
bytes: content.length,
|
|
376
|
-
width: dimensions?.width ?? null,
|
|
377
|
-
height: dimensions?.height ?? null,
|
|
378
|
-
errors,
|
|
379
|
-
warnings,
|
|
380
|
-
};
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
export function discoverMetadataAssets(projectDir) {
|
|
384
|
-
const metadataDir = join(projectDir, METADATA_DIR);
|
|
385
|
-
const screenshots = [];
|
|
386
|
-
|
|
387
|
-
if (existsSync(metadataDir)) {
|
|
388
|
-
for (const entry of readdirSync(metadataDir, { withFileTypes: true })) {
|
|
389
|
-
if (!entry.isFile()) continue;
|
|
390
|
-
const match = /^screenshot-(\d+)\.png$/i.exec(entry.name);
|
|
391
|
-
if (!match) continue;
|
|
392
|
-
screenshots.push({
|
|
393
|
-
index: Number.parseInt(match[1], 10),
|
|
394
|
-
...imageAssetMeta(projectDir, join(METADATA_DIR, entry.name), {
|
|
395
|
-
maxBytes: 2 * 1024 * 1024,
|
|
396
|
-
}),
|
|
397
|
-
});
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
screenshots.sort((a, b) => a.index - b.index);
|
|
402
|
-
return {
|
|
403
|
-
screenshots: screenshots.slice(0, 6),
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function normalizeConfiguredScreenshots(appConfig = null) {
|
|
408
|
-
if (!Array.isArray(appConfig?.screenshots) || appConfig.screenshots.length === 0) {
|
|
409
|
-
return null;
|
|
410
|
-
}
|
|
411
|
-
if (appConfig.screenshots.length > 6) {
|
|
412
|
-
throw usageError('Configure at most six listing screenshots.');
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
const seenPaths = new Set();
|
|
416
|
-
return appConfig.screenshots.map((entry, index) => {
|
|
417
|
-
const path = typeof entry?.path === 'string' ? entry.path.trim().replace(/\\/g, '/') : '';
|
|
418
|
-
const alt = typeof entry?.alt === 'string' ? entry.alt.trim() : '';
|
|
419
|
-
const route = typeof entry?.route === 'string' ? entry.route.trim() : '';
|
|
420
|
-
const scenario = typeof entry?.scenario === 'string' ? entry.scenario.trim() : '';
|
|
421
|
-
const focus = typeof entry?.focus === 'string' ? entry.focus.trim() : '';
|
|
422
|
-
const theme = typeof entry?.theme === 'string' ? entry.theme.trim().toLowerCase() : '';
|
|
423
|
-
if (!/^metadata\/screenshot-\d+\.png$/i.test(path)) {
|
|
424
|
-
throw usageError(`screenshots[${index}].path must match metadata/screenshot-N.png.`);
|
|
425
|
-
}
|
|
426
|
-
const expectedPath = `metadata/screenshot-${index + 1}.png`;
|
|
427
|
-
if (path !== expectedPath) {
|
|
428
|
-
throw usageError(`screenshots[${index}].path must be ${expectedPath} so capture order stays stable.`);
|
|
429
|
-
}
|
|
430
|
-
if (seenPaths.has(path)) {
|
|
431
|
-
throw usageError(`Duplicate listing screenshot path: ${path}`);
|
|
432
|
-
}
|
|
433
|
-
if (theme && theme !== 'light' && theme !== 'dark') {
|
|
434
|
-
throw usageError(`screenshots[${index}].theme must be light or dark.`);
|
|
435
|
-
}
|
|
436
|
-
seenPaths.add(path);
|
|
437
|
-
return {
|
|
438
|
-
path,
|
|
439
|
-
alt,
|
|
440
|
-
route: route || null,
|
|
441
|
-
scenario: scenario || null,
|
|
442
|
-
focus: focus || null,
|
|
443
|
-
theme: theme || 'light',
|
|
444
|
-
};
|
|
445
|
-
});
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
/** Resolve listing screenshot files in their configured editorial order. */
|
|
449
|
-
export function resolveListingScreenshots(projectDir, appConfig = null) {
|
|
450
|
-
const discovered = discoverMetadataAssets(projectDir).screenshots;
|
|
451
|
-
const configured = normalizeConfiguredScreenshots(appConfig);
|
|
452
|
-
if (!configured) {
|
|
453
|
-
return discovered.map((asset) => ({
|
|
454
|
-
...asset,
|
|
455
|
-
alt: null,
|
|
456
|
-
route: null,
|
|
457
|
-
scenario: null,
|
|
458
|
-
focus: null,
|
|
459
|
-
theme: 'light',
|
|
460
|
-
}));
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
const byPath = new Map(discovered.map((asset) => [asset.path.replace(/\\/g, '/'), asset]));
|
|
464
|
-
return configured.map((entry) => {
|
|
465
|
-
const asset = byPath.get(entry.path);
|
|
466
|
-
if (!asset) {
|
|
467
|
-
return {
|
|
468
|
-
...entry,
|
|
469
|
-
content_type: 'image/png',
|
|
470
|
-
bytes: null,
|
|
471
|
-
width: null,
|
|
472
|
-
height: null,
|
|
473
|
-
errors: [`${entry.path} is configured but the file does not exist.`],
|
|
474
|
-
warnings: [],
|
|
475
|
-
};
|
|
476
|
-
}
|
|
477
|
-
return { ...asset, ...entry };
|
|
478
|
-
});
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
/**
|
|
482
|
-
* Screenshot scenarios named in notis.config.ts that metadata/screenshot-fixtures.json
|
|
483
|
-
* does not define. A missing scenario is silent at capture time -- the harness
|
|
484
|
-
* simply falls back to the default fixtures -- so it is reported as a warning.
|
|
485
|
-
*/
|
|
486
|
-
export function findUnknownScreenshotScenarios(projectDir, screenshots = []) {
|
|
487
|
-
const named = screenshots.filter((entry) => entry?.scenario);
|
|
488
|
-
if (named.length === 0) {
|
|
489
|
-
return [];
|
|
490
|
-
}
|
|
491
|
-
const fixturesPath = join(projectDir, METADATA_DIR, 'screenshot-fixtures.json');
|
|
492
|
-
if (!existsSync(fixturesPath)) {
|
|
493
|
-
return ['metadata/screenshot-fixtures.json is missing, so screenshot scenarios cannot be applied.'];
|
|
494
|
-
}
|
|
495
|
-
let defined;
|
|
496
|
-
try {
|
|
497
|
-
const parsed = JSON.parse(readFileSync(fixturesPath, 'utf-8'));
|
|
498
|
-
const scenarios = parsed?.scenarios && typeof parsed.scenarios === 'object' ? parsed.scenarios : {};
|
|
499
|
-
defined = new Set(Object.keys(scenarios));
|
|
500
|
-
} catch {
|
|
501
|
-
return ['metadata/screenshot-fixtures.json is not valid JSON, so screenshot scenarios cannot be applied.'];
|
|
502
|
-
}
|
|
503
|
-
const warnings = [];
|
|
504
|
-
for (const entry of named) {
|
|
505
|
-
if (!defined.has(entry.scenario)) {
|
|
506
|
-
warnings.push(
|
|
507
|
-
`${entry.path} names scenario "${entry.scenario}", which metadata/screenshot-fixtures.json does not define.`,
|
|
508
|
-
);
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
return warnings;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
export function inspectListingReadiness(projectDir, appConfig = null) {
|
|
515
|
-
const config = appConfig || {};
|
|
516
|
-
const warnings = [];
|
|
517
|
-
const errors = [];
|
|
518
|
-
const screenshots = resolveListingScreenshots(projectDir, config);
|
|
519
|
-
const metadata = { screenshots };
|
|
520
|
-
const categories = normalizeCategories(config.categories || []);
|
|
521
|
-
const changelog = readAppChangelog(projectDir);
|
|
522
|
-
errors.push(...changelog.errors);
|
|
523
|
-
|
|
524
|
-
const packagePath = join(projectDir, 'package.json');
|
|
525
|
-
if (!existsSync(packagePath)) {
|
|
526
|
-
errors.push('package.json is required for Store publication.');
|
|
527
|
-
} else {
|
|
528
|
-
try {
|
|
529
|
-
const packageJson = JSON.parse(readFileSync(packagePath, 'utf-8'));
|
|
530
|
-
const notisAppVersion = String(packageJson.notisAppVersion || '').trim();
|
|
531
|
-
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(notisAppVersion)) {
|
|
532
|
-
errors.push('package.json must include a semver `notisAppVersion` (for example, "0.1.0").');
|
|
533
|
-
}
|
|
534
|
-
} catch {
|
|
535
|
-
errors.push('package.json must contain valid JSON for Store publication.');
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
if (!String(config.tagline || '').trim()) {
|
|
540
|
-
errors.push('Listing tagline missing in notis.config.ts.');
|
|
541
|
-
}
|
|
542
|
-
if (categories.length === 0) {
|
|
543
|
-
errors.push(`Listing category missing in notis.config.ts. Use one of: ${NOTIS_APP_CATEGORIES.join(', ')}.`);
|
|
544
|
-
}
|
|
545
|
-
if (metadata.screenshots.length < MIN_LISTING_SCREENSHOTS) {
|
|
546
|
-
errors.push(`Listing screenshots: ${metadata.screenshots.length}/${MIN_LISTING_SCREENSHOTS} minimum in metadata/screenshot-N.png. Run \`notis apps screenshot\` to generate them.`);
|
|
547
|
-
}
|
|
548
|
-
for (const screenshot of metadata.screenshots) {
|
|
549
|
-
errors.push(...screenshot.errors);
|
|
550
|
-
warnings.push(...screenshot.warnings);
|
|
551
|
-
if (!screenshot.alt) {
|
|
552
|
-
errors.push(`${screenshot.path} is missing descriptive alt text in notis.config.ts -> screenshots.`);
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
return {
|
|
557
|
-
ready:
|
|
558
|
-
errors.length === 0 &&
|
|
559
|
-
Boolean(String(config.tagline || '').trim()) &&
|
|
560
|
-
categories.length > 0 &&
|
|
561
|
-
metadata.screenshots.length >= MIN_LISTING_SCREENSHOTS &&
|
|
562
|
-
metadata.screenshots.every((screenshot) => Boolean(screenshot.alt)) &&
|
|
563
|
-
changelog.entries.length > 0,
|
|
564
|
-
warnings,
|
|
565
|
-
errors,
|
|
566
|
-
metadata,
|
|
567
|
-
categories,
|
|
568
|
-
changelog,
|
|
569
|
-
};
|
|
570
264
|
}
|
|
571
265
|
|
|
572
266
|
// ---------------------------------------------------------------------------
|
|
573
267
|
// Build
|
|
574
268
|
// ---------------------------------------------------------------------------
|
|
575
269
|
|
|
576
|
-
export async function runProjectScript({ projectDir, scriptName, env = {}, stdio = 'inherit' }) {
|
|
270
|
+
export async function runProjectScript({ projectDir, scriptName, args = [], env = {}, stdio = 'inherit' }) {
|
|
577
271
|
await new Promise((resolvePromise, rejectPromise) => {
|
|
578
|
-
const child = spawn('npm', ['run', scriptName], {
|
|
272
|
+
const child = spawn('npm', ['run', scriptName, ...(args.length ? ['--', ...args] : [])], {
|
|
579
273
|
cwd: projectDir,
|
|
580
274
|
stdio,
|
|
581
275
|
env: { ...process.env, ...env },
|
|
@@ -603,749 +297,78 @@ export async function runProjectScript({ projectDir, scriptName, env = {}, stdio
|
|
|
603
297
|
}
|
|
604
298
|
|
|
605
299
|
/**
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
|
|
609
|
-
export function exportNameFromPath(routePath) {
|
|
610
|
-
if (routePath === '/') return 'index';
|
|
611
|
-
const slug = routePath.replace(/^\//, '').replace(/\//g, '-');
|
|
612
|
-
const identifier = slug
|
|
613
|
-
.split(/[^A-Za-z0-9_$]+/)
|
|
614
|
-
.filter(Boolean)
|
|
615
|
-
.map((part, index) => {
|
|
616
|
-
const lower = part.toLowerCase();
|
|
617
|
-
if (index === 0) return lower;
|
|
618
|
-
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
|
619
|
-
})
|
|
620
|
-
.join('');
|
|
621
|
-
if (!identifier) return 'route';
|
|
622
|
-
return /^[A-Za-z_$]/.test(identifier) ? identifier : `r${identifier}`;
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
function slugFromPath(routePath) {
|
|
626
|
-
if (routePath === '/') return 'index';
|
|
627
|
-
return routePath.replace(/^\//, '').replace(/\//g, '-');
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
/**
|
|
631
|
-
* Auto-detect routes from the app/ directory by scanning for page.tsx files.
|
|
632
|
-
*/
|
|
633
|
-
function autoDetectRoutes(projectDir) {
|
|
634
|
-
const appDir = join(projectDir, 'app');
|
|
635
|
-
const routes = [];
|
|
636
|
-
|
|
637
|
-
function scan(dir, pathPrefix) {
|
|
638
|
-
if (!existsSync(dir)) return;
|
|
639
|
-
const entries = readdirSync(dir, { withFileTypes: true });
|
|
640
|
-
|
|
641
|
-
for (const entry of entries) {
|
|
642
|
-
if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
|
|
643
|
-
|
|
644
|
-
if (entry.isDirectory()) {
|
|
645
|
-
scan(join(dir, entry.name), `${pathPrefix}/${entry.name}`);
|
|
646
|
-
} else if (entry.name === 'page.tsx' || entry.name === 'page.jsx' || entry.name === 'page.js') {
|
|
647
|
-
const routePath = pathPrefix || '/';
|
|
648
|
-
const slug = slugFromPath(routePath);
|
|
649
|
-
routes.push({
|
|
650
|
-
path: routePath,
|
|
651
|
-
slug,
|
|
652
|
-
name: slug === 'index' ? 'Home' : slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
653
|
-
default: routePath === '/',
|
|
654
|
-
export_name: exportNameFromPath(routePath),
|
|
655
|
-
});
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
scan(appDir, '');
|
|
661
|
-
return routes;
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
function validateConfiguredRoutes(routes) {
|
|
665
|
-
if (routes.length === 0) {
|
|
666
|
-
return;
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
const slugSet = new Set();
|
|
670
|
-
let defaultCount = 0;
|
|
671
|
-
|
|
672
|
-
for (const route of routes) {
|
|
673
|
-
if (!route.slug || typeof route.slug !== 'string') {
|
|
674
|
-
throw usageError(`Route "${route.path}" must define a slug.`);
|
|
675
|
-
}
|
|
676
|
-
if (slugSet.has(route.slug)) {
|
|
677
|
-
throw usageError(`Duplicate route slug "${route.slug}".`);
|
|
678
|
-
}
|
|
679
|
-
slugSet.add(route.slug);
|
|
680
|
-
if (route.default) {
|
|
681
|
-
defaultCount += 1;
|
|
682
|
-
}
|
|
683
|
-
if (route.resourceDeepLinks !== undefined && typeof route.resourceDeepLinks !== 'boolean') {
|
|
684
|
-
throw usageError(`Route "${route.slug}" resourceDeepLinks must be a boolean.`);
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
if (defaultCount !== 1) {
|
|
689
|
-
throw usageError(`Expected exactly one default route, received ${defaultCount}.`);
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
for (const route of routes) {
|
|
693
|
-
if (route.parentSlug && !slugSet.has(route.parentSlug)) {
|
|
694
|
-
throw usageError(
|
|
695
|
-
`Route "${route.slug}" references unknown parentSlug "${route.parentSlug}".`,
|
|
696
|
-
);
|
|
697
|
-
}
|
|
698
|
-
const collection = route.collection || null;
|
|
699
|
-
if (
|
|
700
|
-
collection?.sidebar?.mode === 'tree' &&
|
|
701
|
-
(!collection.parentProperty || typeof collection.parentProperty !== 'string')
|
|
702
|
-
) {
|
|
703
|
-
throw usageError(
|
|
704
|
-
`Tree collection route "${route.slug}" must define collection.parentProperty.`,
|
|
705
|
-
);
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
const childrenByParent = new Map();
|
|
710
|
-
for (const route of routes) {
|
|
711
|
-
if (!route.parentSlug) {
|
|
712
|
-
continue;
|
|
713
|
-
}
|
|
714
|
-
const children = childrenByParent.get(route.parentSlug) || [];
|
|
715
|
-
children.push(route.slug);
|
|
716
|
-
childrenByParent.set(route.parentSlug, children);
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
for (const route of routes) {
|
|
720
|
-
const seen = new Set([route.slug]);
|
|
721
|
-
let current = route.parentSlug || null;
|
|
722
|
-
while (current) {
|
|
723
|
-
if (seen.has(current)) {
|
|
724
|
-
throw usageError(`Route parent cycle detected at "${route.slug}".`);
|
|
725
|
-
}
|
|
726
|
-
seen.add(current);
|
|
727
|
-
current = routes.find((candidate) => candidate.slug === current)?.parentSlug || null;
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
for (const route of routes) {
|
|
732
|
-
if (
|
|
733
|
-
route.collection?.sidebar?.mode === 'tree' &&
|
|
734
|
-
(childrenByParent.get(route.slug) || []).length > 0
|
|
735
|
-
) {
|
|
736
|
-
throw usageError(
|
|
737
|
-
`Tree collection route "${route.slug}" cannot also define static child routes.`,
|
|
738
|
-
);
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
function resolveConfiguredRoutes(appConfig, projectDir) {
|
|
744
|
-
const configuredRoutes = Array.isArray(appConfig.routes) ? appConfig.routes : [];
|
|
745
|
-
validateConfiguredRoutes(configuredRoutes);
|
|
746
|
-
const routes = configuredRoutes.map((route) => ({
|
|
747
|
-
...route,
|
|
748
|
-
slug: route.slug,
|
|
749
|
-
export_name: route.exportName || route.export_name || exportNameFromPath(route.path),
|
|
750
|
-
}));
|
|
751
|
-
return routes.length > 0 ? routes : autoDetectRoutes(projectDir);
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
/**
|
|
755
|
-
* Generate the _entry.tsx file that re-exports each route's page component.
|
|
756
|
-
*/
|
|
757
|
-
function generateEntryFile(projectDir, routes) {
|
|
758
|
-
const entryDir = join(projectDir, NOTIS_DIR);
|
|
759
|
-
mkdirSync(entryDir, { recursive: true });
|
|
760
|
-
|
|
761
|
-
// Also re-export the layout if it exists
|
|
762
|
-
const lines = [];
|
|
763
|
-
const layoutPath = join(projectDir, 'app', 'layout.tsx');
|
|
764
|
-
if (existsSync(layoutPath)) {
|
|
765
|
-
lines.push(`export { default as __AppShell } from '../app/layout';`);
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
for (const route of routes) {
|
|
769
|
-
const pagePath = route.path === '/' ? '../app/page' : `../app${route.path}/page`;
|
|
770
|
-
lines.push(`export { default as ${route.export_name} } from '${pagePath}';`);
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
const entryPath = join(entryDir, '_entry.tsx');
|
|
774
|
-
writeFileSync(entryPath, lines.join('\n') + '\n');
|
|
775
|
-
return entryPath;
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
export async function prepareArtifactBuild(projectDir) {
|
|
779
|
-
validateProjectBoundary(projectDir);
|
|
780
|
-
const appConfig = await loadAppConfig(projectDir);
|
|
781
|
-
const detectedRoutes = resolveConfiguredRoutes(appConfig, projectDir);
|
|
782
|
-
|
|
783
|
-
generateEntryFile(projectDir, detectedRoutes);
|
|
784
|
-
|
|
785
|
-
const manifest = generateManifest(appConfig, projectDir);
|
|
786
|
-
const manifestPath = join(projectDir, MANIFEST_FILE);
|
|
787
|
-
mkdirSync(dirname(manifestPath), { recursive: true });
|
|
788
|
-
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
789
|
-
|
|
790
|
-
return { appConfig, manifest, routes: detectedRoutes };
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
/**
|
|
794
|
-
* Generate the manifest from app config and build output.
|
|
795
|
-
*/
|
|
796
|
-
export function generateManifest(appConfig, projectDir) {
|
|
797
|
-
const routes = resolveConfiguredRoutes(appConfig, projectDir).map((route) => {
|
|
798
|
-
const entry = {
|
|
799
|
-
path: route.path,
|
|
800
|
-
slug: route.slug,
|
|
801
|
-
name: route.name,
|
|
802
|
-
icon: route.icon || null,
|
|
803
|
-
parentSlug: route.parentSlug || null,
|
|
804
|
-
default: route.default || false,
|
|
805
|
-
resourceDeepLinks: route.resourceDeepLinks === true,
|
|
806
|
-
export_name: route.exportName || route.export_name || exportNameFromPath(route.path),
|
|
807
|
-
collection: route.collection || null,
|
|
808
|
-
};
|
|
809
|
-
if (route.tool_access) {
|
|
810
|
-
entry.tool_access = route.tool_access;
|
|
811
|
-
}
|
|
812
|
-
return entry;
|
|
813
|
-
});
|
|
814
|
-
|
|
815
|
-
// Database entries may be a bare slug (structure only) or an object opting
|
|
816
|
-
// into shipping rows to installers. Normalize to the manifest's snake_case.
|
|
817
|
-
const databases = (appConfig.databases || []).map((entry) => {
|
|
818
|
-
if (typeof entry === 'string') return entry;
|
|
819
|
-
if (!entry || typeof entry !== 'object' || !entry.slug) return entry;
|
|
820
|
-
const seedDocuments = entry.seedDocuments ?? entry.seed_documents;
|
|
821
|
-
return seedDocuments === true ? { slug: entry.slug, seed_documents: true } : entry.slug;
|
|
822
|
-
});
|
|
823
|
-
const categories = normalizeCategories(appConfig.categories || []);
|
|
824
|
-
const metadata = { screenshots: resolveListingScreenshots(projectDir, appConfig) };
|
|
825
|
-
const appSlug = safeKebab(appConfig.name);
|
|
826
|
-
const displayTitle = appConfig.title || appConfig.displayName || appConfig.name;
|
|
827
|
-
const skills = (Array.isArray(appConfig.skills) ? appConfig.skills : []).map((skill) => ({
|
|
828
|
-
key: skill.key,
|
|
829
|
-
path: normalizeAppSkillManifestPath(skill.path),
|
|
830
|
-
name: skill.name,
|
|
831
|
-
description: skill.description || null,
|
|
832
|
-
}));
|
|
833
|
-
const listingMedia = {
|
|
834
|
-
screenshots: metadata.screenshots.map((screenshot) => ({
|
|
835
|
-
path: screenshot.path,
|
|
836
|
-
content_type: screenshot.content_type,
|
|
837
|
-
width: screenshot.width,
|
|
838
|
-
height: screenshot.height,
|
|
839
|
-
bytes: screenshot.bytes,
|
|
840
|
-
alt: screenshot.alt || null,
|
|
841
|
-
theme: screenshot.theme || 'light',
|
|
842
|
-
})),
|
|
843
|
-
};
|
|
844
|
-
const changelog = readAppChangelog(projectDir);
|
|
845
|
-
if (changelog.exists && changelog.errors.length > 0) {
|
|
846
|
-
throw usageError(changelog.errors.join('\n'));
|
|
847
|
-
}
|
|
848
|
-
const latestChangelogEntry = changelog.entries[0] || null;
|
|
849
|
-
const legacyVersionNotes = latestChangelogEntry?.body
|
|
850
|
-
|| appConfig.versionNotes
|
|
851
|
-
|| appConfig.version_notes
|
|
852
|
-
|| null;
|
|
853
|
-
let releaseVersion = null;
|
|
854
|
-
try {
|
|
855
|
-
const packageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'));
|
|
856
|
-
releaseVersion = String(packageJson.notisAppVersion || '').trim() || null;
|
|
857
|
-
} catch {
|
|
858
|
-
// Store readiness reports missing or malformed package metadata. Ordinary
|
|
859
|
-
// development builds remain usable without a Store release version.
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
return {
|
|
863
|
-
version: 1,
|
|
864
|
-
spec_version: 4,
|
|
865
|
-
app: {
|
|
866
|
-
name: displayTitle,
|
|
867
|
-
slug: appSlug || null,
|
|
868
|
-
description: appConfig.description || null,
|
|
869
|
-
icon: appConfig.icon || null,
|
|
870
|
-
accent: appConfig.accent || null,
|
|
871
|
-
title: displayTitle,
|
|
872
|
-
tagline: appConfig.tagline || null,
|
|
873
|
-
categories,
|
|
874
|
-
author: appConfig.author || null,
|
|
875
|
-
release_version: releaseVersion,
|
|
876
|
-
version_notes: legacyVersionNotes,
|
|
877
|
-
},
|
|
878
|
-
listing: {
|
|
879
|
-
title: displayTitle,
|
|
880
|
-
tagline: appConfig.tagline || null,
|
|
881
|
-
categories,
|
|
882
|
-
author: appConfig.author || null,
|
|
883
|
-
version_notes: legacyVersionNotes,
|
|
884
|
-
...(changelog.exists
|
|
885
|
-
? {
|
|
886
|
-
changelog: {
|
|
887
|
-
source_path: changelog.source_path,
|
|
888
|
-
entries: changelog.entries,
|
|
889
|
-
},
|
|
890
|
-
}
|
|
891
|
-
: {}),
|
|
892
|
-
media: listingMedia,
|
|
893
|
-
},
|
|
894
|
-
metadata: listingMedia,
|
|
895
|
-
routes,
|
|
896
|
-
bundle: {
|
|
897
|
-
js: 'bundle/app.js',
|
|
898
|
-
css: 'bundle/app.css',
|
|
899
|
-
},
|
|
900
|
-
databases,
|
|
901
|
-
capabilities: normalizeAppCapabilities(appConfig.capabilities),
|
|
902
|
-
tools: appConfig.tools || [],
|
|
903
|
-
tool_bindings: normalizeAppToolBindings(appConfig.toolBindings),
|
|
904
|
-
skills,
|
|
905
|
-
onboarding: appConfig.onboarding || null,
|
|
906
|
-
};
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
/**
|
|
910
|
-
* Keep the persisted app row's presentation metadata aligned with the bundle
|
|
911
|
-
* manifest. The app slug remains the stable identity; title/name is only the
|
|
912
|
-
* user-facing label.
|
|
913
|
-
*/
|
|
914
|
-
export function appRowFieldsFromManifest(manifest) {
|
|
915
|
-
const app = manifest?.app && typeof manifest.app === 'object' ? manifest.app : {};
|
|
916
|
-
const displayName = typeof app.title === 'string' && app.title.trim()
|
|
917
|
-
? app.title.trim()
|
|
918
|
-
: typeof app.name === 'string' && app.name.trim()
|
|
919
|
-
? app.name.trim()
|
|
920
|
-
: null;
|
|
921
|
-
return {
|
|
922
|
-
...(displayName ? { name: displayName } : {}),
|
|
923
|
-
accent: app.accent ?? null,
|
|
924
|
-
};
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
export function normalizeAppToolBindings(bindings) {
|
|
928
|
-
return (Array.isArray(bindings) ? bindings : [])
|
|
929
|
-
.map((binding) => {
|
|
930
|
-
const name = typeof binding?.name === 'string' ? binding.name.trim() : '';
|
|
931
|
-
const providerToolName =
|
|
932
|
-
typeof binding?.providerToolName === 'string' ? binding.providerToolName.trim() : '';
|
|
933
|
-
return name && providerToolName
|
|
934
|
-
? { name, provider_tool_name: providerToolName }
|
|
935
|
-
: null;
|
|
936
|
-
})
|
|
937
|
-
.filter(Boolean);
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
/**
|
|
941
|
-
* Keeps only capabilities the platform actually understands, at the exact
|
|
942
|
-
* values it accepts. An unknown key or value is dropped rather than passed
|
|
943
|
-
* through, so a typo can never reach the server as a permission grant.
|
|
944
|
-
*/
|
|
945
|
-
export function normalizeAppCapabilities(capabilities) {
|
|
946
|
-
if (!capabilities || typeof capabilities !== 'object') {
|
|
947
|
-
return {};
|
|
948
|
-
}
|
|
949
|
-
const normalized = {};
|
|
950
|
-
if (capabilities.workspaceDatabases === 'read') {
|
|
951
|
-
normalized.workspaceDatabases = 'read';
|
|
952
|
-
}
|
|
953
|
-
if (capabilities.cloudComputer === 'read' || capabilities.cloudComputer === 'shell') {
|
|
954
|
-
normalized.cloudComputer = capabilities.cloudComputer;
|
|
955
|
-
}
|
|
956
|
-
return normalized;
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
/**
|
|
960
|
-
* Manifest form of a declared skill path: no leading `./`, no trailing slash.
|
|
961
|
-
* A directory declaration is the same string as the source-tree prefix the
|
|
962
|
-
* server matches the uploaded source files against.
|
|
963
|
-
*/
|
|
964
|
-
export function normalizeAppSkillManifestPath(sourcePath) {
|
|
965
|
-
return String(sourcePath || '').replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/\/+$/, '');
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
/**
|
|
969
|
-
* Every packageable file under a declared skill directory, relative to that
|
|
970
|
-
* directory, in stable order. Excludes match `readSourceFiles` so the files a
|
|
971
|
-
* dev session sends inline are exactly the ones a deploy uploads as source.
|
|
972
|
-
*/
|
|
973
|
-
function readAppSkillDirectoryFiles(skillDir) {
|
|
974
|
-
const entries = [];
|
|
975
|
-
|
|
976
|
-
function walk(dir, prefix) {
|
|
977
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
978
|
-
if (shouldExcludeSourceEntry(entry.name) || entry.isSymbolicLink()) {
|
|
979
|
-
continue;
|
|
980
|
-
}
|
|
981
|
-
const fullPath = join(dir, entry.name);
|
|
982
|
-
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
983
|
-
if (entry.isDirectory()) {
|
|
984
|
-
walk(fullPath, relPath);
|
|
985
|
-
} else if (entry.isFile()) {
|
|
986
|
-
entries.push({ path: relPath, absolutePath: fullPath });
|
|
987
|
-
}
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
walk(skillDir, '');
|
|
992
|
-
// Byte order, not locale order: the server sorts the same file set the same
|
|
993
|
-
// way before hashing it, so a dev session and a deploy agree on the hash.
|
|
994
|
-
return entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
export function resolveConfiguredAppSkills(appConfig, projectDir) {
|
|
998
|
-
const configured = Array.isArray(appConfig.skills) ? appConfig.skills : [];
|
|
999
|
-
const projectRoot = resolve(projectDir);
|
|
1000
|
-
const realProjectRoot = realpathSync(projectRoot);
|
|
1001
|
-
const seenKeys = new Set();
|
|
1002
|
-
|
|
1003
|
-
return configured.map((skill, index) => {
|
|
1004
|
-
const key = typeof skill?.key === 'string' ? skill.key.trim() : '';
|
|
1005
|
-
const sourcePath = typeof skill?.path === 'string' ? skill.path.trim() : '';
|
|
1006
|
-
const name = typeof skill?.name === 'string' ? skill.name.trim() : '';
|
|
1007
|
-
if (!key || !sourcePath || !name) {
|
|
1008
|
-
throw usageError(`skills[${index}] must define non-empty key, path, and name values.`);
|
|
1009
|
-
}
|
|
1010
|
-
if (seenKeys.has(key)) {
|
|
1011
|
-
throw usageError(`Duplicate app skill key: ${key}`);
|
|
1012
|
-
}
|
|
1013
|
-
seenKeys.add(key);
|
|
1014
|
-
|
|
1015
|
-
const absolutePath = resolve(projectRoot, sourcePath);
|
|
1016
|
-
const relativePath = relative(projectRoot, absolutePath).replace(/\\/g, '/');
|
|
1017
|
-
if (!relativePath || relativePath.startsWith('../') || relativePath === '..') {
|
|
1018
|
-
throw usageError(`App skill path must stay inside the project: ${sourcePath}`);
|
|
1019
|
-
}
|
|
1020
|
-
if (!existsSync(absolutePath)) {
|
|
1021
|
-
throw usageError(`App skill entrypoint not found: ${sourcePath}`);
|
|
1022
|
-
}
|
|
1023
|
-
if (lstatSync(absolutePath).isSymbolicLink()) {
|
|
1024
|
-
throw usageError(`App skill entrypoint cannot be a symbolic link: ${sourcePath}`);
|
|
1025
|
-
}
|
|
1026
|
-
const realAbsolutePath = realpathSync(absolutePath);
|
|
1027
|
-
const realRelativePath = relative(realProjectRoot, realAbsolutePath).replace(/\\/g, '/');
|
|
1028
|
-
if (!realRelativePath || realRelativePath.startsWith('../') || realRelativePath === '..') {
|
|
1029
|
-
throw usageError(`App skill path must stay inside the project after resolving links: ${sourcePath}`);
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
const description = typeof skill.description === 'string' ? skill.description.trim() : null;
|
|
1033
|
-
const stats = statSync(absolutePath);
|
|
1034
|
-
if (stats.isDirectory()) {
|
|
1035
|
-
const entries = readAppSkillDirectoryFiles(absolutePath);
|
|
1036
|
-
if (!entries.some((entry) => entry.path === 'SKILL.md')) {
|
|
1037
|
-
throw usageError(`App skill directory must contain SKILL.md: ${sourcePath}`);
|
|
1038
|
-
}
|
|
1039
|
-
const bundleFiles = entries.map((entry) => ({
|
|
1040
|
-
path: entry.path,
|
|
1041
|
-
content: readFileSync(entry.absolutePath),
|
|
1042
|
-
}));
|
|
1043
|
-
const totalBytes = bundleFiles.reduce((total, entry) => total + entry.content.length, 0);
|
|
1044
|
-
if (totalBytes > MAX_APP_SKILL_BUNDLE_BYTES) {
|
|
1045
|
-
throw usageError(
|
|
1046
|
-
`App skill "${key}" bundles ${totalBytes} bytes, above the ${MAX_APP_SKILL_BUNDLE_BYTES} byte limit.`,
|
|
1047
|
-
);
|
|
1048
|
-
}
|
|
1049
|
-
const skillMd = bundleFiles.find((entry) => entry.path === 'SKILL.md');
|
|
1050
|
-
return {
|
|
1051
|
-
key,
|
|
1052
|
-
path: relativePath,
|
|
1053
|
-
name,
|
|
1054
|
-
description,
|
|
1055
|
-
skill_md: skillMd.content.toString('utf8'),
|
|
1056
|
-
bundle_files: bundleFiles.map((entry) => ({
|
|
1057
|
-
path: entry.path,
|
|
1058
|
-
content_b64: entry.content.toString('base64'),
|
|
1059
|
-
})),
|
|
1060
|
-
};
|
|
1061
|
-
}
|
|
1062
|
-
if (!stats.isFile()) {
|
|
1063
|
-
throw usageError(`App skill entrypoint not found: ${sourcePath}`);
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
return {
|
|
1067
|
-
key,
|
|
1068
|
-
path: relativePath,
|
|
1069
|
-
name,
|
|
1070
|
-
description,
|
|
1071
|
-
skill_md: readFileSync(absolutePath, 'utf8'),
|
|
1072
|
-
};
|
|
1073
|
-
});
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
/**
|
|
1077
|
-
* Build the app bundle: generate entry file, run `vite build`, package into .notis/output/.
|
|
300
|
+
* Build a bundle in place: `prepare` writes the entry file and manifest, the
|
|
301
|
+
* build script packages into .notis/output/, then the build receipt is saved.
|
|
302
|
+
* Space builds supply `prepare` (see space-platform.js).
|
|
1078
303
|
*/
|
|
1079
|
-
export async function buildArtifact(projectDir, { stdio = 'inherit'
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
304
|
+
export async function buildArtifact(projectDir, { stdio = 'inherit', prepare,
|
|
305
|
+
finalize = null, executable = true, refreshSdk = true, runBuild = runProjectScript } = {}) {
|
|
306
|
+
if (typeof prepare !== 'function') {
|
|
307
|
+
throw new TypeError('buildArtifact needs a prepare step that writes the entry file and manifest.');
|
|
308
|
+
}
|
|
309
|
+
projectDir = realpathSync(assertSourceRoot(projectDir));
|
|
310
|
+
const workspace = withAppReleaseWorkspace(projectDir, (identity) => {
|
|
311
|
+
rmSync('build-receipt.json', { force: true });
|
|
312
|
+
return identity;
|
|
313
|
+
}, { create: true });
|
|
314
|
+
// SDK refresh is intentional source preparation, before the immutable build receipt.
|
|
315
|
+
if (refreshSdk) syncEmbeddedSdk(projectDir);
|
|
316
|
+
const sourceHash = appFilesDigest(collectSourceFiles(projectDir));
|
|
317
|
+
await prepare(projectDir);
|
|
318
|
+
|
|
319
|
+
// Historical source must round-trip byte-for-byte. Supply the compatible
|
|
320
|
+
// config loader at execution for the old canonical script, without editing
|
|
321
|
+
// pulled package.json files or interpreting custom shell commands.
|
|
322
|
+
const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'));
|
|
323
|
+
const args = String(pkg.scripts?.build || '').trim() === 'vite build'
|
|
324
|
+
? ['--configLoader', 'runner']
|
|
325
|
+
: [];
|
|
326
|
+
// runBuild defaults to the PATH npm script. A confined caller supplies a pinned runner instead.
|
|
327
|
+
if (executable) await runBuild({ projectDir, scriptName: 'build', args, stdio });
|
|
328
|
+
|
|
329
|
+
withAppReleaseWorkspace(projectDir, () => {}, { expected: workspace });
|
|
1088
330
|
|
|
1089
331
|
// Verify the canonical `.notis/output/bundle` packaging contract.
|
|
1090
332
|
const builtBundleDir = resolveBuiltBundleDir(projectDir);
|
|
1091
|
-
if (!builtBundleDir) {
|
|
333
|
+
if (executable && !builtBundleDir) {
|
|
1092
334
|
throw usageError(
|
|
1093
335
|
'Vite build did not produce app.js in .notis/output/bundle. Check your vite.config.ts.',
|
|
1094
336
|
);
|
|
1095
337
|
}
|
|
1096
338
|
normalizeBundleStylesheets(projectDir);
|
|
1097
|
-
|
|
339
|
+
if (finalize) await finalize(projectDir);
|
|
340
|
+
withAppReleaseWorkspace(projectDir, () => {}, { expected: workspace });
|
|
1098
341
|
|
|
1099
342
|
validateArtifactBoundary(readArtifactFiles(projectDir));
|
|
1100
343
|
|
|
1101
344
|
const manifest = readManifest(projectDir);
|
|
1102
345
|
|
|
346
|
+
if (sourceHash !== appFilesDigest(collectSourceFiles(projectDir))) {
|
|
347
|
+
throw usageError('App source changed during the build. Build again before deploying.');
|
|
348
|
+
}
|
|
349
|
+
const receipt = JSON.stringify({
|
|
350
|
+
source_hash: sourceHash,
|
|
351
|
+
artifact_hash: appFilesDigest(collectArtifactFiles(projectDir)),
|
|
352
|
+
});
|
|
353
|
+
withAppReleaseWorkspace(projectDir, () => {
|
|
354
|
+
const temporary = `.build-receipt-${randomUUID()}`;
|
|
355
|
+
try {
|
|
356
|
+
writeFileSync(temporary, receipt, { flag: 'wx' });
|
|
357
|
+
renameSync(temporary, 'build-receipt.json');
|
|
358
|
+
} finally { rmSync(temporary, { force: true }); }
|
|
359
|
+
}, { expected: workspace });
|
|
1103
360
|
return { manifest, outputDir: join(projectDir, OUTPUT_DIR) };
|
|
1104
361
|
}
|
|
1105
362
|
|
|
1106
363
|
/**
|
|
1107
364
|
* Read the manifest from a built project.
|
|
1108
|
-
*/
|
|
1109
|
-
export function readManifest(projectDir) {
|
|
1110
|
-
const manifestPath = join(projectDir, MANIFEST_FILE);
|
|
1111
|
-
if (!existsSync(manifestPath)) {
|
|
1112
|
-
throw usageError('No manifest found. Run "notis
|
|
1113
|
-
}
|
|
1114
|
-
return JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
function copyMetadataAssets(projectDir) {
|
|
1118
|
-
const metadataDir = join(projectDir, METADATA_DIR);
|
|
1119
|
-
const outputMetadataDir = join(projectDir, OUTPUT_DIR, METADATA_DIR);
|
|
1120
|
-
// Vite clears the bundle directory, not sibling listing media. Always make
|
|
1121
|
-
// the packaged metadata an exact reflection of the source tree so removed
|
|
1122
|
-
// placeholders can never survive into a later deploy.
|
|
1123
|
-
rmSync(outputMetadataDir, { recursive: true, force: true });
|
|
1124
|
-
if (!existsSync(metadataDir)) {
|
|
1125
|
-
return;
|
|
1126
|
-
}
|
|
1127
|
-
mkdirSync(outputMetadataDir, { recursive: true });
|
|
1128
|
-
for (const entry of readdirSync(metadataDir, { withFileTypes: true })) {
|
|
1129
|
-
if (!entry.isFile()) continue;
|
|
1130
|
-
if (!/^screenshot-\d+\.png$/i.test(entry.name)) {
|
|
1131
|
-
continue;
|
|
1132
|
-
}
|
|
1133
|
-
cpSync(join(metadataDir, entry.name), join(outputMetadataDir, entry.name));
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
// ---------------------------------------------------------------------------
|
|
1138
|
-
// Linking
|
|
1139
|
-
// ---------------------------------------------------------------------------
|
|
1140
|
-
|
|
1141
|
-
const PROFILE_LINK_FIELDS = new Set([
|
|
1142
|
-
'app_id',
|
|
1143
|
-
'linked_at',
|
|
1144
|
-
'deployed_at',
|
|
1145
|
-
'version',
|
|
1146
|
-
'dev_app_id',
|
|
1147
|
-
'dev_linked_at',
|
|
1148
|
-
'auto_linked_at',
|
|
1149
|
-
'cloud_computer_shell_consent',
|
|
1150
|
-
]);
|
|
1151
|
-
|
|
1152
|
-
function splitLinkedState(state) {
|
|
1153
|
-
const base = {};
|
|
1154
|
-
const link = {};
|
|
1155
|
-
for (const [key, value] of Object.entries(state || {})) {
|
|
1156
|
-
if (key === 'profiles') continue;
|
|
1157
|
-
(PROFILE_LINK_FIELDS.has(key) ? link : base)[key] = value;
|
|
1158
|
-
}
|
|
1159
|
-
return { base, link };
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
export function appLinkedStateProfileKey({ apiBase, userId }) {
|
|
1163
|
-
const normalizedApi = String(apiBase || '').trim().replace(/\/$/, '');
|
|
1164
|
-
const normalizedUser = String(userId || '').trim();
|
|
1165
|
-
if (!normalizedApi || !normalizedUser) return null;
|
|
1166
|
-
return Buffer.from(`${normalizedApi}\0${normalizedUser}`).toString('base64url');
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
export function readLinkedState(projectDir, profileKey = null) {
|
|
1170
|
-
const statePath = join(projectDir, STATE_FILE);
|
|
1171
|
-
assertLinkedStatePathSafe(projectDir);
|
|
1172
|
-
if (!existsSync(statePath)) return null;
|
|
1173
|
-
const raw = JSON.parse(readFileSync(statePath, 'utf-8'));
|
|
1174
|
-
if (!profileKey) return raw;
|
|
1175
|
-
const { base, link: legacyLink } = splitLinkedState(raw);
|
|
1176
|
-
const profiles = raw?.profiles && typeof raw.profiles === 'object' ? raw.profiles : {};
|
|
1177
|
-
const scoped = profiles[profileKey];
|
|
1178
|
-
return {
|
|
1179
|
-
...base,
|
|
1180
|
-
...(scoped && typeof scoped === 'object' ? scoped : legacyLink),
|
|
1181
|
-
};
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
function readStateWriteLockOwner(lockPath) {
|
|
1185
|
-
try {
|
|
1186
|
-
return readFileSync(join(lockPath, 'owner'), 'utf8').trim();
|
|
1187
|
-
} catch {
|
|
1188
|
-
return null;
|
|
1189
|
-
}
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
function reclaimStaleStateWriteLock(lockPath, observedOwner, observedMtimeMs) {
|
|
1193
|
-
const quarantinePath = `${lockPath}.stale-${process.pid}-${randomUUID()}`;
|
|
1194
|
-
try {
|
|
1195
|
-
renameSync(lockPath, quarantinePath);
|
|
1196
|
-
} catch (error) {
|
|
1197
|
-
if (error?.code === 'ENOENT') return false;
|
|
1198
|
-
throw error;
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
let unchanged = false;
|
|
1202
|
-
try {
|
|
1203
|
-
const quarantinedStat = lstatSync(quarantinePath);
|
|
1204
|
-
unchanged = readStateWriteLockOwner(quarantinePath) === observedOwner
|
|
1205
|
-
&& quarantinedStat.mtimeMs === observedMtimeMs;
|
|
1206
|
-
} catch (error) {
|
|
1207
|
-
try {
|
|
1208
|
-
renameSync(quarantinePath, lockPath);
|
|
1209
|
-
} catch {
|
|
1210
|
-
// Fail closed below if the quarantined lock cannot be restored.
|
|
1211
|
-
}
|
|
1212
|
-
throw error;
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
if (!unchanged) {
|
|
1216
|
-
try {
|
|
1217
|
-
renameSync(quarantinePath, lockPath);
|
|
1218
|
-
} catch {
|
|
1219
|
-
throw usageError(`Notis app state lock changed while it was being recovered: ${lockPath}`);
|
|
1220
|
-
}
|
|
1221
|
-
return false;
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
rmSync(quarantinePath, { recursive: true, force: true });
|
|
1225
|
-
return true;
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
function withStateWriteLock(statePath, callback) {
|
|
1229
|
-
mkdirSync(dirname(statePath), { recursive: true, mode: 0o700 });
|
|
1230
|
-
const lockPath = `${statePath}.write-lock`;
|
|
1231
|
-
const ownerId = `${process.pid}.${randomUUID()}`;
|
|
1232
|
-
const startedAt = Date.now();
|
|
1233
|
-
while (true) {
|
|
1234
|
-
try {
|
|
1235
|
-
mkdirSync(lockPath, { mode: 0o700 });
|
|
1236
|
-
try {
|
|
1237
|
-
writeFileSync(join(lockPath, 'owner'), ownerId, { mode: 0o600, flag: 'wx' });
|
|
1238
|
-
} catch (error) {
|
|
1239
|
-
// A stale-lock recovery may have moved this candidate while the owner
|
|
1240
|
-
// record was being written. Never remove a replacement writer's lock.
|
|
1241
|
-
if (readStateWriteLockOwner(lockPath) === ownerId) {
|
|
1242
|
-
rmSync(lockPath, { recursive: true, force: true });
|
|
1243
|
-
}
|
|
1244
|
-
throw error;
|
|
1245
|
-
}
|
|
1246
|
-
break;
|
|
1247
|
-
} catch (error) {
|
|
1248
|
-
if (error?.code !== 'EEXIST') throw error;
|
|
1249
|
-
try {
|
|
1250
|
-
const lockStat = lstatSync(lockPath);
|
|
1251
|
-
if (lockStat.isSymbolicLink() || !lockStat.isDirectory()) {
|
|
1252
|
-
throw usageError(`Refusing unsafe Notis app state lock: ${lockPath}`);
|
|
1253
|
-
}
|
|
1254
|
-
const observedOwner = readStateWriteLockOwner(lockPath);
|
|
1255
|
-
const ownerPidText = String(observedOwner || '').split('.')[0];
|
|
1256
|
-
const ownerPid = /^\d+$/.test(ownerPidText) ? Number.parseInt(ownerPidText, 10) : null;
|
|
1257
|
-
// PID liveness alone is insufficient: operating systems can reuse a
|
|
1258
|
-
// crashed writer's PID for an unrelated process. A linked-state write
|
|
1259
|
-
// is synchronous and normally holds this lock only for milliseconds,
|
|
1260
|
-
// so an aged lock is stale even when that numeric PID now exists.
|
|
1261
|
-
let stale = Date.now() - lockStat.mtimeMs >= STATE_WRITE_LOCK_STALE_MS;
|
|
1262
|
-
if (!stale && Number.isInteger(ownerPid) && ownerPid > 0) {
|
|
1263
|
-
try {
|
|
1264
|
-
process.kill(ownerPid, 0);
|
|
1265
|
-
} catch (ownerError) {
|
|
1266
|
-
if (ownerError?.code === 'ESRCH') {
|
|
1267
|
-
stale = true;
|
|
1268
|
-
}
|
|
1269
|
-
}
|
|
1270
|
-
}
|
|
1271
|
-
if (stale && reclaimStaleStateWriteLock(lockPath, observedOwner, lockStat.mtimeMs)) {
|
|
1272
|
-
continue;
|
|
1273
|
-
}
|
|
1274
|
-
} catch (statError) {
|
|
1275
|
-
if (statError?.code === 'ENOENT') continue;
|
|
1276
|
-
throw statError;
|
|
1277
|
-
}
|
|
1278
|
-
if (Date.now() - startedAt >= STATE_WRITE_LOCK_TIMEOUT_MS) {
|
|
1279
|
-
throw usageError(`Timed out waiting to update Notis app state: ${statePath}`);
|
|
1280
|
-
}
|
|
1281
|
-
Atomics.wait(stateWriteLockWait, 0, 0, 10);
|
|
1282
|
-
}
|
|
1283
|
-
}
|
|
1284
|
-
try {
|
|
1285
|
-
return callback();
|
|
1286
|
-
} finally {
|
|
1287
|
-
try {
|
|
1288
|
-
if (readStateWriteLockOwner(lockPath) === ownerId) {
|
|
1289
|
-
rmSync(lockPath, { recursive: true, force: true });
|
|
1290
|
-
}
|
|
1291
|
-
} catch {
|
|
1292
|
-
// Never remove a lock that no longer belongs to this writer.
|
|
1293
|
-
}
|
|
1294
|
-
}
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
export function writeLinkedState(projectDir, state, profileKey = null) {
|
|
1298
|
-
const statePath = join(projectDir, STATE_FILE);
|
|
1299
|
-
assertLinkedStatePathSafe(projectDir);
|
|
1300
|
-
return withStateWriteLock(statePath, () => {
|
|
1301
|
-
let nextState = state;
|
|
1302
|
-
if (profileKey) {
|
|
1303
|
-
let current = {};
|
|
1304
|
-
if (existsSync(statePath)) {
|
|
1305
|
-
current = JSON.parse(readFileSync(statePath, 'utf-8'));
|
|
1306
|
-
}
|
|
1307
|
-
const { base: currentBase } = splitLinkedState(current);
|
|
1308
|
-
const { base } = splitLinkedState(state);
|
|
1309
|
-
const { link } = splitLinkedState(state);
|
|
1310
|
-
const profiles = current?.profiles && typeof current.profiles === 'object'
|
|
1311
|
-
? { ...current.profiles }
|
|
1312
|
-
: {};
|
|
1313
|
-
profiles[profileKey] = link;
|
|
1314
|
-
nextState = { ...currentBase, ...base, profiles };
|
|
1315
|
-
}
|
|
1316
|
-
const temporary = `${statePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
1317
|
-
writeFileSync(temporary, JSON.stringify(nextState, null, 2) + '\n');
|
|
1318
|
-
renameSync(temporary, statePath);
|
|
1319
|
-
});
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
function assertLinkedStatePathSafe(projectDir) {
|
|
1323
|
-
const notisDir = join(projectDir, NOTIS_DIR);
|
|
1324
|
-
try {
|
|
1325
|
-
const directoryStat = lstatSync(notisDir);
|
|
1326
|
-
if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
|
|
1327
|
-
throw usageError(`Refusing to use unsafe Notis state directory: ${notisDir}`);
|
|
1328
|
-
}
|
|
1329
|
-
} catch (error) {
|
|
1330
|
-
if (error?.code !== 'ENOENT') throw error;
|
|
1331
|
-
}
|
|
1332
|
-
|
|
1333
|
-
const statePath = join(projectDir, STATE_FILE);
|
|
1334
|
-
try {
|
|
1335
|
-
const stateStat = lstatSync(statePath);
|
|
1336
|
-
if (stateStat.isSymbolicLink() || !stateStat.isFile()) {
|
|
1337
|
-
throw usageError(`Refusing to use unsafe Notis state file: ${statePath}`);
|
|
1338
|
-
}
|
|
1339
|
-
} catch (error) {
|
|
1340
|
-
if (error?.code !== 'ENOENT') throw error;
|
|
365
|
+
*/
|
|
366
|
+
export function readManifest(projectDir) {
|
|
367
|
+
const manifestPath = join(projectDir, MANIFEST_FILE);
|
|
368
|
+
if (!existsSync(manifestPath)) {
|
|
369
|
+
throw usageError('No manifest found. Run "notis spaces build" first.');
|
|
1341
370
|
}
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
export function requireLinkedAppId(projectDir, explicitAppId, profileKey = null) {
|
|
1345
|
-
if (explicitAppId) return explicitAppId;
|
|
1346
|
-
const state = readLinkedState(projectDir, profileKey);
|
|
1347
|
-
if (state?.app_id) return state.app_id;
|
|
1348
|
-
throw usageError('This project is not linked to a Notis app. Run "notis apps link <app-id> ." first.');
|
|
371
|
+
return JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
1349
372
|
}
|
|
1350
373
|
|
|
1351
374
|
// ---------------------------------------------------------------------------
|
|
@@ -1353,31 +376,17 @@ export function requireLinkedAppId(projectDir, explicitAppId, profileKey = null)
|
|
|
1353
376
|
// ---------------------------------------------------------------------------
|
|
1354
377
|
|
|
1355
378
|
/**
|
|
1356
|
-
* Scaffold a new
|
|
1357
|
-
* Store app downloaded from the public registry repository.
|
|
379
|
+
* Scaffold a new project from the SDK template.
|
|
1358
380
|
*/
|
|
1359
|
-
export async function scaffoldProject({ projectDir, appName
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
throw usageError(`Unknown scaffold "${fromSlug}". Available scaffolds: ${known}.`);
|
|
1366
|
-
}
|
|
1367
|
-
}
|
|
1368
|
-
try {
|
|
1369
|
-
return scaffoldProjectFromDir({
|
|
1370
|
-
projectDir,
|
|
1371
|
-
appName,
|
|
1372
|
-
fromSlug,
|
|
1373
|
-
templateDir: scaffoldSource ? scaffoldSource.dir : join(CLI_ROOT, 'template'),
|
|
1374
|
-
});
|
|
1375
|
-
} finally {
|
|
1376
|
-
scaffoldSource?.cleanup();
|
|
1377
|
-
}
|
|
381
|
+
export async function scaffoldProject({ projectDir, appName }) {
|
|
382
|
+
return scaffoldProjectFromDir({
|
|
383
|
+
projectDir,
|
|
384
|
+
appName,
|
|
385
|
+
templateDir: join(CLI_ROOT, 'template'),
|
|
386
|
+
});
|
|
1378
387
|
}
|
|
1379
388
|
|
|
1380
|
-
function scaffoldProjectFromDir({ projectDir, appName,
|
|
389
|
+
function scaffoldProjectFromDir({ projectDir, appName, templateDir }) {
|
|
1381
390
|
const sourceDir = resolve(templateDir);
|
|
1382
391
|
const requestedProjectDir = resolve(projectDir);
|
|
1383
392
|
if (!existsSync(sourceDir)) {
|
|
@@ -1404,14 +413,14 @@ function scaffoldProjectFromDir({ projectDir, appName, fromSlug, templateDir })
|
|
|
1404
413
|
assertPullTargetIdentity('.', parentIdentity);
|
|
1405
414
|
const lockedTargetIdentity = captureScaffoldTargetIdentity(targetName);
|
|
1406
415
|
if (!pullTargetIdentitiesMatch(initialTargetIdentity, lockedTargetIdentity)) {
|
|
1407
|
-
throw usageError(`
|
|
416
|
+
throw usageError(`Scaffold target changed before copying: ${requestedProjectDir}`);
|
|
1408
417
|
}
|
|
1409
418
|
if (!lockedTargetIdentity.exists) {
|
|
1410
419
|
try {
|
|
1411
420
|
mkdirSync(targetName);
|
|
1412
421
|
} catch (error) {
|
|
1413
422
|
if (error?.code === 'EEXIST') {
|
|
1414
|
-
throw usageError(`
|
|
423
|
+
throw usageError(`Scaffold target changed before copying: ${requestedProjectDir}`);
|
|
1415
424
|
}
|
|
1416
425
|
throw error;
|
|
1417
426
|
}
|
|
@@ -1422,7 +431,7 @@ function scaffoldProjectFromDir({ projectDir, appName, fromSlug, templateDir })
|
|
|
1422
431
|
activeTargetIdentity,
|
|
1423
432
|
captureScaffoldTargetIdentity('.'),
|
|
1424
433
|
)) {
|
|
1425
|
-
throw usageError(`
|
|
434
|
+
throw usageError(`Scaffold target changed before copying: ${requestedProjectDir}`);
|
|
1426
435
|
}
|
|
1427
436
|
mkdirSync(scaffoldLockName, { mode: 0o700 });
|
|
1428
437
|
writeFileSync(
|
|
@@ -1441,55 +450,11 @@ function scaffoldProjectFromDir({ projectDir, appName, fromSlug, templateDir })
|
|
|
1441
450
|
if (existsSync(pkgPath)) {
|
|
1442
451
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
1443
452
|
pkg.name = appName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
|
|
1444
|
-
// A scaffold creates a new app identity, even when its source comes from a
|
|
1445
|
-
// versioned Store example. New apps must therefore start their own release
|
|
1446
|
-
// history instead of inheriting the example app's registry version.
|
|
1447
|
-
pkg.notisAppVersion = '0.1.0';
|
|
1448
|
-
normalizeScaffoldPackageScripts(pkg);
|
|
1449
453
|
ensureScaffoldLocalSdk(projectDir, pkg);
|
|
1450
454
|
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
1451
455
|
normalizeScaffoldLockfile(projectDir, pkg);
|
|
1452
456
|
}
|
|
1453
457
|
|
|
1454
|
-
// Update notis.config.ts with the app name
|
|
1455
|
-
const configPath = join(projectDir, 'notis.config.ts');
|
|
1456
|
-
if (existsSync(configPath)) {
|
|
1457
|
-
let config = readFileSync(configPath, 'utf-8');
|
|
1458
|
-
const displayName = jsStringLiteral(appName);
|
|
1459
|
-
const slugName = jsStringLiteral(safeKebab(appName) || 'my-notis-app');
|
|
1460
|
-
if (fromSlug) {
|
|
1461
|
-
if (/name\s*:/.test(config)) {
|
|
1462
|
-
config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName}`);
|
|
1463
|
-
}
|
|
1464
|
-
if (/title\s*:/.test(config)) {
|
|
1465
|
-
config = config.replace(/title\s*:\s*(['"`])[\s\S]*?\1/, `title: ${displayName}`);
|
|
1466
|
-
} else {
|
|
1467
|
-
config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName},\n title: ${displayName}`);
|
|
1468
|
-
}
|
|
1469
|
-
} else {
|
|
1470
|
-
if (/name\s*:/.test(config)) {
|
|
1471
|
-
config = config.replace(/name\s*:\s*(['"`])[\s\S]*?\1/, `name: ${slugName}`);
|
|
1472
|
-
}
|
|
1473
|
-
if (/title\s*:/.test(config)) {
|
|
1474
|
-
config = config.replace(/title\s*:\s*(['"`])[\s\S]*?\1/, `title: ${displayName}`);
|
|
1475
|
-
} else {
|
|
1476
|
-
config = config.replace(/'My Notis App'/, displayName);
|
|
1477
|
-
}
|
|
1478
|
-
}
|
|
1479
|
-
if (/devSlug\s*:/.test(config)) {
|
|
1480
|
-
config = config.replace(/devSlug\s*:\s*(['"`])[\s\S]*?\1/, `devSlug: ${slugName}`);
|
|
1481
|
-
} else {
|
|
1482
|
-
config = config.replace(
|
|
1483
|
-
/name\s*:\s*(['"`])[\s\S]*?\1/,
|
|
1484
|
-
(nameDeclaration) => `${nameDeclaration},\n devSlug: ${slugName}`,
|
|
1485
|
-
);
|
|
1486
|
-
}
|
|
1487
|
-
config = removeConfigArrayProperty(config, 'screenshots');
|
|
1488
|
-
writeFileSync(configPath, config);
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
resetScaffoldChangelog(projectDir, appName);
|
|
1492
|
-
|
|
1493
458
|
assertPullParentIdentity(parentDir, canonicalParent, parentIdentity);
|
|
1494
459
|
assertPullTargetIdentity(requestedProjectDir, activeTargetIdentity);
|
|
1495
460
|
const stageRoot = realpathSync(stageName);
|
|
@@ -1521,7 +486,7 @@ function scaffoldProjectFromDir({ projectDir, appName, fromSlug, templateDir })
|
|
|
1521
486
|
installedDirectory,
|
|
1522
487
|
capturePullTargetIdentity('.'),
|
|
1523
488
|
)) {
|
|
1524
|
-
throw usageError(`
|
|
489
|
+
throw usageError(`Scaffold destination changed during installation: ${relativePath}`);
|
|
1525
490
|
}
|
|
1526
491
|
try {
|
|
1527
492
|
for (const name of readdirSync(sourcePath)) {
|
|
@@ -1541,7 +506,7 @@ function scaffoldProjectFromDir({ projectDir, appName, fromSlug, templateDir })
|
|
|
1541
506
|
parentIdentity,
|
|
1542
507
|
capturePullTargetIdentity('.'),
|
|
1543
508
|
)) {
|
|
1544
|
-
throw usageError(`
|
|
509
|
+
throw usageError(`Scaffold destination changed during installation: ${parentRelativePath || '.'}`);
|
|
1545
510
|
}
|
|
1546
511
|
}
|
|
1547
512
|
return;
|
|
@@ -1611,7 +576,7 @@ function scaffoldProjectFromDir({ projectDir, appName, fromSlug, templateDir })
|
|
|
1611
576
|
} catch (error) {
|
|
1612
577
|
cleanupStage = false;
|
|
1613
578
|
throw usageError(
|
|
1614
|
-
`
|
|
579
|
+
`Scaffold was installed, but staging cleanup failed; recovery files were retained at `
|
|
1615
580
|
+ `${join(requestedProjectDir, stageName)}: `
|
|
1616
581
|
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
1617
582
|
);
|
|
@@ -1655,26 +620,6 @@ function scaffoldProjectFromDir({ projectDir, appName, fromSlug, templateDir })
|
|
|
1655
620
|
return { projectDir: requestedProjectDir };
|
|
1656
621
|
}
|
|
1657
622
|
|
|
1658
|
-
function normalizeScaffoldPackageScripts(pkg) {
|
|
1659
|
-
const scripts = pkg?.scripts;
|
|
1660
|
-
if (!scripts || typeof scripts !== 'object' || Array.isArray(scripts)) {
|
|
1661
|
-
return;
|
|
1662
|
-
}
|
|
1663
|
-
const generateEntry = String(scripts['generate-entry'] || '').trim();
|
|
1664
|
-
if (!/^tsx\s+\.\.\/\.\.\/scripts\/generate-entry\.ts\s+\.$/.test(generateEntry)) {
|
|
1665
|
-
return;
|
|
1666
|
-
}
|
|
1667
|
-
for (const [name, command] of Object.entries(scripts)) {
|
|
1668
|
-
if (name === 'generate-entry' || typeof command !== 'string') {
|
|
1669
|
-
continue;
|
|
1670
|
-
}
|
|
1671
|
-
if (command.trim() === 'npm run generate-entry') {
|
|
1672
|
-
delete scripts[name];
|
|
1673
|
-
}
|
|
1674
|
-
}
|
|
1675
|
-
scripts['generate-entry'] = 'node -e ""';
|
|
1676
|
-
}
|
|
1677
|
-
|
|
1678
623
|
function captureScaffoldTargetIdentity(targetDir) {
|
|
1679
624
|
try {
|
|
1680
625
|
const targetStat = lstatSync(targetDir, { bigint: true });
|
|
@@ -1682,7 +627,7 @@ function captureScaffoldTargetIdentity(targetDir) {
|
|
|
1682
627
|
throw usageError(`Refusing to scaffold through an unsafe target: ${targetDir}`);
|
|
1683
628
|
}
|
|
1684
629
|
if (readdirSync(targetDir).length > 0) {
|
|
1685
|
-
throw usageError(`
|
|
630
|
+
throw usageError(`Scaffold target must be empty: ${targetDir}`);
|
|
1686
631
|
}
|
|
1687
632
|
return { exists: true, dev: targetStat.dev, ino: targetStat.ino };
|
|
1688
633
|
} catch (error) {
|
|
@@ -1691,76 +636,6 @@ function captureScaffoldTargetIdentity(targetDir) {
|
|
|
1691
636
|
}
|
|
1692
637
|
}
|
|
1693
638
|
|
|
1694
|
-
/**
|
|
1695
|
-
* Drop `property: [ ... ]` from a notis.config.ts source.
|
|
1696
|
-
*
|
|
1697
|
-
* The scan tracks bracket depth while skipping string literals, so entries
|
|
1698
|
-
* whose text contains a bracket (alt text, selectors) cannot end the array
|
|
1699
|
-
* early. When the array cannot be resolved the source is returned untouched --
|
|
1700
|
-
* a stale screenshots list is a warning at verify time, a broken config is not.
|
|
1701
|
-
*/
|
|
1702
|
-
function removeConfigArrayProperty(source, property) {
|
|
1703
|
-
const start = source.search(new RegExp(`^[ \\t]*${property}[ \\t]*:[ \\t]*\\[`, 'm'));
|
|
1704
|
-
if (start === -1) {
|
|
1705
|
-
return source;
|
|
1706
|
-
}
|
|
1707
|
-
let index = source.indexOf('[', start);
|
|
1708
|
-
let depth = 0;
|
|
1709
|
-
let quote = null;
|
|
1710
|
-
for (; index < source.length; index += 1) {
|
|
1711
|
-
const char = source[index];
|
|
1712
|
-
if (quote) {
|
|
1713
|
-
if (char === '\\') {
|
|
1714
|
-
index += 1;
|
|
1715
|
-
} else if (char === quote) {
|
|
1716
|
-
quote = null;
|
|
1717
|
-
}
|
|
1718
|
-
continue;
|
|
1719
|
-
}
|
|
1720
|
-
if (char === '\'' || char === '"' || char === '`') {
|
|
1721
|
-
quote = char;
|
|
1722
|
-
continue;
|
|
1723
|
-
}
|
|
1724
|
-
if (char === '[') {
|
|
1725
|
-
depth += 1;
|
|
1726
|
-
} else if (char === ']') {
|
|
1727
|
-
depth -= 1;
|
|
1728
|
-
if (depth === 0) {
|
|
1729
|
-
break;
|
|
1730
|
-
}
|
|
1731
|
-
}
|
|
1732
|
-
}
|
|
1733
|
-
if (depth !== 0) {
|
|
1734
|
-
return source;
|
|
1735
|
-
}
|
|
1736
|
-
let end = index + 1;
|
|
1737
|
-
if (source[end] === ',') {
|
|
1738
|
-
end += 1;
|
|
1739
|
-
}
|
|
1740
|
-
while (end < source.length && (source[end] === ' ' || source[end] === '\t')) {
|
|
1741
|
-
end += 1;
|
|
1742
|
-
}
|
|
1743
|
-
if (source[end] === '\n') {
|
|
1744
|
-
end += 1;
|
|
1745
|
-
}
|
|
1746
|
-
return source.slice(0, start) + source.slice(end);
|
|
1747
|
-
}
|
|
1748
|
-
|
|
1749
|
-
/**
|
|
1750
|
-
* A new project starts its own release history: the scaffold's entries describe
|
|
1751
|
-
* releases of a different app.
|
|
1752
|
-
*/
|
|
1753
|
-
function resetScaffoldChangelog(projectDir, appName) {
|
|
1754
|
-
const changelogPath = join(projectDir, 'CHANGELOG.md');
|
|
1755
|
-
if (!existsSync(changelogPath)) {
|
|
1756
|
-
return;
|
|
1757
|
-
}
|
|
1758
|
-
writeFileSync(
|
|
1759
|
-
changelogPath,
|
|
1760
|
-
`# ${appName} Changelog\n\n## [Initial Release] - ${CHANGELOG_MERGE_DATE}\n\n- First Store release.\n`,
|
|
1761
|
-
);
|
|
1762
|
-
}
|
|
1763
|
-
|
|
1764
639
|
function normalizeScaffoldLockfile(projectDir, pkg) {
|
|
1765
640
|
const lockPath = join(projectDir, 'package-lock.json');
|
|
1766
641
|
if (!existsSync(lockPath)) {
|
|
@@ -1790,21 +665,315 @@ function ensureScaffoldLocalSdk(projectDir, pkg) {
|
|
|
1790
665
|
}
|
|
1791
666
|
|
|
1792
667
|
const localSdkDir = join(projectDir, 'packages', 'sdk');
|
|
1793
|
-
if (existsSync(join(localSdkDir, 'package.json'))) {
|
|
1794
|
-
return;
|
|
1795
|
-
}
|
|
1796
668
|
if (!existsSync(join(TEMPLATE_SDK_DIR, 'package.json'))) {
|
|
1797
669
|
throw usageError(`SDK template not found at ${TEMPLATE_SDK_DIR}. Ensure @notis_ai/cli is installed correctly.`);
|
|
1798
670
|
}
|
|
1799
671
|
|
|
1800
672
|
mkdirSync(dirname(localSdkDir), { recursive: true });
|
|
1801
|
-
cpSync(TEMPLATE_SDK_DIR, localSdkDir, { recursive: true, dereference: true
|
|
673
|
+
cpSync(TEMPLATE_SDK_DIR, localSdkDir, { recursive: true, dereference: true,
|
|
674
|
+
filter: (source) => !HOST_ONLY_SDK_FILES.includes(relative(TEMPLATE_SDK_DIR, source).split(sep).join('/')) });
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function listFilesRecursive(dir, base = dir, results = []) {
|
|
678
|
+
if (!existsSync(dir)) return results;
|
|
679
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
680
|
+
if (entry.name === 'node_modules' || entry.name === 'dist') continue;
|
|
681
|
+
const fullPath = join(dir, entry.name);
|
|
682
|
+
if (entry.isDirectory()) {
|
|
683
|
+
listFilesRecursive(fullPath, base, results);
|
|
684
|
+
} else {
|
|
685
|
+
results.push(relative(base, fullPath).split(sep).join('/'));
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return results;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Keep an app's embedded public SDK identical to the SDK this CLI ships,
|
|
693
|
+
* excluding host-only mounting code. The copy is a mirror by contract (the parity test in the CLI keeps
|
|
694
|
+
* the bundled artifact generated from packages/sdk), so an app never edits it; before
|
|
695
|
+
* this sync existed every app silently ran whatever SDK snapshot it was
|
|
696
|
+
* scaffolded with and never received hook or style updates.
|
|
697
|
+
*
|
|
698
|
+
* Files that exist locally but not in the template are left alone with a
|
|
699
|
+
* warning so a deliberate local addition is never destroyed. The refresh owns
|
|
700
|
+
* only packages/sdk/{package.json,tsconfig.json,src/<template files>} and the
|
|
701
|
+
* exact retired copies of a real source; a folder without a source marker
|
|
702
|
+
* (the Notis repository's own packages/sdk, for one) is refused untouched.
|
|
703
|
+
*
|
|
704
|
+
* `prune` (Spaces) makes the mirror exact instead: every owned file the
|
|
705
|
+
* template does not ship (packages/sdk/tsconfig.json, src/* and dist/*, the
|
|
706
|
+
* Space SDK's Node test runtime) is removed.
|
|
707
|
+
*/
|
|
708
|
+
export function syncEmbeddedSdk(projectDir, { log = null, templateSdkDir = TEMPLATE_SDK_DIR, retiredFiles = RETIRED_SDK_FILES, prune = false } = {}) {
|
|
709
|
+
const canonicalProjectDir = realpathSync(assertSourceRoot(projectDir));
|
|
710
|
+
const canonicalTemplateDir = resolve(templateSdkDir);
|
|
711
|
+
if (!existsSync(join(canonicalTemplateDir, 'package.json'))) {
|
|
712
|
+
return { updated: false, reason: 'no-template-sdk' };
|
|
713
|
+
}
|
|
714
|
+
const previousCwd = process.cwd();
|
|
715
|
+
const previousIdentity = capturePullTargetIdentity(previousCwd);
|
|
716
|
+
const projectIdentity = capturePullTargetIdentity(canonicalProjectDir);
|
|
717
|
+
// listFilesRecursive skips every dist/ folder; an exact mirror owns the top-level one.
|
|
718
|
+
const listOwned = (dir) => [...listFilesRecursive(dir),
|
|
719
|
+
...(prune ? listFilesRecursive(join(dir, 'dist')).map((relPath) => `dist/${relPath}`) : [])];
|
|
720
|
+
const templateFiles = listOwned(canonicalTemplateDir).filter(
|
|
721
|
+
(relPath) => !HOST_ONLY_SDK_FILES.includes(relPath)
|
|
722
|
+
&& (relPath === 'package.json' || relPath === 'tsconfig.json' || relPath.startsWith('src/') || relPath.startsWith('dist/')),
|
|
723
|
+
);
|
|
724
|
+
const changed = [];
|
|
725
|
+
let foreign = [];
|
|
726
|
+
|
|
727
|
+
// Pin each app-controlled parent as cwd before touching a child. Absolute
|
|
728
|
+
// path writes would follow a replaced packages/sdk or src directory link.
|
|
729
|
+
function inDirectory(name, callback, { create = false } = {}) {
|
|
730
|
+
const parentIdentity = capturePullTargetIdentity('.');
|
|
731
|
+
let identity = capturePullTargetIdentity(name);
|
|
732
|
+
if (!identity.exists) {
|
|
733
|
+
if (!create) return { updated: false, reason: 'no-embedded-sdk' };
|
|
734
|
+
mkdirSync(name);
|
|
735
|
+
identity = capturePullTargetIdentity(name);
|
|
736
|
+
}
|
|
737
|
+
process.chdir(name);
|
|
738
|
+
try {
|
|
739
|
+
assertPullTargetIdentity('.', identity);
|
|
740
|
+
return callback();
|
|
741
|
+
} finally {
|
|
742
|
+
process.chdir('..');
|
|
743
|
+
assertPullTargetIdentity('.', parentIdentity);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function mirrorFile(segments, source) {
|
|
748
|
+
if (segments.length > 1) {
|
|
749
|
+
return inDirectory(segments[0], () => mirrorFile(segments.slice(1), source), { create: true });
|
|
750
|
+
}
|
|
751
|
+
const name = segments[0];
|
|
752
|
+
let before;
|
|
753
|
+
try { before = lstatSync(name, { bigint: true }); }
|
|
754
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
755
|
+
if (before) {
|
|
756
|
+
if (before.isSymbolicLink() || !before.isFile()) {
|
|
757
|
+
throw usageError(`Refusing to refresh the SDK through an unsafe target: ${name}`);
|
|
758
|
+
}
|
|
759
|
+
const descriptor = openSync(name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
760
|
+
try {
|
|
761
|
+
if (Buffer.compare(readStablePinnedFile(descriptor, before, `SDK file ${name}`), source) === 0) return false;
|
|
762
|
+
} finally { closeSync(descriptor); }
|
|
763
|
+
}
|
|
764
|
+
// Replace, never truncate: an app-controlled hard link must not modify
|
|
765
|
+
// another file. O_EXCL staging and rename are relative to the pinned cwd.
|
|
766
|
+
const temporary = `.notis-sdk-sync-${randomUUID()}`;
|
|
767
|
+
try {
|
|
768
|
+
writeFileSync(temporary, source, { flag: 'wx' });
|
|
769
|
+
renameSync(temporary, name);
|
|
770
|
+
} finally { rmSync(temporary, { force: true }); }
|
|
771
|
+
return true;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function retireFile(segments, digests) {
|
|
775
|
+
if (segments.length > 1) return inDirectory(segments[0], () => retireFile(segments.slice(1), digests));
|
|
776
|
+
const name = segments[0];
|
|
777
|
+
let before;
|
|
778
|
+
try { before = lstatSync(name, { bigint: true }); }
|
|
779
|
+
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
780
|
+
if (!before.isFile() || before.isSymbolicLink()) throw usageError(`Refusing an unsafe retired SDK target: ${name}`);
|
|
781
|
+
const descriptor = openSync(name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
782
|
+
try {
|
|
783
|
+
const bytes = readStablePinnedFile(descriptor, before, `Retired SDK file ${name}`);
|
|
784
|
+
if (!digests.includes(createHash('sha256').update(bytes).digest('hex'))) return false;
|
|
785
|
+
// Capture the exact directory entry before removing anything. A newly
|
|
786
|
+
// created replacement at the original path is never overwritten.
|
|
787
|
+
const temporary = `.notis-sdk-retired-${randomUUID()}`;
|
|
788
|
+
renameSync(name, temporary);
|
|
789
|
+
try {
|
|
790
|
+
const captured = lstatSync(temporary, { bigint: true });
|
|
791
|
+
if (captured.dev !== before.dev || captured.ino !== before.ino) {
|
|
792
|
+
throw usageError(`SDK file changed during retirement: ${name}`);
|
|
793
|
+
}
|
|
794
|
+
const capturedDescriptor = openSync(temporary, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
795
|
+
try {
|
|
796
|
+
if (!readStablePinnedFile(capturedDescriptor, captured, `Retired SDK file ${name}`).equals(bytes)) {
|
|
797
|
+
throw usageError(`SDK file changed during retirement: ${name}`);
|
|
798
|
+
}
|
|
799
|
+
} finally { closeSync(capturedDescriptor); }
|
|
800
|
+
unlinkSync(temporary);
|
|
801
|
+
} catch (error) {
|
|
802
|
+
// O_EXCL hard-link restoration preserves an independently-created
|
|
803
|
+
// replacement as well as the captured local bytes.
|
|
804
|
+
try { linkSync(temporary, name); unlinkSync(temporary); } catch { /* Retain the captured file for reconciliation. */ }
|
|
805
|
+
throw error;
|
|
806
|
+
}
|
|
807
|
+
return true;
|
|
808
|
+
} finally { closeSync(descriptor); }
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Unlink one directory entry relative to the pinned cwd; never follows a link.
|
|
812
|
+
function removeFile(segments) {
|
|
813
|
+
if (segments.length > 1) return inDirectory(segments[0], () => removeFile(segments.slice(1)));
|
|
814
|
+
try { unlinkSync(segments[0]); return true; }
|
|
815
|
+
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function removeEmptyDirectories() {
|
|
819
|
+
for (const entry of readdirSync('.', { withFileTypes: true })) {
|
|
820
|
+
if (!entry.isDirectory()) continue;
|
|
821
|
+
inDirectory(entry.name, removeEmptyDirectories);
|
|
822
|
+
try { rmdirSync(entry.name); }
|
|
823
|
+
catch (error) { if (error.code !== 'ENOTEMPTY' && error.code !== 'EEXIST' && error.code !== 'ENOENT') throw error; }
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
try {
|
|
828
|
+
process.chdir(canonicalProjectDir);
|
|
829
|
+
assertPullTargetIdentity('.', projectIdentity);
|
|
830
|
+
const result = inDirectory('packages', () => inDirectory('sdk', () => {
|
|
831
|
+
let packageStat;
|
|
832
|
+
try { packageStat = lstatSync('package.json'); }
|
|
833
|
+
catch (error) {
|
|
834
|
+
if (error.code === 'ENOENT') return { updated: false, reason: 'no-embedded-sdk' };
|
|
835
|
+
throw error;
|
|
836
|
+
}
|
|
837
|
+
if (packageStat.isSymbolicLink() || !packageStat.isFile()) {
|
|
838
|
+
throw usageError('Refusing to refresh the SDK through an unsafe package.json target.');
|
|
839
|
+
}
|
|
840
|
+
for (const relPath of templateFiles) {
|
|
841
|
+
const source = readFileSync(join(canonicalTemplateDir, relPath));
|
|
842
|
+
if (mirrorFile(relPath.split('/'), source)) changed.push(relPath);
|
|
843
|
+
}
|
|
844
|
+
const templateSet = new Set(templateFiles);
|
|
845
|
+
for (const [relPath, digests] of Object.entries(retiredFiles)) {
|
|
846
|
+
if (!templateSet.has(relPath) && retireFile(relPath.split('/'), digests) === true) changed.push(relPath);
|
|
847
|
+
}
|
|
848
|
+
foreign = listOwned('.').filter((relPath) => (relPath.startsWith('src/') || (prune && (relPath === 'tsconfig.json' || relPath.startsWith('dist/'))))
|
|
849
|
+
&& !templateSet.has(relPath));
|
|
850
|
+
if (prune) {
|
|
851
|
+
for (const relPath of foreign) {
|
|
852
|
+
if (removeFile(relPath.split('/'))) changed.push(relPath);
|
|
853
|
+
}
|
|
854
|
+
foreign = [];
|
|
855
|
+
inDirectory('src', removeEmptyDirectories);
|
|
856
|
+
inDirectory('dist', removeEmptyDirectories);
|
|
857
|
+
try { rmdirSync('dist'); }
|
|
858
|
+
catch (error) { if (error.code !== 'ENOTEMPTY' && error.code !== 'EEXIST' && error.code !== 'ENOENT') throw error; }
|
|
859
|
+
}
|
|
860
|
+
return null;
|
|
861
|
+
}));
|
|
862
|
+
if (result) return result;
|
|
863
|
+
assertPullTargetIdentity('.', projectIdentity);
|
|
864
|
+
} finally {
|
|
865
|
+
process.chdir(previousCwd);
|
|
866
|
+
assertPullTargetIdentity('.', previousIdentity);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const templateVersion = JSON.parse(readFileSync(join(canonicalTemplateDir, 'package.json'), 'utf-8')).version;
|
|
870
|
+
const emit = typeof log === 'function' ? log : (message) => process.stderr.write(`${message}\n`);
|
|
871
|
+
if (changed.length > 0) {
|
|
872
|
+
emit(`Updated embedded @notis/sdk to ${templateVersion} (${changed.length} file${changed.length === 1 ? '' : 's'}).`);
|
|
873
|
+
}
|
|
874
|
+
for (const relPath of foreign) {
|
|
875
|
+
emit(`Warning: packages/sdk/${relPath} is not part of the @notis/sdk template and was left unchanged.`);
|
|
876
|
+
}
|
|
877
|
+
return { updated: changed.length > 0, changed, foreign, templateVersion };
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* A Space's packages/sdk is exactly the package this CLI ships for Spaces
|
|
882
|
+
* (dist/space-sdk): host-module types, the build-time and pure entries, and a
|
|
883
|
+
* Node-only runtime for the Space's own tests. A Space view never loads SDK
|
|
884
|
+
* code of its own, so the mirror is pruned, not merged.
|
|
885
|
+
*/
|
|
886
|
+
export function syncSpaceSdk(projectDir, { log = null, templateSdkDir = SPACE_SDK_DIR, updateLock = updatePackageLock } = {}) {
|
|
887
|
+
if (!existsSync(join(templateSdkDir, 'package.json'))) {
|
|
888
|
+
throw usageError(`Space SDK not found at ${templateSdkDir}. Ensure @notis_ai/cli is installed correctly.`);
|
|
889
|
+
}
|
|
890
|
+
const result = syncEmbeddedSdk(projectDir, { log, templateSdkDir, retiredFiles: {}, prune: true });
|
|
891
|
+
if (result.reason) return result;
|
|
892
|
+
const manifest = JSON.parse(readFileSync(join(templateSdkDir, 'package.json'), 'utf-8'));
|
|
893
|
+
return { ...result, lockfile: reconcileSdkLockfile(realpathSync(projectDir), { manifest, log, updateLock }) };
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// The package.json fields npm records for a linked package. `npm ci` checks a
|
|
897
|
+
// lock against the linked package.json, so a changed SDK dependency makes it
|
|
898
|
+
// refuse a lock written for the previous SDK.
|
|
899
|
+
const LOCKED_MANIFEST_FIELDS = ['name', 'version', 'dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies', 'peerDependenciesMeta'];
|
|
900
|
+
|
|
901
|
+
function canonicalJson(value) {
|
|
902
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
903
|
+
if (value && typeof value === 'object') {
|
|
904
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
|
|
905
|
+
}
|
|
906
|
+
return JSON.stringify(value ?? null);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** True when a lock entry records exactly this manifest (an absent map equals an empty one). */
|
|
910
|
+
export function lockRecordsManifest(entry, manifest) {
|
|
911
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
912
|
+
return LOCKED_MANIFEST_FIELDS.every((field) => {
|
|
913
|
+
const absent = field === 'name' || field === 'version' ? null : {};
|
|
914
|
+
return canonicalJson(entry[field] ?? absent) === canonicalJson(manifest[field] ?? absent);
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// The lock only: no install scripts, node_modules untouched. One quick retry
|
|
919
|
+
// keeps an offline machine from waiting out npm's minute-long default backoff,
|
|
920
|
+
// and npm still answers from its cache when the network is down.
|
|
921
|
+
const LOCK_UPDATE_ARGS = Object.freeze(['install', '--package-lock-only', '--ignore-scripts', '--no-audit', '--no-fund',
|
|
922
|
+
'--fetch-retries=1', '--fetch-retry-mintimeout=1000']);
|
|
923
|
+
|
|
924
|
+
function updatePackageLock(projectDir) {
|
|
925
|
+
const result = spawnSync('npm', [...LOCK_UPDATE_ARGS], {
|
|
926
|
+
cwd: projectDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 180_000, shell: process.platform === 'win32',
|
|
927
|
+
});
|
|
928
|
+
if (result.error) {
|
|
929
|
+
return { ok: false, detail: result.error.code === 'ETIMEDOUT' ? 'npm did not finish within 3 minutes' : result.error.message };
|
|
930
|
+
}
|
|
931
|
+
if (result.status === 0) return { ok: true };
|
|
932
|
+
return { ok: false, detail: npmFailureReason(`${result.stderr || ''}\n${result.stdout || ''}`) || `npm exited with code ${result.status}` };
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** npm's reason in one line: its first error line that is not code/syscall/errno metadata, a stack frame or the log pointer. */
|
|
936
|
+
export function npmFailureReason(output) {
|
|
937
|
+
const lines = String(output || '').split('\n').map((line) => line.trim()).filter(Boolean);
|
|
938
|
+
const meaningful = (line) => line && !/^(code|syscall|errno)\s/.test(line) && !/^at\s/.test(line) && !/complete log of this run/i.test(line);
|
|
939
|
+
const errors = lines.filter((line) => /^npm (error|ERR!)/.test(line)).map((line) => line.replace(/^npm (error|ERR!)\s*/, ''));
|
|
940
|
+
return errors.find(meaningful) || lines.find(meaningful) || '';
|
|
1802
941
|
}
|
|
1803
942
|
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
943
|
+
/**
|
|
944
|
+
* Keep package-lock.json in step with the mirrored @notis/sdk so `npm ci`
|
|
945
|
+
* accepts it. npm runs only when the lock's packages/sdk record differs from
|
|
946
|
+
* the mirrored manifest (`npm install --package-lock-only`: the lock only,
|
|
947
|
+
* no scripts, node_modules untouched). A failure, such as an offline
|
|
948
|
+
* machine, is a warning: the sync and the build or pull that ran it continue,
|
|
949
|
+
* and the next sync retries.
|
|
950
|
+
*/
|
|
951
|
+
export function reconcileSdkLockfile(projectDir, { manifest, log = null, updateLock = updatePackageLock } = {}) {
|
|
952
|
+
const emit = typeof log === 'function' ? log : (message) => process.stderr.write(`${message}\n`);
|
|
953
|
+
const lockPath = join(projectDir, 'package-lock.json');
|
|
954
|
+
const readLock = () => JSON.parse(readFileSync(lockPath, 'utf-8'));
|
|
955
|
+
let project, lock;
|
|
956
|
+
try {
|
|
957
|
+
project = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'));
|
|
958
|
+
lock = readLock();
|
|
959
|
+
} catch {
|
|
960
|
+
return 'absent'; // No npm lock to keep, or one npm itself reports as unreadable.
|
|
961
|
+
}
|
|
962
|
+
const spec = project?.dependencies?.['@notis/sdk'] ?? project?.devDependencies?.['@notis/sdk'];
|
|
963
|
+
if (typeof spec !== 'string' || !/^file:(\.\/)?packages\/sdk\/?$/.test(spec)) return 'unlinked';
|
|
964
|
+
if (!lock?.packages || typeof lock.packages !== 'object') return 'unsupported'; // lockfileVersion 1
|
|
965
|
+
if (lockRecordsManifest(lock.packages['packages/sdk'], manifest)) return 'current';
|
|
966
|
+
const outcome = updateLock(projectDir);
|
|
967
|
+
let current = false;
|
|
968
|
+
try { current = lockRecordsManifest(readLock().packages?.['packages/sdk'], manifest); } catch { /* Reported below. */ }
|
|
969
|
+
if (outcome.ok && current) {
|
|
970
|
+
emit(`Updated package-lock.json for @notis/sdk ${manifest.version}. Run npm install to bring node_modules up to date.`);
|
|
971
|
+
return 'updated';
|
|
972
|
+
}
|
|
973
|
+
emit(`Warning: package-lock.json still records the previous @notis/sdk dependencies, so npm ci will refuse it${outcome.detail ? ` (${outcome.detail})` : ''}. `
|
|
974
|
+
+ 'Everything else continues; run npm install in this project once the npm registry is reachable.');
|
|
975
|
+
return 'stale';
|
|
976
|
+
}
|
|
1808
977
|
|
|
1809
978
|
function readStablePinnedFile(descriptor, expectedStat, label) {
|
|
1810
979
|
const before = fstatSync(descriptor, { bigint: true });
|
|
@@ -1850,10 +1019,6 @@ function copyScaffoldSource(sourceDir, targetDir) {
|
|
|
1850
1019
|
function shouldCopy(path) {
|
|
1851
1020
|
const name = path.split(/[\\/]/).pop();
|
|
1852
1021
|
if (!name) return true;
|
|
1853
|
-
const relPath = relative(sourceDir, path).replace(/\\/g, '/');
|
|
1854
|
-
if (SCAFFOLD_LISTING_MEDIA.test(relPath) || REGISTRY_ARTIFACTS.test(relPath)) {
|
|
1855
|
-
return false;
|
|
1856
|
-
}
|
|
1857
1022
|
return !SCAFFOLD_COPY_EXCLUDES.has(name)
|
|
1858
1023
|
&& !name.toLocaleLowerCase('en-US').startsWith('.notis-app-scaffold-')
|
|
1859
1024
|
&& !name.startsWith('.env')
|
|
@@ -1996,7 +1161,7 @@ function copyScaffoldSource(sourceDir, targetDir) {
|
|
|
1996
1161
|
export function collectArtifactFiles(projectDir) {
|
|
1997
1162
|
const outputDir = join(projectDir, OUTPUT_DIR);
|
|
1998
1163
|
if (!existsSync(outputDir)) {
|
|
1999
|
-
throw usageError('No build output found. Run "notis
|
|
1164
|
+
throw usageError('No build output found. Run "notis spaces build" first.');
|
|
2000
1165
|
}
|
|
2001
1166
|
|
|
2002
1167
|
validateArtifactBoundary(readArtifactFiles(projectDir));
|
|
@@ -2006,6 +1171,7 @@ export function collectArtifactFiles(projectDir) {
|
|
|
2006
1171
|
function walk(dir, prefix) {
|
|
2007
1172
|
const entries = readdirSync(dir, { withFileTypes: true });
|
|
2008
1173
|
for (const entry of entries) {
|
|
1174
|
+
if (entry.name.startsWith('.') || (!prefix && entry.name === 'verify.json')) continue;
|
|
2009
1175
|
const fullPath = join(dir, entry.name);
|
|
2010
1176
|
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
2011
1177
|
if (entry.isDirectory()) {
|
|
@@ -2026,6 +1192,7 @@ function shouldExcludeSourceEntry(name) {
|
|
|
2026
1192
|
SOURCE_COPY_EXCLUDES_CASEFOLDED.has(casefolded)
|
|
2027
1193
|
|| casefolded.startsWith('.notis-app-pull-')
|
|
2028
1194
|
|| casefolded.startsWith('.notis-app-scaffold-')
|
|
1195
|
+
|| casefolded.startsWith('._notis_config_')
|
|
2029
1196
|
|| casefolded.startsWith('.env')
|
|
2030
1197
|
|| casefolded.endsWith('.pyc')
|
|
2031
1198
|
|| casefolded.endsWith('.pyo')
|
|
@@ -2162,7 +1329,8 @@ function parsePaxPath(data) {
|
|
|
2162
1329
|
}
|
|
2163
1330
|
|
|
2164
1331
|
function extractTarGz(buffer, targetDir) {
|
|
2165
|
-
|
|
1332
|
+
// Source snapshots allow 256 MiB of files plus bounded tar/PAX metadata.
|
|
1333
|
+
const tar = gunzipSync(buffer, { maxOutputLength: 320 * 1024 * 1024 });
|
|
2166
1334
|
let offset = 0;
|
|
2167
1335
|
let pendingPaxPath = null;
|
|
2168
1336
|
const extractedPaths = new Map();
|
|
@@ -2261,7 +1429,7 @@ function capturePullTargetIdentity(targetDir) {
|
|
|
2261
1429
|
try {
|
|
2262
1430
|
const targetStat = lstatSync(targetDir, { bigint: true });
|
|
2263
1431
|
if (targetStat.isSymbolicLink() || !targetStat.isDirectory()) {
|
|
2264
|
-
throw usageError(`Refusing to pull
|
|
1432
|
+
throw usageError(`Refusing to pull Space source through an unsafe target: ${targetDir}`);
|
|
2265
1433
|
}
|
|
2266
1434
|
return { exists: true, dev: targetStat.dev, ino: targetStat.ino };
|
|
2267
1435
|
} catch (error) {
|
|
@@ -2273,7 +1441,7 @@ function capturePullTargetIdentity(targetDir) {
|
|
|
2273
1441
|
function assertPullTargetIdentity(targetDir, expected) {
|
|
2274
1442
|
const current = capturePullTargetIdentity(targetDir);
|
|
2275
1443
|
if (!pullTargetIdentitiesMatch(current, expected)) {
|
|
2276
|
-
throw usageError(`
|
|
1444
|
+
throw usageError(`Space source pull target changed while the download was in progress: ${targetDir}`);
|
|
2277
1445
|
}
|
|
2278
1446
|
}
|
|
2279
1447
|
|
|
@@ -2289,7 +1457,7 @@ function assertPullParentIdentity(parentDir, canonicalParent, expectedIdentity)
|
|
|
2289
1457
|
try {
|
|
2290
1458
|
currentCanonicalParent = realpathSync(parentDir);
|
|
2291
1459
|
} catch {
|
|
2292
|
-
throw usageError(`
|
|
1460
|
+
throw usageError(`Space source pull parent changed while the download was in progress: ${parentDir}`);
|
|
2293
1461
|
}
|
|
2294
1462
|
if (
|
|
2295
1463
|
currentCanonicalParent !== canonicalParent
|
|
@@ -2298,7 +1466,7 @@ function assertPullParentIdentity(parentDir, canonicalParent, expectedIdentity)
|
|
|
2298
1466
|
expectedIdentity,
|
|
2299
1467
|
)
|
|
2300
1468
|
) {
|
|
2301
|
-
throw usageError(`
|
|
1469
|
+
throw usageError(`Space source pull parent changed while the download was in progress: ${parentDir}`);
|
|
2302
1470
|
}
|
|
2303
1471
|
}
|
|
2304
1472
|
|
|
@@ -2319,7 +1487,7 @@ async function withPullTargetLock(targetDir, callback) {
|
|
|
2319
1487
|
capturePullTargetIdentity(canonicalTarget),
|
|
2320
1488
|
)
|
|
2321
1489
|
) {
|
|
2322
|
-
throw usageError(`
|
|
1490
|
+
throw usageError(`Space source pull target changed before locking: ${requestedTarget}`);
|
|
2323
1491
|
}
|
|
2324
1492
|
const lockDirectory = join(canonicalParent, `.${basename(canonicalTarget)}.notis-pull-lock`);
|
|
2325
1493
|
const ownerId = `${process.pid}.${randomUUID()}`;
|
|
@@ -2344,11 +1512,11 @@ async function withPullTargetLock(targetDir, callback) {
|
|
|
2344
1512
|
throw statError;
|
|
2345
1513
|
}
|
|
2346
1514
|
if (lockStat.isSymbolicLink() || !lockStat.isDirectory()) {
|
|
2347
|
-
throw usageError(`Refusing to use unsafe
|
|
1515
|
+
throw usageError(`Refusing to use unsafe Space source pull lock: ${lockDirectory}`);
|
|
2348
1516
|
}
|
|
2349
1517
|
if (Date.now() >= deadline) {
|
|
2350
1518
|
throw usageError(
|
|
2351
|
-
`Timed out waiting to pull
|
|
1519
|
+
`Timed out waiting to pull Space source into ${targetDir}. `
|
|
2352
1520
|
+ `If no pull process is running, remove the orphaned lock: ${lockDirectory}`,
|
|
2353
1521
|
);
|
|
2354
1522
|
}
|
|
@@ -2359,7 +1527,7 @@ async function withPullTargetLock(targetDir, callback) {
|
|
|
2359
1527
|
try {
|
|
2360
1528
|
const assertOwnership = () => {
|
|
2361
1529
|
if (readPullLockOwner(lockDirectory)?.id !== ownerId) {
|
|
2362
|
-
throw usageError(`Lost the
|
|
1530
|
+
throw usageError(`Lost the Space source pull lock for ${targetDir}.`);
|
|
2363
1531
|
}
|
|
2364
1532
|
};
|
|
2365
1533
|
const assertParentIdentity = () => assertPullParentIdentity(
|
|
@@ -2370,7 +1538,7 @@ async function withPullTargetLock(targetDir, callback) {
|
|
|
2370
1538
|
assertParentIdentity();
|
|
2371
1539
|
const lockedTargetIdentity = capturePullTargetIdentity(canonicalTarget);
|
|
2372
1540
|
if (!pullTargetIdentitiesMatch(initialRequestedIdentity, lockedTargetIdentity)) {
|
|
2373
|
-
throw usageError(`
|
|
1541
|
+
throw usageError(`Space source pull target changed before locking: ${requestedTarget}`);
|
|
2374
1542
|
}
|
|
2375
1543
|
return await callback(
|
|
2376
1544
|
assertOwnership,
|
|
@@ -2389,6 +1557,28 @@ async function withPullTargetLock(targetDir, callback) {
|
|
|
2389
1557
|
}
|
|
2390
1558
|
}
|
|
2391
1559
|
|
|
1560
|
+
function assertPullStatePathSafe(projectDir) {
|
|
1561
|
+
const notisDir = join(projectDir, NOTIS_DIR);
|
|
1562
|
+
try {
|
|
1563
|
+
const directoryStat = lstatSync(notisDir);
|
|
1564
|
+
if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
|
|
1565
|
+
throw usageError(`Refusing to use unsafe Notis state directory: ${notisDir}`);
|
|
1566
|
+
}
|
|
1567
|
+
} catch (error) {
|
|
1568
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
const statePath = join(projectDir, SPACE_SOURCE_FILE);
|
|
1572
|
+
try {
|
|
1573
|
+
const stateStat = lstatSync(statePath);
|
|
1574
|
+
if (stateStat.isSymbolicLink() || !stateStat.isFile()) {
|
|
1575
|
+
throw usageError(`Refusing to use unsafe Notis state file: ${statePath}`);
|
|
1576
|
+
}
|
|
1577
|
+
} catch (error) {
|
|
1578
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
2392
1582
|
export async function pullAppSource(args) {
|
|
2393
1583
|
const targetDir = resolve(args.targetDir);
|
|
2394
1584
|
return withPullTargetLock(
|
|
@@ -2408,55 +1598,39 @@ export async function pullAppSource(args) {
|
|
|
2408
1598
|
}
|
|
2409
1599
|
|
|
2410
1600
|
async function pullAppSourceUnlocked({
|
|
2411
|
-
apiBase,
|
|
2412
|
-
jwt,
|
|
2413
|
-
appId,
|
|
2414
1601
|
targetDir,
|
|
2415
1602
|
version = 'latest',
|
|
2416
1603
|
force = false,
|
|
2417
|
-
|
|
2418
|
-
|
|
1604
|
+
downloadSource,
|
|
1605
|
+
sourceLink,
|
|
2419
1606
|
}, assertLockOwnership, assertParentIdentity, initialTargetIdentity) {
|
|
2420
1607
|
assertLockOwnership();
|
|
2421
1608
|
assertParentIdentity();
|
|
2422
1609
|
const targetWasNonEmpty = existsSync(targetDir) && readdirSync(targetDir).length > 0;
|
|
2423
1610
|
if (targetWasNonEmpty) {
|
|
2424
1611
|
if (!force) {
|
|
2425
|
-
throw usageError(`Target directory is not empty: ${targetDir}.
|
|
1612
|
+
throw usageError(`Target directory is not empty: ${targetDir}. Pull into a new or empty directory.`);
|
|
2426
1613
|
}
|
|
2427
1614
|
}
|
|
2428
1615
|
|
|
2429
|
-
const
|
|
2430
|
-
const response = await fetch(`${apiBase.replace(/\/$/, '')}/portal_apps/source?${params.toString()}`, {
|
|
2431
|
-
headers: { Authorization: `Bearer ${jwt}` },
|
|
2432
|
-
});
|
|
2433
|
-
if (!response.ok) {
|
|
2434
|
-
const data = await response.json().catch(() => ({}));
|
|
2435
|
-
throw usageError(typeof data?.error === 'string' ? data.error : `Failed to pull app source (${response.status}).`);
|
|
2436
|
-
}
|
|
2437
|
-
|
|
2438
|
-
const contentDisposition = response.headers.get('content-disposition') || '';
|
|
2439
|
-
const versionMatch = /-v(\d+)\.tar\.gz/i.exec(contentDisposition);
|
|
2440
|
-
const pulledVersion = versionMatch ? Number.parseInt(versionMatch[1], 10) : null;
|
|
1616
|
+
const { archiveBuffer, revision: pulledVersion } = await downloadSource();
|
|
2441
1617
|
const requestedVersion = String(version || 'latest') === 'latest'
|
|
2442
1618
|
? null
|
|
2443
1619
|
: Number.parseInt(String(version), 10);
|
|
2444
1620
|
if (!Number.isInteger(pulledVersion) || pulledVersion <= 0) {
|
|
2445
|
-
throw usageError('The
|
|
1621
|
+
throw usageError('The Space source response did not identify a valid positive version.');
|
|
2446
1622
|
}
|
|
2447
1623
|
if (requestedVersion !== null && (!Number.isInteger(requestedVersion) || requestedVersion <= 0)) {
|
|
2448
|
-
throw usageError(`Invalid
|
|
1624
|
+
throw usageError(`Invalid Space source version: ${version}`);
|
|
2449
1625
|
}
|
|
2450
1626
|
if (
|
|
2451
1627
|
requestedVersion !== null
|
|
2452
1628
|
&& pulledVersion !== requestedVersion
|
|
2453
1629
|
) {
|
|
2454
1630
|
throw usageError(
|
|
2455
|
-
`Requested
|
|
1631
|
+
`Requested Space source version ${requestedVersion}, but the server returned version ${pulledVersion}.`,
|
|
2456
1632
|
);
|
|
2457
1633
|
}
|
|
2458
|
-
const linkedVersion = pulledVersion;
|
|
2459
|
-
const archiveBuffer = Buffer.from(await response.arrayBuffer());
|
|
2460
1634
|
assertParentIdentity();
|
|
2461
1635
|
assertLockOwnership();
|
|
2462
1636
|
|
|
@@ -2489,7 +1663,7 @@ async function pullAppSourceUnlocked({
|
|
|
2489
1663
|
mkdirSync(targetName);
|
|
2490
1664
|
} catch (error) {
|
|
2491
1665
|
if (error?.code === 'EEXIST') {
|
|
2492
|
-
throw usageError(`
|
|
1666
|
+
throw usageError(`Space source pull target changed while the download was in progress: ${targetDir}`);
|
|
2493
1667
|
}
|
|
2494
1668
|
throw error;
|
|
2495
1669
|
}
|
|
@@ -2502,13 +1676,13 @@ async function pullAppSourceUnlocked({
|
|
|
2502
1676
|
|| activeTargetIdentity.ino !== initialTargetIdentity.ino
|
|
2503
1677
|
)
|
|
2504
1678
|
) {
|
|
2505
|
-
throw usageError(`
|
|
1679
|
+
throw usageError(`Space source pull target changed while the download was in progress: ${targetDir}`);
|
|
2506
1680
|
}
|
|
2507
1681
|
assertLockOwnership();
|
|
2508
1682
|
process.chdir(targetName);
|
|
2509
1683
|
assertPullTargetIdentity('.', activeTargetIdentity);
|
|
2510
1684
|
const targetRoot = '.';
|
|
2511
|
-
|
|
1685
|
+
assertPullStatePathSafe(targetRoot);
|
|
2512
1686
|
const preservedPaths = collectPullPreservedPaths(targetRoot);
|
|
2513
1687
|
|
|
2514
1688
|
const transactionName = basename(mkdtempSync(join('.', '.notis-app-pull-')));
|
|
@@ -2524,11 +1698,11 @@ async function pullAppSourceUnlocked({
|
|
|
2524
1698
|
extractTarGz(archiveBuffer, stageDir);
|
|
2525
1699
|
const stagedEntries = readdirSync(stageDir);
|
|
2526
1700
|
if (stagedEntries.length === 0) {
|
|
2527
|
-
throw usageError('The
|
|
1701
|
+
throw usageError('The Space source archive did not contain any editable source files.');
|
|
2528
1702
|
}
|
|
2529
1703
|
|
|
2530
|
-
const previousStatePath = join(targetRoot,
|
|
2531
|
-
const backupStatePath = join(backupDir,
|
|
1704
|
+
const previousStatePath = join(targetRoot, SPACE_SOURCE_FILE);
|
|
1705
|
+
const backupStatePath = join(backupDir, SPACE_SOURCE_FILE);
|
|
2532
1706
|
const hadPreviousState = existsSync(previousStatePath);
|
|
2533
1707
|
if (hadPreviousState) {
|
|
2534
1708
|
mkdirSync(dirname(backupStatePath), { recursive: true });
|
|
@@ -2578,12 +1752,13 @@ async function pullAppSourceUnlocked({
|
|
|
2578
1752
|
}
|
|
2579
1753
|
assertPullTargetIdentity(targetDir, activeTargetIdentity);
|
|
2580
1754
|
assertLockOwnership();
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
1755
|
+
withAppReleaseWorkspace(targetRoot, () => {
|
|
1756
|
+
const temporary = `.space-source-${randomUUID()}.json`;
|
|
1757
|
+
try {
|
|
1758
|
+
writeFileSync(temporary, JSON.stringify({ ...sourceLink, revision: pulledVersion }), { flag: 'wx', mode: 0o600 });
|
|
1759
|
+
renameSync(temporary, basename(SPACE_SOURCE_FILE));
|
|
1760
|
+
} finally { rmSync(temporary, { force: true }); }
|
|
1761
|
+
}, { create: true });
|
|
2587
1762
|
assertPullTargetIdentity(targetDir, activeTargetIdentity);
|
|
2588
1763
|
assertLockOwnership();
|
|
2589
1764
|
} catch (error) {
|
|
@@ -2657,19 +1832,19 @@ async function pullAppSourceUnlocked({
|
|
|
2657
1832
|
);
|
|
2658
1833
|
}
|
|
2659
1834
|
throw usageError(
|
|
2660
|
-
`
|
|
1835
|
+
`Space source was updated, but transaction cleanup failed; recovery files were retained at `
|
|
2661
1836
|
+ `${transactionDisplayPath}: ${cleanupMessage}`,
|
|
2662
1837
|
);
|
|
2663
1838
|
}
|
|
2664
1839
|
}
|
|
2665
1840
|
|
|
2666
|
-
return { projectDir: targetDir, version: pulledVersion
|
|
1841
|
+
return { projectDir: targetDir, version: pulledVersion };
|
|
2667
1842
|
}
|
|
2668
1843
|
|
|
2669
1844
|
function readArtifactFiles(projectDir) {
|
|
2670
1845
|
const outputDir = join(projectDir, OUTPUT_DIR);
|
|
2671
1846
|
if (!existsSync(outputDir)) {
|
|
2672
|
-
throw usageError('No build output found. Run "notis
|
|
1847
|
+
throw usageError('No build output found. Run "notis spaces build" first.');
|
|
2673
1848
|
}
|
|
2674
1849
|
|
|
2675
1850
|
const files = {};
|
|
@@ -2691,233 +1866,238 @@ function readArtifactFiles(projectDir) {
|
|
|
2691
1866
|
return files;
|
|
2692
1867
|
}
|
|
2693
1868
|
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
1869
|
+
/** Fingerprint exact source/artifact bytes, independent of directory enumeration order. */
|
|
1870
|
+
export function appFilesDigest(files) {
|
|
1871
|
+
return createHash('sha256').update(JSON.stringify(Object.keys(files).sort().map((key) => [key, files[key]]))).digest('hex');
|
|
1872
|
+
}
|
|
2697
1873
|
|
|
2698
|
-
/**
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
const
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
1874
|
+
/** Run synchronous release bookkeeping relative to a pinned, real .notis directory. */
|
|
1875
|
+
function withAppReleaseWorkspace(projectDir, operation, { create = false, expected = null } = {}) {
|
|
1876
|
+
const canonicalProjectDir = expected?.projectDir || realpathSync(projectDir);
|
|
1877
|
+
const projectIdentity = expected?.projectIdentity || capturePullTargetIdentity(canonicalProjectDir);
|
|
1878
|
+
const previousCwd = process.cwd();
|
|
1879
|
+
const previousIdentity = capturePullTargetIdentity(previousCwd);
|
|
1880
|
+
try {
|
|
1881
|
+
process.chdir(canonicalProjectDir);
|
|
1882
|
+
assertPullTargetIdentity('.', projectIdentity);
|
|
1883
|
+
let notisIdentity = capturePullTargetIdentity(NOTIS_DIR);
|
|
1884
|
+
if (!notisIdentity.exists && create) {
|
|
1885
|
+
mkdirSync(NOTIS_DIR);
|
|
1886
|
+
notisIdentity = capturePullTargetIdentity(NOTIS_DIR);
|
|
1887
|
+
}
|
|
1888
|
+
if (!notisIdentity.exists) throw usageError('No build workspace found. Run notis spaces build first.');
|
|
1889
|
+
if (expected) assertPullTargetIdentity(NOTIS_DIR, expected.notisIdentity);
|
|
1890
|
+
process.chdir(NOTIS_DIR);
|
|
1891
|
+
assertPullTargetIdentity('.', notisIdentity);
|
|
1892
|
+
const value = operation({ projectDir: canonicalProjectDir, projectIdentity, notisIdentity });
|
|
1893
|
+
assertPullTargetIdentity('.', notisIdentity);
|
|
1894
|
+
assertPullTargetIdentity(canonicalProjectDir, projectIdentity);
|
|
1895
|
+
assertPullTargetIdentity(join(canonicalProjectDir, NOTIS_DIR), notisIdentity);
|
|
1896
|
+
return value;
|
|
1897
|
+
} finally {
|
|
1898
|
+
process.chdir(previousCwd);
|
|
1899
|
+
assertPullTargetIdentity('.', previousIdentity);
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
function freezePinnedArtifactSnapshot(workspace, files, sourceFiles) {
|
|
1904
|
+
for (const [source, entries] of [[true, sourceFiles], [false, files]]) {
|
|
1905
|
+
for (const [path, encoded] of Object.entries(entries)) {
|
|
1906
|
+
const segments = path.split('/');
|
|
1907
|
+
if (!path || path.includes('\\') || path.includes('\0') || segments.some(part => !part || part === '.' || part === '..')
|
|
1908
|
+
|| (source && segments.some(part => ['.notis', '.context', '.git', 'node_modules'].includes(part) || part.startsWith('.env')))
|
|
1909
|
+
|| typeof encoded !== 'string') throw usageError('Unsafe frozen source or artifact path.');
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
// Nest under the source project so imports resolve its installed dependencies.
|
|
1913
|
+
// Every staging write is relative to pinned directories, never a replaced parent path.
|
|
1914
|
+
const frozenName = mkdtempSync('release-');
|
|
1915
|
+
const frozenIdentity = capturePullTargetIdentity(frozenName);
|
|
1916
|
+
const frozenDir = join(workspace.projectDir, NOTIS_DIR, frozenName);
|
|
1917
|
+
let closed = false;
|
|
1918
|
+
const close = () => {
|
|
1919
|
+
process.removeListener('exit', close);
|
|
1920
|
+
if (closed) return;
|
|
1921
|
+
withAppReleaseWorkspace(workspace.projectDir, () => {
|
|
1922
|
+
assertPullTargetIdentity(frozenName, frozenIdentity);
|
|
1923
|
+
rmSync(frozenName, { recursive: true, force: true });
|
|
1924
|
+
}, { expected: workspace });
|
|
1925
|
+
closed = true;
|
|
1926
|
+
};
|
|
1927
|
+
function writeEntry(segments, content) {
|
|
1928
|
+
const name = segments[0];
|
|
1929
|
+
if (segments.length === 1) {
|
|
1930
|
+
writeFileSync(name, content, { flag: 'wx' });
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
const parentIdentity = capturePullTargetIdentity('.');
|
|
1934
|
+
let identity = capturePullTargetIdentity(name);
|
|
1935
|
+
if (!identity.exists) {
|
|
1936
|
+
mkdirSync(name);
|
|
1937
|
+
identity = capturePullTargetIdentity(name);
|
|
1938
|
+
}
|
|
1939
|
+
process.chdir(name);
|
|
1940
|
+
try {
|
|
1941
|
+
assertPullTargetIdentity('.', identity);
|
|
1942
|
+
writeEntry(segments.slice(1), content);
|
|
1943
|
+
} finally {
|
|
1944
|
+
process.chdir('..');
|
|
1945
|
+
assertPullTargetIdentity('.', parentIdentity);
|
|
2730
1946
|
}
|
|
2731
1947
|
}
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
1948
|
+
try {
|
|
1949
|
+
process.chdir(frozenName);
|
|
1950
|
+
try {
|
|
1951
|
+
assertPullTargetIdentity('.', frozenIdentity);
|
|
1952
|
+
for (const [path, encoded] of Object.entries(sourceFiles)) {
|
|
1953
|
+
writeEntry(path.split('/'), Buffer.from(encoded, 'base64'));
|
|
1954
|
+
}
|
|
1955
|
+
for (const [path, encoded] of Object.entries(files)) {
|
|
1956
|
+
writeEntry([NOTIS_DIR, 'output', ...path.split('/')], Buffer.from(encoded, 'base64'));
|
|
1957
|
+
}
|
|
1958
|
+
} finally {
|
|
1959
|
+
process.chdir('..');
|
|
1960
|
+
assertPullTargetIdentity('.', workspace.notisIdentity);
|
|
1961
|
+
}
|
|
1962
|
+
const manifest = files['manifest.json'] ? JSON.parse(Buffer.from(files['manifest.json'], 'base64').toString('utf8')) : null;
|
|
1963
|
+
process.once('exit', close);
|
|
1964
|
+
return { projectDir: frozenDir, files, sourceFiles, manifest, close };
|
|
1965
|
+
} catch (error) {
|
|
1966
|
+
try { close(); } catch { /* Retain owned staging if its parent identity changed. */ }
|
|
1967
|
+
throw error;
|
|
2744
1968
|
}
|
|
2745
|
-
|
|
2746
|
-
return { supabaseUrl, supabaseKey };
|
|
2747
1969
|
}
|
|
2748
1970
|
|
|
2749
|
-
/**
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
const url = `${supabaseUrl}/storage/v1/object/${bucket}/${storagePath}`;
|
|
2754
|
-
const response = await fetch(url, {
|
|
2755
|
-
method: 'POST',
|
|
2756
|
-
headers: {
|
|
2757
|
-
'Authorization': `Bearer ${supabaseKey}`,
|
|
2758
|
-
'Content-Type': contentType,
|
|
2759
|
-
'x-upsert': 'true',
|
|
2760
|
-
},
|
|
2761
|
-
body: content,
|
|
2762
|
-
});
|
|
1971
|
+
/** Freeze a selected source slice using the same pinned-directory writer as releases. */
|
|
1972
|
+
export function freezeSourceWorkspace(projectDir, sourceFiles) {
|
|
1973
|
+
return withAppReleaseWorkspace(projectDir, workspace => freezePinnedArtifactSnapshot(workspace, {}, sourceFiles), { create: true });
|
|
1974
|
+
}
|
|
2763
1975
|
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
}
|
|
1976
|
+
function selectedBuildName(key) {
|
|
1977
|
+
if (typeof key !== 'string' || !/^[a-z][a-z0-9_-]{0,99}$/.test(key)) throw usageError('Choose a valid selected build key.');
|
|
1978
|
+
return `space-build-${key}.json`;
|
|
2768
1979
|
}
|
|
2769
1980
|
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
return;
|
|
2781
|
-
}
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
1981
|
+
/** Immutable bytes, not another authoring project or installed dependency tree. */
|
|
1982
|
+
export function saveSelectedBuild(projectDir, key, release) {
|
|
1983
|
+
return withAppReleaseWorkspace(projectDir, () => {
|
|
1984
|
+
const name = selectedBuildName(key), temporary = `.${name}-${randomUUID()}`;
|
|
1985
|
+
const snapshot = { version: 1, key, files: release.files, sourceFiles: release.sourceFiles,
|
|
1986
|
+
source_hash: appFilesDigest(release.sourceFiles), artifact_hash: appFilesDigest(release.files) };
|
|
1987
|
+
try {
|
|
1988
|
+
writeFileSync(temporary, JSON.stringify(snapshot), { flag: 'wx', mode: 0o600 });
|
|
1989
|
+
renameSync(temporary, name);
|
|
1990
|
+
} finally { rmSync(temporary, { force: true }); }
|
|
1991
|
+
return join(projectDir, NOTIS_DIR, name);
|
|
1992
|
+
}, { create: true });
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
export function prepareSelectedBuild(projectDir, key, sourceFiles) {
|
|
1996
|
+
return withAppReleaseWorkspace(projectDir, workspace => {
|
|
1997
|
+
const name = selectedBuildName(key);
|
|
1998
|
+
let before;
|
|
1999
|
+
try { before = lstatSync(name, { bigint: true }); } catch (error) {
|
|
2000
|
+
if (error.code === 'ENOENT') throw usageError('Selected Space build is missing or stale. Build it again.');
|
|
2001
|
+
throw error;
|
|
2002
|
+
}
|
|
2003
|
+
if (!before.isFile() || before.isSymbolicLink()) throw usageError('Unsafe selected build receipt.');
|
|
2004
|
+
const descriptor = openSync(name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
2005
|
+
let snapshot;
|
|
2006
|
+
try { snapshot = JSON.parse(readStablePinnedFile(descriptor, before, 'Selected build receipt').toString('utf8')); }
|
|
2007
|
+
finally { closeSync(descriptor); }
|
|
2008
|
+
if (snapshot.version !== 1 || snapshot.key !== key || !snapshot.files || !snapshot.sourceFiles
|
|
2009
|
+
|| snapshot.source_hash !== appFilesDigest(sourceFiles) || snapshot.source_hash !== appFilesDigest(snapshot.sourceFiles)
|
|
2010
|
+
|| snapshot.artifact_hash !== appFilesDigest(snapshot.files)) throw usageError('Selected Space build is missing or stale. Build it again.');
|
|
2011
|
+
validateArtifactBoundary(Object.fromEntries(Object.entries(snapshot.files).map(([path, encoded]) => [path, Buffer.from(encoded, 'base64')])));
|
|
2012
|
+
return freezePinnedArtifactSnapshot(workspace, snapshot.files, snapshot.sourceFiles);
|
|
2798
2013
|
});
|
|
2799
|
-
if (createResponse.ok) {
|
|
2800
|
-
return;
|
|
2801
|
-
}
|
|
2802
|
-
|
|
2803
|
-
const text = await createResponse.text().catch(() => '');
|
|
2804
|
-
if (createResponse.status === 409 || /already exists|duplicate/i.test(text)) {
|
|
2805
|
-
return;
|
|
2806
|
-
}
|
|
2807
|
-
throw new Error(`Failed to create storage bucket ${bucket} (${createResponse.status}): ${text}`);
|
|
2808
2014
|
}
|
|
2809
2015
|
|
|
2810
|
-
/**
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
const response = await fetch(url, {
|
|
2817
|
-
headers: {
|
|
2818
|
-
'Authorization': `Bearer ${supabaseKey}`,
|
|
2819
|
-
'apikey': supabaseKey,
|
|
2820
|
-
},
|
|
2016
|
+
/** A diagnostic, never an approval stamp or a replacement for exact-byte checks. */
|
|
2017
|
+
export function saveSpaceVerification(projectDir, diagnostic) {
|
|
2018
|
+
return withAppReleaseWorkspace(projectDir, (workspace) => {
|
|
2019
|
+
const name = `space-verification-${randomUUID()}.json`;
|
|
2020
|
+
writeFileSync(name, JSON.stringify(diagnostic, null, 2), { flag: 'wx', mode: 0o600 });
|
|
2021
|
+
return join(workspace.projectDir, '.notis', name);
|
|
2821
2022
|
});
|
|
2822
|
-
if (!response.ok) throw new Error(`Failed to get app version: ${response.status}`);
|
|
2823
|
-
const rows = await response.json();
|
|
2824
|
-
if (!rows.length) throw usageError(`App ${appId} not found.`);
|
|
2825
|
-
const manifest = rows[0].manifest || {};
|
|
2826
|
-
return { currentVersion: manifest.version || 0, manifest };
|
|
2827
2023
|
}
|
|
2828
2024
|
|
|
2829
|
-
/**
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
updated_at: new Date().toISOString(),
|
|
2847
|
-
}),
|
|
2025
|
+
/** Retain the original CAS after an uncertain publish response, across CLI exits. */
|
|
2026
|
+
export function spacePublicationIntent(projectDir, identity, create = null) {
|
|
2027
|
+
const name = `space-publish-${createHash('sha256').update(JSON.stringify(identity)).digest('hex')}.json`;
|
|
2028
|
+
return withAppReleaseWorkspace(projectDir, () => {
|
|
2029
|
+
if (create) {
|
|
2030
|
+
const temporary = `.${name}-${randomUUID()}`;
|
|
2031
|
+
try {
|
|
2032
|
+
writeFileSync(temporary, JSON.stringify(create), { flag: 'wx', mode: 0o600 });
|
|
2033
|
+
try { linkSync(temporary, name); } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
2034
|
+
} finally { rmSync(temporary, { force: true }); }
|
|
2035
|
+
}
|
|
2036
|
+
let before;
|
|
2037
|
+
try { before = lstatSync(name, { bigint: true }); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
2038
|
+
if (!before.isFile() || before.isSymbolicLink()) throw usageError('Unsafe Space publication receipt.');
|
|
2039
|
+
const descriptor = openSync(name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
2040
|
+
try { return JSON.parse(readStablePinnedFile(descriptor, before, 'Space publication receipt').toString('utf8')); }
|
|
2041
|
+
finally { closeSync(descriptor); }
|
|
2848
2042
|
});
|
|
2849
|
-
if (!response.ok) {
|
|
2850
|
-
const text = await response.text().catch(() => '');
|
|
2851
|
-
throw new Error(`Failed to update app version: ${response.status} ${text}`);
|
|
2852
|
-
}
|
|
2853
2043
|
}
|
|
2854
2044
|
|
|
2855
|
-
|
|
2856
|
-
'
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
'.html': 'text/html',
|
|
2860
|
-
'.png': 'image/png',
|
|
2861
|
-
'.jpg': 'image/jpeg',
|
|
2862
|
-
'.jpeg': 'image/jpeg',
|
|
2863
|
-
};
|
|
2864
|
-
|
|
2865
|
-
/**
|
|
2866
|
-
* Deploy app bundle directly to Supabase storage, bypassing the backend server.
|
|
2867
|
-
*/
|
|
2868
|
-
export async function directDeploy(projectDir, appId) {
|
|
2869
|
-
const { supabaseUrl, supabaseKey } = resolveSupabaseCredentials();
|
|
2870
|
-
const manifest = readManifest(projectDir);
|
|
2871
|
-
const artifactFiles = readArtifactFiles(projectDir);
|
|
2872
|
-
const sourceFiles = readSourceFiles(projectDir);
|
|
2873
|
-
validateArtifactBoundary(artifactFiles);
|
|
2045
|
+
function spaceStateFileName(name) {
|
|
2046
|
+
if (typeof name !== 'string' || !/^[a-z][a-z0-9-]*\.json$/.test(name)) throw usageError('Unsafe local Space state file name.');
|
|
2047
|
+
return name;
|
|
2048
|
+
}
|
|
2874
2049
|
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2050
|
+
/** Read one local-only JSON state file under the pinned .notis directory; null when absent. */
|
|
2051
|
+
export function readSpaceStateFile(projectDir, name) {
|
|
2052
|
+
spaceStateFileName(name);
|
|
2053
|
+
if (!existsSync(join(resolve(projectDir), NOTIS_DIR))) return null;
|
|
2054
|
+
return withAppReleaseWorkspace(projectDir, () => {
|
|
2055
|
+
let before;
|
|
2056
|
+
try { before = lstatSync(name, { bigint: true }); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
2057
|
+
if (!before.isFile() || before.isSymbolicLink()) throw usageError(`Unsafe local Space state file: ${name}.`);
|
|
2058
|
+
const descriptor = openSync(name, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
2059
|
+
try { return JSON.parse(readStablePinnedFile(descriptor, before, `Space state file ${name}`).toString('utf8')); }
|
|
2060
|
+
finally { closeSync(descriptor); }
|
|
2061
|
+
});
|
|
2062
|
+
}
|
|
2878
2063
|
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2064
|
+
/** Replace one local-only JSON state file atomically inside the pinned .notis directory. */
|
|
2065
|
+
export function writeSpaceStateFile(projectDir, name, value) {
|
|
2066
|
+
spaceStateFileName(name);
|
|
2067
|
+
return withAppReleaseWorkspace(projectDir, (workspace) => {
|
|
2068
|
+
const temporary = `.${name}-${randomUUID()}`;
|
|
2069
|
+
try {
|
|
2070
|
+
writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
2071
|
+
renameSync(temporary, name);
|
|
2072
|
+
} finally { rmSync(temporary, { force: true }); }
|
|
2073
|
+
return join(workspace.projectDir, NOTIS_DIR, name);
|
|
2074
|
+
}, { create: true });
|
|
2075
|
+
}
|
|
2883
2076
|
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
const
|
|
2894
|
-
|
|
2895
|
-
const
|
|
2896
|
-
|
|
2077
|
+
/** Capture one checked build. Verification and upload must use these same bytes. */
|
|
2078
|
+
export function prepareAppRelease(projectDir) {
|
|
2079
|
+
let release;
|
|
2080
|
+
try {
|
|
2081
|
+
return withAppReleaseWorkspace(projectDir, (workspace) => {
|
|
2082
|
+
const sourceFiles = collectSourceFiles(workspace.projectDir);
|
|
2083
|
+
const files = collectArtifactFiles(workspace.projectDir);
|
|
2084
|
+
let receipt;
|
|
2085
|
+
try {
|
|
2086
|
+
const before = lstatSync('build-receipt.json', { bigint: true });
|
|
2087
|
+
if (!before.isFile() || before.isSymbolicLink()) throw usageError('Unsafe build receipt.');
|
|
2088
|
+
const descriptor = openSync('build-receipt.json', fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
2089
|
+
try { receipt = JSON.parse(readStablePinnedFile(descriptor, before, 'Build receipt').toString('utf8')); }
|
|
2090
|
+
finally { closeSync(descriptor); }
|
|
2091
|
+
} catch (error) {
|
|
2092
|
+
if (error?.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
|
|
2897
2093
|
}
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
const storagePath = `${appId}/v${newVersion}/${relPath}`;
|
|
2907
|
-
await uploadToStorage(supabaseUrl, supabaseKey, 'app-source', storagePath, content, contentType);
|
|
2094
|
+
if (receipt?.source_hash !== appFilesDigest(sourceFiles) || receipt?.artifact_hash !== appFilesDigest(files)) {
|
|
2095
|
+
throw usageError('Build output is missing or stale. Run notis spaces build before deploying.');
|
|
2096
|
+
}
|
|
2097
|
+
return release = freezePinnedArtifactSnapshot(workspace, files, sourceFiles);
|
|
2098
|
+
});
|
|
2099
|
+
} catch (error) {
|
|
2100
|
+
try { release?.close(); } catch { /* Parent replacement retains staging, never follows it. */ }
|
|
2101
|
+
throw error;
|
|
2908
2102
|
}
|
|
2909
|
-
|
|
2910
|
-
// Update the app record -- include storage_prefix so the portal can resolve bundle URLs
|
|
2911
|
-
const storagePrefix = `${appId}/v${newVersion}/`;
|
|
2912
|
-
const deployManifest = {
|
|
2913
|
-
...manifest,
|
|
2914
|
-
version: newVersion,
|
|
2915
|
-
storage_bucket: bucket,
|
|
2916
|
-
storage_prefix: storagePrefix,
|
|
2917
|
-
source_storage_bucket: 'app-source',
|
|
2918
|
-
source_storage_prefix: storagePrefix,
|
|
2919
|
-
};
|
|
2920
|
-
await updateAppVersion(supabaseUrl, supabaseKey, appId, newVersion, deployManifest);
|
|
2921
|
-
|
|
2922
|
-
return { version: newVersion };
|
|
2923
2103
|
}
|