@notis_ai/cli 0.2.0-beta.19.1 → 0.2.0-beta.196.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +822 -135
- package/bin/check-runtime.js +15 -0
- package/bin/notis.js +2 -0
- package/config/notis_app_boundary_rules.json +50 -0
- package/config/notis_app_design_rules.json +135 -0
- package/config/portal-route-roots.json +48 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +45587 -0
- package/dist/base-skills/notis-apps/SKILL.md +93 -0
- package/dist/base-skills/notis-apps/references/architecture.md +114 -0
- package/dist/base-skills/notis-apps/references/context.md +81 -0
- package/dist/base-skills/notis-apps/references/design.md +97 -0
- package/dist/base-skills/notis-apps/references/memory.md +58 -0
- package/dist/base-skills/notis-apps/references/reading.md +67 -0
- package/dist/base-skills/notis-apps/references/release.md +107 -0
- package/dist/base-skills/notis-apps/references/sdk.md +116 -0
- package/dist/base-skills/notis-apps/references/troubleshooting.md +24 -0
- package/dist/base-skills/notis-apps/references/views.md +142 -0
- package/dist/base-skills/notis-cli/SKILL.md +154 -0
- package/dist/base-skills/notis-cli/references/app-delivery.md +19 -0
- package/dist/base-skills/notis-cli/references/intelligence.md +94 -0
- package/dist/base-skills/notis-cli/references/native-databases.md +21 -0
- package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
- package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
- package/dist/base-skills/notis-query/SKILL.md +41 -0
- package/dist/base-skills/notis-query/references/database-discovery.md +60 -0
- package/dist/base-skills/notis-query/references/documents.md +63 -0
- package/dist/base-skills/notis-query/references/query.md +76 -0
- package/{template/packages/notis-sdk → dist/sdk}/package.json +14 -4
- package/dist/sdk/src/agentContext.ts +36 -0
- package/dist/sdk/src/bundleModule.ts +79 -0
- package/dist/sdk/src/components/Dialog.tsx +46 -0
- package/dist/sdk/src/components/DocumentEditor.tsx +106 -0
- package/dist/sdk/src/components/DocumentPage.tsx +141 -0
- package/dist/sdk/src/components/Markdown.tsx +60 -0
- package/dist/sdk/src/components/MarkdownEditor.tsx +121 -0
- package/dist/sdk/src/components/MultiSelectActionBar.tsx +285 -0
- package/dist/sdk/src/components/MultiSelectCheckbox.tsx +97 -0
- package/dist/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
- package/dist/sdk/src/components/NotisCommentBoundary.tsx +172 -0
- package/dist/sdk/src/components/NotisSelectionBoundary.tsx +59 -0
- package/dist/sdk/src/components/RecordComponents.tsx +33 -0
- package/dist/sdk/src/components/RenderChartData.tsx +19 -0
- package/dist/sdk/src/components/ShortcutHints.tsx +56 -0
- package/dist/sdk/src/components/Skeleton.tsx +24 -0
- package/dist/sdk/src/config.ts +239 -0
- package/dist/sdk/src/documents.ts +258 -0
- package/dist/sdk/src/hooks/useAction.ts +28 -0
- package/dist/sdk/src/hooks/useActiveResource.ts +19 -0
- package/dist/sdk/src/hooks/useAgentContext.ts +23 -0
- package/dist/sdk/src/hooks/useCloudComputer.ts +95 -0
- package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
- package/dist/sdk/src/hooks/useCollectionInteractions.ts +838 -0
- package/dist/sdk/src/hooks/useDatabaseSchema.ts +49 -0
- package/dist/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
- package/dist/sdk/src/hooks/useDocument.ts +43 -0
- package/dist/sdk/src/hooks/useDocuments.ts +84 -0
- package/dist/sdk/src/hooks/useHandover.ts +78 -0
- package/dist/sdk/src/hooks/useLongPressSelection.ts +79 -0
- package/dist/sdk/src/hooks/useMultiSelect.ts +95 -0
- package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
- package/dist/sdk/src/hooks/useNativeDocuments.ts +83 -0
- package/dist/sdk/src/hooks/useNotis.ts +42 -0
- package/dist/sdk/src/hooks/useNotisNavigation.ts +67 -0
- package/dist/sdk/src/hooks/useQuery.ts +74 -0
- package/dist/sdk/src/hooks/useTool.ts +65 -0
- package/dist/sdk/src/hooks/useToolQuery.ts +12 -0
- package/dist/sdk/src/hooks/useTopBarSearch.ts +81 -0
- package/dist/sdk/src/hooks/useUpsertDocument.ts +95 -0
- package/dist/sdk/src/hooks/useViewParams.ts +121 -0
- package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
- package/dist/sdk/src/index.ts +200 -0
- package/dist/sdk/src/interactions/actions.ts +59 -0
- package/dist/sdk/src/interactions/shortcuts.tsx +740 -0
- package/dist/sdk/src/interactions/visibility.ts +13 -0
- package/dist/sdk/src/interactions.ts +45 -0
- package/dist/sdk/src/nativeData.ts +39 -0
- package/dist/sdk/src/presentation.tsx +82 -0
- package/dist/sdk/src/provider.tsx +49 -0
- package/dist/sdk/src/queryCache.ts +172 -0
- package/dist/sdk/src/queryErrors.ts +51 -0
- package/dist/sdk/src/runtime.ts +603 -0
- package/dist/sdk/src/space.ts +425 -0
- package/dist/sdk/src/styles.css +290 -0
- package/dist/sdk/src/tailwind.ts +66 -0
- package/dist/sdk/src/vite.ts +83 -0
- package/dist/skill-sync/index.js +2143 -0
- package/dist/skill-sync/index.js.map +7 -0
- package/dist/skill-sync-worker.mjs +3487 -0
- package/dist/space-harness/host.js +66 -0
- package/dist/view-renderer/dist/host.css +1 -0
- package/dist/view-renderer/dist/host.js +1042 -0
- package/dist/view-renderer/dist/theme.css +73 -0
- package/dist/view-renderer/src/dom.js +180 -0
- package/dist/view-renderer/src/errors.js +8 -0
- package/dist/view-renderer/src/host.tsx +173 -0
- package/dist/view-renderer/src/index.js +4 -0
- package/dist/view-renderer/src/pool.js +76 -0
- package/dist/view-renderer/src/remote-broker.js +59 -0
- package/dist/view-renderer/src/renderer.js +201 -0
- package/package.json +25 -8
- package/skills/notis-apps/cli.md +406 -0
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
- package/skills/notis-onboarding/BRIEF.md +133 -0
- package/skills/notis-query/cli.md +39 -0
- package/src/agent-hook-entry.js +5 -0
- package/src/cli.js +294 -25
- package/src/command-specs/agents.js +392 -0
- package/src/command-specs/apps.js +1476 -203
- package/src/command-specs/auth.js +114 -137
- package/src/command-specs/diagnostics.js +730 -0
- package/src/command-specs/files.js +29 -0
- package/src/command-specs/handover.js +374 -0
- package/src/command-specs/helpers.js +86 -82
- package/src/command-specs/index.js +31 -6
- package/src/command-specs/meta.js +150 -18
- package/src/command-specs/onboarding.js +290 -0
- package/src/command-specs/profile.js +358 -0
- package/src/command-specs/skills.js +304 -0
- package/src/command-specs/smoke.js +386 -0
- package/src/command-specs/spaces.js +572 -0
- package/src/command-specs/tools.js +455 -139
- package/src/command-specs/views.js +69 -0
- package/src/runtime/agent-browser.js +700 -0
- package/src/runtime/agent-memory-state.js +126 -0
- package/src/runtime/agent-setup.js +383 -0
- package/src/runtime/app-boundary-validator.js +407 -0
- package/src/runtime/app-changelog.js +79 -0
- package/src/runtime/app-data-literals.js +82 -0
- package/src/runtime/app-platform.js +2752 -237
- package/src/runtime/app-registry-scaffolds.js +367 -0
- package/src/runtime/app-test-server.js +293 -0
- package/src/runtime/assets/store-screenshot-dark.png +0 -0
- package/src/runtime/auth-recovery.js +110 -0
- package/src/runtime/base-skills.d.ts +20 -0
- package/src/runtime/base-skills.js +167 -0
- package/src/runtime/channel.js +133 -0
- package/src/runtime/confined-space-build.js +74 -0
- package/src/runtime/confined-space-verify.js +49 -0
- package/src/runtime/delegated-context.js +68 -0
- package/src/runtime/errors.js +1 -0
- package/src/runtime/git.js +233 -0
- package/src/runtime/login-listener.js +15 -0
- package/src/runtime/native-record-files.js +47 -0
- package/src/runtime/oauth.js +2622 -0
- package/src/runtime/output.js +37 -5
- package/src/runtime/ports.js +31 -0
- package/src/runtime/profiles.js +906 -55
- package/src/runtime/retired-sdk-files.js +20 -0
- package/src/runtime/screenshot-framing.js +85 -0
- package/src/runtime/skill-sync/cloud-client.ts +121 -0
- package/src/runtime/skill-sync/index.ts +905 -0
- package/src/runtime/skill-sync/local-scanner.ts +1259 -0
- package/src/runtime/skill-sync/native-identity.ts +102 -0
- package/src/runtime/skill-sync/native-plan.ts +59 -0
- package/src/runtime/skill-sync/symlink-manager.ts +433 -0
- package/src/runtime/skill-sync/sync-plan.ts +54 -0
- package/src/runtime/skill-sync/types.ts +157 -0
- package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
- package/src/runtime/skill-sync-service.js +109 -0
- package/src/runtime/source-root.js +32 -0
- package/src/runtime/space-collection-scaffold.js +66 -0
- package/src/runtime/space-database-structures.js +229 -0
- package/src/runtime/space-fixture-broker.js +50 -0
- package/src/runtime/space-lock.js +337 -0
- package/src/runtime/space-platform.js +105 -0
- package/src/runtime/space-source.js +281 -0
- package/src/runtime/space-test-server.js +327 -0
- package/src/runtime/space-verification.js +89 -0
- package/src/runtime/space-verify-browser-worker.js +51 -0
- package/src/runtime/space-view-manifest.js +254 -0
- package/src/runtime/store-screenshot.js +146 -0
- package/src/runtime/sync-skills.d.ts +37 -0
- package/src/runtime/sync-skills.js +231 -0
- package/src/runtime/telemetry.js +92 -0
- package/src/runtime/transport.js +331 -45
- package/src/runtime/view-render.js +49 -0
- package/src/skill-sync-worker-entry.js +2 -0
- package/src/skill-sync-worker.js +50 -0
- package/src/space-harness-host.ts +103 -0
- package/template/.harness/index.html.tmpl +435 -0
- package/template/CHANGELOG.md +5 -0
- package/template/app/globals.css +29 -3
- package/template/app/layout.tsx +6 -3
- package/template/app/page.tsx +49 -42
- package/template/components/page-heading.tsx +23 -0
- package/template/components/ui/badge.tsx +7 -4
- package/template/components/ui/button.tsx +1 -1
- package/template/components/ui/card.tsx +24 -11
- package/template/components/ui/native-select.tsx +24 -0
- package/template/notis.config.ts +24 -6
- package/template/package-lock.json +3642 -0
- package/template/package.json +19 -16
- package/template/postcss.config.mjs +1 -1
- package/template/tailwind.config.ts +1 -6
- package/template/tsconfig.json +1 -0
- package/src/command-specs/db.js +0 -163
- package/src/runtime/app-preview-server.js +0 -312
- package/template/packages/notis-sdk/src/config.ts +0 -48
- package/template/packages/notis-sdk/src/helpers.ts +0 -131
- package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
- package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
- package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
- package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
- package/template/packages/notis-sdk/src/hooks/useNotis.ts +0 -31
- package/template/packages/notis-sdk/src/hooks/useNotisNavigation.ts +0 -49
- package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
- package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
- package/template/packages/notis-sdk/src/index.ts +0 -47
- package/template/packages/notis-sdk/src/provider.tsx +0 -44
- package/template/packages/notis-sdk/src/runtime.ts +0 -159
- package/template/packages/notis-sdk/src/styles.css +0 -123
- package/template/packages/notis-sdk/src/vite.ts +0 -54
- package/template/packages/notis-sdk/tsconfig.json +0 -15
- /package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useBackend.ts +0 -0
- /package/{template/packages/notis-sdk → dist/sdk}/src/hooks/useTools.ts +0 -0
- /package/{template/packages/notis-sdk → dist/sdk}/src/ui.ts +0 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
export interface AgentTargets {
|
|
2
|
+
notis: boolean;
|
|
3
|
+
claude_code: boolean;
|
|
4
|
+
cursor: boolean;
|
|
5
|
+
codex: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface NativeSkillReference {
|
|
9
|
+
kind: 'native_standalone_skill';
|
|
10
|
+
skill_id: string;
|
|
11
|
+
access_revision: number;
|
|
12
|
+
version: { revision: number; digest: string };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SyncedSkill {
|
|
16
|
+
cloudId: string;
|
|
17
|
+
nativeReference?: NativeSkillReference;
|
|
18
|
+
nativeDisplayName?: string;
|
|
19
|
+
nativeScope?: string;
|
|
20
|
+
nativeSettingsRevision?: number;
|
|
21
|
+
/** Retain managed provenance even when its cloud access is removed. */
|
|
22
|
+
nativeUnavailable?: boolean;
|
|
23
|
+
folderHash: string;
|
|
24
|
+
agentTargets: AgentTargets;
|
|
25
|
+
/** Actual readable managed links, never inferred from desired targets. */
|
|
26
|
+
verifiedAgentLinks?: Partial<AgentTargets>;
|
|
27
|
+
cloudUpdatedAt?: string;
|
|
28
|
+
/** Content accepted on disk; independent of server-specific folder hash formats. */
|
|
29
|
+
cloudContentHash?: string;
|
|
30
|
+
/** Relative paths the last successful write put in this folder. Anything else in
|
|
31
|
+
* the folder was created locally (a running skill's output) and survives the next
|
|
32
|
+
* cloud write; anything listed here that the cloud later drops is deleted. */
|
|
33
|
+
appliedFiles?: string[];
|
|
34
|
+
/** `skill_folder_hash` of the cloud revision a successful write actually applied.
|
|
35
|
+
* The server may compute that hash with a different construction than the local
|
|
36
|
+
* folder hash (bundle-entry hashes do), so only cloud-to-cloud comparison tells
|
|
37
|
+
* "the cloud moved" apart from "the two sides hash differently". */
|
|
38
|
+
appliedCloudFolderHash?: string;
|
|
39
|
+
syncedAt: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface NotisSyncState {
|
|
43
|
+
version: 1;
|
|
44
|
+
lastSyncedAt: string | null;
|
|
45
|
+
skills: Record<string, SyncedSkill>;
|
|
46
|
+
/** Historical folder aliases are provenance, never authority or write pins. */
|
|
47
|
+
nativeHistory?: Record<string, { names: string[] }>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A successful write, plus the paths it put on disk so the next one can tell
|
|
51
|
+
* cloud-managed files apart from whatever ran inside the folder since. */
|
|
52
|
+
export interface SkillWriteOutcome {
|
|
53
|
+
written: true;
|
|
54
|
+
appliedFiles: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface LocalSkill {
|
|
58
|
+
name: string;
|
|
59
|
+
skillMd: string;
|
|
60
|
+
description: string;
|
|
61
|
+
folderHash: string;
|
|
62
|
+
directoryPath: string;
|
|
63
|
+
sourceUrl?: string;
|
|
64
|
+
bundleFiles?: BundleFile[];
|
|
65
|
+
cloudId?: string;
|
|
66
|
+
nativeReference?: NativeSkillReference;
|
|
67
|
+
displayName?: string;
|
|
68
|
+
nativeScope?: string;
|
|
69
|
+
nativeFilesCurrent?: boolean;
|
|
70
|
+
nativeBaseFolderHash?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface BundleFile {
|
|
74
|
+
path: string;
|
|
75
|
+
content_b64: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface CloudSkill {
|
|
79
|
+
updated_at?: string;
|
|
80
|
+
id: string;
|
|
81
|
+
name: string;
|
|
82
|
+
description: string | null;
|
|
83
|
+
skill_md: string | null;
|
|
84
|
+
agent_targets?: Partial<AgentTargets> | null;
|
|
85
|
+
skill_folder_hash?: string | null;
|
|
86
|
+
skill_source_url?: string | null;
|
|
87
|
+
bundle_files?: BundleFile[] | null;
|
|
88
|
+
bundle_hydration_failed?: boolean | null;
|
|
89
|
+
source: string;
|
|
90
|
+
status: string;
|
|
91
|
+
native_reference?: NativeSkillReference;
|
|
92
|
+
display_name?: string;
|
|
93
|
+
native_scope?: string;
|
|
94
|
+
native_settings?: { revision: number; enabled: boolean; agent_targets: AgentTargets; updated_at: string };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface SyncSettings {
|
|
98
|
+
skill_sync_protocol?: number;
|
|
99
|
+
sync_scope?: string;
|
|
100
|
+
sync_namespace?: string | null;
|
|
101
|
+
agent_targets_conditional_updates?: boolean;
|
|
102
|
+
/** Server-verified canonical Notis account id used to scope local mirrors. */
|
|
103
|
+
user_id?: string;
|
|
104
|
+
sync_enabled: boolean;
|
|
105
|
+
last_synced_at: string | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface EntitlementAccessDetail {
|
|
109
|
+
status: "error";
|
|
110
|
+
code: "entitlement_upgrade_required";
|
|
111
|
+
upgrade_required: true;
|
|
112
|
+
entitlement: string;
|
|
113
|
+
required_plan: string;
|
|
114
|
+
upgrade_url: string;
|
|
115
|
+
message: string;
|
|
116
|
+
next_action: Record<string, unknown>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface SyncPullResponse {
|
|
120
|
+
skill_sync_protocol?: number;
|
|
121
|
+
sync_scope?: string;
|
|
122
|
+
sync_namespace?: string | null;
|
|
123
|
+
skills: CloudSkill[];
|
|
124
|
+
sync_enabled: boolean;
|
|
125
|
+
last_synced_at: string | null;
|
|
126
|
+
/** Legacy servers could return a successful empty denial. Current servers
|
|
127
|
+
* return HTTP 403; either form must preserve existing local state. */
|
|
128
|
+
entitlement_access?: EntitlementAccessDetail;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A skill the server could not sync, with a user-facing reason (e.g. an invalid
|
|
132
|
+
* SKILL.md description rejected by OpenAI). One bad skill no longer fails the batch. */
|
|
133
|
+
export interface SkillSyncFailure {
|
|
134
|
+
name: string;
|
|
135
|
+
error: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface SyncPushResponse {
|
|
139
|
+
skills: unknown[];
|
|
140
|
+
failed?: SkillSyncFailure[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const DEFAULT_AGENT_TARGETS: AgentTargets = {
|
|
144
|
+
notis: true,
|
|
145
|
+
claude_code: true,
|
|
146
|
+
cursor: true,
|
|
147
|
+
codex: true,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export function normalizeAgentTargets(targets?: Partial<AgentTargets> | null): AgentTargets {
|
|
151
|
+
return {
|
|
152
|
+
notis: Boolean(targets?.notis ?? DEFAULT_AGENT_TARGETS.notis),
|
|
153
|
+
claude_code: Boolean(targets?.claude_code ?? DEFAULT_AGENT_TARGETS.claude_code),
|
|
154
|
+
cursor: Boolean(targets?.cursor ?? DEFAULT_AGENT_TARGETS.cursor),
|
|
155
|
+
codex: Boolean(targets?.codex ?? DEFAULT_AGENT_TARGETS.codex),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { CloudSkill, SkillWriteOutcome } from './types';
|
|
2
|
+
|
|
3
|
+
interface WriteCloudSkillDependencies {
|
|
4
|
+
downloadSkillBundle: (bundleUrl: string) => Promise<Buffer>;
|
|
5
|
+
writeCloudSkillToDisk: (skill: CloudSkill, bundleBytes?: Buffer) => Promise<SkillWriteOutcome | boolean>;
|
|
6
|
+
onWarning?: (message: string, error: unknown) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function writeCloudSkillWithBundleFallback(
|
|
10
|
+
skill: CloudSkill,
|
|
11
|
+
dependencies: WriteCloudSkillDependencies,
|
|
12
|
+
): Promise<SkillWriteOutcome | boolean> {
|
|
13
|
+
if (skill.skill_source_url) {
|
|
14
|
+
try {
|
|
15
|
+
const bundleBytes = await dependencies.downloadSkillBundle(skill.skill_source_url);
|
|
16
|
+
const wroteBundleToDisk = await dependencies.writeCloudSkillToDisk(skill, bundleBytes);
|
|
17
|
+
if (wroteBundleToDisk) {
|
|
18
|
+
return wroteBundleToDisk;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
dependencies.onWarning?.(
|
|
22
|
+
`Bundle sync for "${skill.name}" produced no local changes, falling back to SKILL.md payload.`,
|
|
23
|
+
new Error('Bundle write returned false'),
|
|
24
|
+
);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
dependencies.onWarning?.(
|
|
27
|
+
`Failed to apply bundle sync for "${skill.name}", falling back to SKILL.md payload.`,
|
|
28
|
+
error,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (skill.bundle_hydration_failed) {
|
|
34
|
+
dependencies.onWarning?.(
|
|
35
|
+
`Skipping markdown fallback for synced skill "${skill.name}" because its stored bundle could not be hydrated by the server.`,
|
|
36
|
+
new Error('Bundle hydration failed'),
|
|
37
|
+
);
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
return await dependencies.writeCloudSkillToDisk(skill);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
dependencies.onWarning?.(
|
|
45
|
+
`Failed to write synced skill "${skill.name}" to disk.`,
|
|
46
|
+
error,
|
|
47
|
+
);
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { isValidProfileName, loadConfig } from './profiles.js';
|
|
8
|
+
import { withSkillSyncLock } from './sync-skills.js';
|
|
9
|
+
|
|
10
|
+
export const SKILL_SYNC_SERVICE_LABEL = 'ai.notis.skills-sync';
|
|
11
|
+
const bundlePath = fileURLToPath(new URL('../../dist/skill-sync-worker.mjs', import.meta.url));
|
|
12
|
+
|
|
13
|
+
function atomicWrite(target, value, mode = 0o600) {
|
|
14
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
15
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
16
|
+
writeFileSync(temporary, value, { mode });
|
|
17
|
+
renameSync(temporary, target);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function xml(value) {
|
|
21
|
+
return String(value).replaceAll('&', '&').replaceAll('<', '<')
|
|
22
|
+
.replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function maybeInstallSkillSyncService(runtime, {
|
|
26
|
+
config = loadConfig(), install = installSkillSyncService,
|
|
27
|
+
fetchSettings, refresh,
|
|
28
|
+
} = {}) {
|
|
29
|
+
if (process.platform !== 'darwin' || runtime.credentialKind !== 'oauth'
|
|
30
|
+
|| config.current_profile !== runtime.profileName
|
|
31
|
+
|| !['https://api.notis.ai', 'https://api-beta.notis.ai'].includes(runtime.apiBase)) return;
|
|
32
|
+
// Ordinary commands must not steal another explicitly bound account's job.
|
|
33
|
+
const plist = join(homedir(), 'Library', 'LaunchAgents', `${SKILL_SYNC_SERVICE_LABEL}.plist`);
|
|
34
|
+
if (existsSync(plist)) {
|
|
35
|
+
const saved = readFileSync(plist, 'utf8');
|
|
36
|
+
if (runtime.oauthUserId && [runtime.profileName, runtime.apiBase, runtime.oauthUserId]
|
|
37
|
+
.every(value => saved.includes(`<string>${xml(value)}</string>`))) return install(runtime);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const refreshCredential = refresh || (await import('./oauth.js')).ensureFreshOAuthCredential;
|
|
41
|
+
await refreshCredential(runtime);
|
|
42
|
+
const getSettings = fetchSettings || (await import('../../dist/skill-sync/index.js')).fetchSyncSettings;
|
|
43
|
+
const settings = await getSettings(runtime.apiBase, runtime.jwt);
|
|
44
|
+
if (settings.sync_enabled && settings.user_id === runtime.oauthUserId) return install(runtime);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function installSkillSyncService(runtime, options = {}) {
|
|
48
|
+
if ((options.platform || process.platform) !== 'darwin') return { status: 'unsupported_platform' };
|
|
49
|
+
if (runtime.credentialKind !== 'oauth' || !runtime.oauthUserId
|
|
50
|
+
|| !['https://api.notis.ai', 'https://api-beta.notis.ai'].includes(runtime.apiBase)) {
|
|
51
|
+
return { status: 'skipped_non_personal_profile' };
|
|
52
|
+
}
|
|
53
|
+
// Registration upgrades must not kill a worker holding the filesystem lock,
|
|
54
|
+
// nor race another CLI registering the same LaunchAgent.
|
|
55
|
+
return withSkillSyncLock(() => installSkillSyncServiceLocked(runtime, options),
|
|
56
|
+
options.home ? { home: options.home } : {});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function installSkillSyncServiceLocked(runtime, {
|
|
60
|
+
home = homedir(), platform = process.platform, nodePath = process.execPath,
|
|
61
|
+
source = bundlePath, run = spawnSync, uid = process.getuid?.(),
|
|
62
|
+
} = {}) {
|
|
63
|
+
// A single explicitly selected personal account owns global agent folders.
|
|
64
|
+
// Development and hosted credentials must never enroll a machine-wide job.
|
|
65
|
+
if (platform !== 'darwin') return { status: 'unsupported_platform' };
|
|
66
|
+
if (runtime.credentialKind !== 'oauth' || runtime.envCredentialOverride
|
|
67
|
+
|| !isValidProfileName(runtime.profileName)
|
|
68
|
+
|| !['https://api.notis.ai', 'https://api-beta.notis.ai'].includes(runtime.apiBase)) {
|
|
69
|
+
return { status: 'skipped_non_personal_profile' };
|
|
70
|
+
}
|
|
71
|
+
const root = join(home, '.notis', 'skills', 'service');
|
|
72
|
+
const bundle = readFileSync(source);
|
|
73
|
+
const digest = createHash('sha256').update(bundle).digest('hex');
|
|
74
|
+
const installedBundle = join(root, 'runtime', digest, 'worker.mjs');
|
|
75
|
+
if (!existsSync(installedBundle)
|
|
76
|
+
|| !readFileSync(installedBundle).equals(bundle)) atomicWrite(installedBundle, bundle, 0o500);
|
|
77
|
+
const args = [nodePath, installedBundle, runtime.profileName, runtime.apiBase, runtime.oauthUserId];
|
|
78
|
+
if (!runtime.oauthUserId) return { status: 'skipped_missing_account' };
|
|
79
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
80
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
81
|
+
<plist version="1.0"><dict>
|
|
82
|
+
<key>Label</key><string>${SKILL_SYNC_SERVICE_LABEL}</string>
|
|
83
|
+
<key>ProgramArguments</key><array>${args.map(value => `<string>${xml(value)}</string>`).join('')}</array>
|
|
84
|
+
<key>WorkingDirectory</key><string>${xml(root)}</string>
|
|
85
|
+
<key>StartInterval</key><integer>60</integer>
|
|
86
|
+
<key>ProcessType</key><string>Background</string>
|
|
87
|
+
</dict></plist>
|
|
88
|
+
`;
|
|
89
|
+
const plistPath = join(home, 'Library', 'LaunchAgents', `${SKILL_SYNC_SERVICE_LABEL}.plist`);
|
|
90
|
+
const domain = `gui/${uid}`;
|
|
91
|
+
const target = `${domain}/${SKILL_SYNC_SERVICE_LABEL}`;
|
|
92
|
+
const unchanged = existsSync(plistPath) && readFileSync(plistPath, 'utf8') === plist;
|
|
93
|
+
const loaded = run('/bin/launchctl', ['print', target], { encoding: 'utf8', timeout: 5000 });
|
|
94
|
+
if (unchanged && loaded.status === 0) return { status: 'installed', intervalSeconds: 60, profile: runtime.profileName };
|
|
95
|
+
if (loaded.status === 0) {
|
|
96
|
+
const stopped = run('/bin/launchctl', ['bootout', target], { encoding: 'utf8', timeout: 5000 });
|
|
97
|
+
if (stopped.status !== 0) throw new Error('Could not update the existing automatic skill sync job');
|
|
98
|
+
}
|
|
99
|
+
atomicWrite(plistPath, plist);
|
|
100
|
+
let started;
|
|
101
|
+
// launchd may finish tearing down an old registration after bootout returns.
|
|
102
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
103
|
+
started = run('/bin/launchctl', ['bootstrap', domain, plistPath], { encoding: 'utf8', timeout: 5000 });
|
|
104
|
+
if (started.status === 0) break;
|
|
105
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
106
|
+
}
|
|
107
|
+
if (started?.status !== 0) throw new Error('Could not register automatic skill sync with macOS');
|
|
108
|
+
return { status: 'installed', intervalSeconds: 60, profile: runtime.profileName };
|
|
109
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commands that write inside a source folder first prove the folder is one.
|
|
3
|
+
*
|
|
4
|
+
* 2026-10-02: `notis spaces build .` run from the Notis repository root treated
|
|
5
|
+
* the repository as a Space source, refreshed "its" embedded SDK and deleted the
|
|
6
|
+
* canonical packages/sdk/src/presentation.tsx. A source folder is identified by
|
|
7
|
+
* a marker at its root, checked before any file is read for writing, staged,
|
|
8
|
+
* refreshed or deleted.
|
|
9
|
+
*/
|
|
10
|
+
import { statSync } from 'node:fs';
|
|
11
|
+
import { join, resolve } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { usageError } from './errors.js';
|
|
14
|
+
|
|
15
|
+
// The workspace Space index (also a legacy App's config) or a pulled Space's lock.
|
|
16
|
+
export const SOURCE_ROOT_MARKERS = Object.freeze(['notis.config.ts', 'notis.config.js', 'notis.config.mjs', '.notis/space-lock.json']);
|
|
17
|
+
|
|
18
|
+
export function isSourceRoot(directory) {
|
|
19
|
+
return SOURCE_ROOT_MARKERS.some((name) => {
|
|
20
|
+
try { return statSync(join(directory, name)).isFile(); } catch { return false; }
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Return the resolved folder, or refuse before anything inside it is touched. */
|
|
25
|
+
export function assertSourceRoot(directory) {
|
|
26
|
+
const root = resolve(directory);
|
|
27
|
+
if (!isSourceRoot(root)) {
|
|
28
|
+
throw usageError(`${root} is not a Space source: it has no notis.config.ts (or .js, .mjs) and no .notis/space-lock.json `
|
|
29
|
+
+ 'at its root, so nothing was changed. Run the command in a Space source folder, or pass that folder.');
|
|
30
|
+
}
|
|
31
|
+
return root;
|
|
32
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { scaffoldProject } from './app-platform.js';
|
|
4
|
+
import { validateViewPath } from './space-view-manifest.js';
|
|
5
|
+
import { usageError } from './errors.js';
|
|
6
|
+
|
|
7
|
+
/** A local, editable collection layout. No remote database or Space mutation. */
|
|
8
|
+
export async function scaffoldSpaceCollection({ projectDir, name, databaseKey, path = 'records', titleProperty = 'title' }) {
|
|
9
|
+
if (typeof databaseKey !== 'string' || !databaseKey.trim() || databaseKey.length > 200) throw usageError('Pass the portable --database-key for the database this Space links.');
|
|
10
|
+
if (typeof name !== 'string' || !name.trim() || typeof titleProperty !== 'string' || !titleProperty.trim()) throw usageError('Choose a Space name and title property.');
|
|
11
|
+
validateViewPath(path);
|
|
12
|
+
await scaffoldProject({ projectDir, appName: name });
|
|
13
|
+
mkdirSync(join(projectDir, 'spaces', 'collection'), { recursive: true });
|
|
14
|
+
writeFileSync(join(projectDir, 'notis.config.ts'), `import { defineSpaces } from '@notis/sdk/config';
|
|
15
|
+
export default defineSpaces({ spaces: { collection: { definition: 'spaces/collection/space.config.ts' } } });\n`);
|
|
16
|
+
const definition = { specVersion: 2, name, description: `Browse and edit ${name} records.`, path, entry: './view.tsx', layout: '../../app/layout.tsx',
|
|
17
|
+
readableContext: `Browse ${name} records. The item parameter opens one record with its properties, collaborative body and sharing controls.`,
|
|
18
|
+
verificationFixtures: './verification.json',
|
|
19
|
+
resources: { items: { kind: 'database', key: databaseKey } },
|
|
20
|
+
collection: { database: 'items', titleProperty },
|
|
21
|
+
params: { item: { type: 'record', database: 'items', main: true, description: 'The record opened beside the list.' } },
|
|
22
|
+
shows: { items: { database: 'items', where: {}, open: 'item' } },
|
|
23
|
+
memory: { markdown: true, attachments: true, screenshot: false, snapshots: [] },
|
|
24
|
+
actions: { read: { tool: 'LOCAL_NOTIS_DATABASE_QUERY', inputs: { type: 'object', additionalProperties: false,
|
|
25
|
+
required: ['request'], properties: { request: { type: 'object' } } }, arguments: { database_id: { $asset: 'items' }, request: { $input: 'request' } } } },
|
|
26
|
+
};
|
|
27
|
+
writeFileSync(join(projectDir, 'spaces/collection/space.config.ts'), `import { defineSpace } from '@notis/sdk/config';\nexport default defineSpace(${JSON.stringify(definition, null, 2)});\n`);
|
|
28
|
+
writeFileSync(join(projectDir, 'spaces/collection/view.tsx'), COLLECTION_VIEW);
|
|
29
|
+
writeFileSync(join(projectDir, 'spaces/collection/verification.json'), JSON.stringify({
|
|
30
|
+
actions: {}, shown: { items: [{ params: {}, result: { rows: [], has_more: false, schema_revision: 1 } }] },
|
|
31
|
+
}, null, 2) + '\n');
|
|
32
|
+
return { projectDir, key: 'collection', databaseKey };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const COLLECTION_VIEW = `'use client';
|
|
36
|
+
import { DocumentPage, useNotis, useNotisNavigation, useNotisRuntime, usePrefetchRecord, useShown, useViewParams, ViewSkeleton } from '@notis/sdk';
|
|
37
|
+
import definition from './space.config';
|
|
38
|
+
import { Button } from '@/components/ui/button';
|
|
39
|
+
|
|
40
|
+
const copy = {
|
|
41
|
+
en: { empty: 'No records yet.', all: 'All records', untitled: 'Untitled', retry: 'Retry' },
|
|
42
|
+
fr: { empty: 'Aucun enregistrement pour le moment.', all: 'Tous les enregistrements', untitled: 'Sans titre', retry: 'Réessayer' },
|
|
43
|
+
};
|
|
44
|
+
export default function CollectionView() {
|
|
45
|
+
const runtime = useNotisRuntime();
|
|
46
|
+
const t = copy[runtime?.context.locale === 'fr' ? 'fr' : 'en'];
|
|
47
|
+
const { resource } = useNotis();
|
|
48
|
+
const navigation = useNotisNavigation();
|
|
49
|
+
const { params } = useViewParams<typeof definition>();
|
|
50
|
+
const { data, loading, error, refetch } = useShown<{ record_key: string; title: string | null }>('items');
|
|
51
|
+
const prefetch = usePrefetchRecord();
|
|
52
|
+
// A record is the main content: the host's document page (breadcrumb back here, title, properties, body).
|
|
53
|
+
if (params.item) return <DocumentPage recordKey={params.item} onSaved={refetch}
|
|
54
|
+
breadcrumb={[{ label: t.all, onSelect: () => resource && navigation.toSpace(resource.id) }]} />;
|
|
55
|
+
return <main className="notis-app-shell space-y-2">
|
|
56
|
+
{error ? <div role="alert">{error.message}<Button onClick={refetch}>{t.retry}</Button></div> : null}
|
|
57
|
+
{loading ? <ViewSkeleton variant="table" rows={5} /> : null}
|
|
58
|
+
{data?.rows.length === 0 ? <p className="text-sm text-muted-foreground">{t.empty}</p> : null}
|
|
59
|
+
{data?.rows.map(record => <Button key={record.record_key} variant="ghost" className="w-full justify-start truncate"
|
|
60
|
+
onPointerEnter={() => prefetch(record.record_key)} onFocus={() => prefetch(record.record_key)}
|
|
61
|
+
onClick={() => resource && navigation.toSpace(resource.id, { recordKey: record.record_key })}>
|
|
62
|
+
{record.title || t.untitled}
|
|
63
|
+
</Button>)}
|
|
64
|
+
</main>;
|
|
65
|
+
}
|
|
66
|
+
`;
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/** Refresh only pulled database creation declarations; never execute authored code. */
|
|
2
|
+
import { parse } from '@babel/parser';
|
|
3
|
+
import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { usageError } from './errors.js';
|
|
6
|
+
|
|
7
|
+
const nameOf = node => node?.type === 'Identifier' ? node.name : node?.type === 'StringLiteral' ? node.value : null;
|
|
8
|
+
|
|
9
|
+
export function refreshDatabaseStructures(projectDir, localKey, structures) {
|
|
10
|
+
return refreshSpaceAuthoring(projectDir, localKey, { databaseStructures: structures });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Exact current-host overrides for copied source. Archives remain byte-preserved. */
|
|
14
|
+
export function refreshSpaceAuthoring(projectDir, localKey, { databaseStructures, authoringOverrides } = {}) {
|
|
15
|
+
let rewritten = new Map();
|
|
16
|
+
if (Object.keys(databaseStructures || {}).length) rewritten = planSpaceAuthoring(projectDir, localKey, { databaseStructures }, rewritten);
|
|
17
|
+
if (authoringOverrides) rewritten = planSpaceAuthoring(projectDir, localKey, { authoringOverrides }, rewritten);
|
|
18
|
+
// Both stages must succeed before mutating even one downloaded file.
|
|
19
|
+
for (const [file, source] of rewritten) writeFileSync(file, source);
|
|
20
|
+
return [...rewritten.keys()].map(file => relative(realpathSync(projectDir), file).split(sep).join('/'));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function planSpaceAuthoring(projectDir, localKey, { databaseStructures: structures, authoringOverrides: overrides }, staged) {
|
|
24
|
+
const root = realpathSync(projectDir), modules = new Map();
|
|
25
|
+
const fail = () => usageError('The pulled database declaration is not statically addressable. Keep resources and create declarations as objects, local constants or relative imports; the downloaded source has not been rewritten.');
|
|
26
|
+
function load(file) {
|
|
27
|
+
file = realpathSync(file);
|
|
28
|
+
if (!file.startsWith(root + sep) || file.includes(`${sep}node_modules${sep}`)) throw fail();
|
|
29
|
+
if (modules.has(file)) return modules.get(file);
|
|
30
|
+
const source = staged.get(file) ?? readFileSync(file, 'utf8');
|
|
31
|
+
const ast = parse(source, { sourceType: 'module', plugins: ['typescript', 'jsx'] });
|
|
32
|
+
const unit = { file, source, ast, bindings: new Map(), exports: new Map() };
|
|
33
|
+
modules.set(file, unit);
|
|
34
|
+
for (const statement of ast.program.body) {
|
|
35
|
+
const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement;
|
|
36
|
+
if (declaration?.type === 'VariableDeclaration') {
|
|
37
|
+
for (const item of declaration.declarations) if (item.id.type === 'Identifier' && item.init) {
|
|
38
|
+
unit.bindings.set(item.id.name, { node: item.init, unit });
|
|
39
|
+
if (statement.type === 'ExportNamedDeclaration') unit.exports.set(item.id.name, { node: item.init, unit });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (statement.type === 'ImportDeclaration') {
|
|
43
|
+
for (const item of statement.specifiers) unit.bindings.set(item.local.name, {
|
|
44
|
+
imported: item.type === 'ImportDefaultSpecifier' ? 'default' : item.type === 'ImportNamespaceSpecifier' ? '*' : nameOf(item.imported),
|
|
45
|
+
from: statement.source.value, unit,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (statement.type === 'ExportDefaultDeclaration') unit.exports.set('default', { node: statement.declaration, unit });
|
|
49
|
+
if (statement.type === 'ExportNamedDeclaration') for (const item of statement.specifiers || []) {
|
|
50
|
+
unit.exports.set(nameOf(item.exported), statement.source
|
|
51
|
+
? { imported: nameOf(item.local), from: statement.source.value, unit }
|
|
52
|
+
: { node: item.local, unit });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return unit;
|
|
56
|
+
}
|
|
57
|
+
function imported(reference) {
|
|
58
|
+
if (!reference.from?.startsWith('.')) throw fail();
|
|
59
|
+
const base = resolve(dirname(reference.unit.file), reference.from);
|
|
60
|
+
const file = ['', '.ts', '.tsx', '.js', '.mjs', '/index.ts', '/index.js'].map(suffix => base + suffix).find(existsSync);
|
|
61
|
+
if (!file) throw fail();
|
|
62
|
+
return load(file);
|
|
63
|
+
}
|
|
64
|
+
function unwrap(reference, depth = 0) {
|
|
65
|
+
if (!reference || depth > 50) throw fail();
|
|
66
|
+
if (reference.from) {
|
|
67
|
+
if (reference.imported === '*') return { namespace: imported(reference) };
|
|
68
|
+
return unwrap(imported(reference).exports.get(reference.imported), depth + 1);
|
|
69
|
+
}
|
|
70
|
+
const { node, unit } = reference;
|
|
71
|
+
if (node.type === 'Identifier') return unwrap(unit.bindings.get(node.name), depth + 1);
|
|
72
|
+
if (['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'ParenthesizedExpression', 'TSTypeAssertion'].includes(node.type)) {
|
|
73
|
+
return unwrap({ node: node.expression, unit }, depth + 1);
|
|
74
|
+
}
|
|
75
|
+
if (node.type === 'CallExpression' && node.arguments.length === 1 && node.callee.type === 'Identifier') {
|
|
76
|
+
const helper = unit.bindings.get(node.callee.name);
|
|
77
|
+
if (helper?.from === '@notis/sdk/config' && ['defineSpace', 'defineSpaces'].includes(helper.imported)) {
|
|
78
|
+
return unwrap({ node: node.arguments[0], unit }, depth + 1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (node.type === 'MemberExpression' && (!node.computed || node.property.type === 'StringLiteral')) {
|
|
82
|
+
const object = unwrap({ node: node.object, unit }, depth + 1);
|
|
83
|
+
return object.namespace ? unwrap(object.namespace.exports.get(nameOf(node.property)), depth + 1)
|
|
84
|
+
: unwrap(property(object, nameOf(node.property), depth + 1), depth + 1);
|
|
85
|
+
}
|
|
86
|
+
return reference;
|
|
87
|
+
}
|
|
88
|
+
function property(reference, name, depth = 0) {
|
|
89
|
+
const object = unwrap(reference, depth + 1);
|
|
90
|
+
if (object.node?.type !== 'ObjectExpression') throw fail();
|
|
91
|
+
// Later properties/spreads win, exactly as the authored object does.
|
|
92
|
+
for (const entry of [...object.node.properties].reverse()) {
|
|
93
|
+
if (entry.type === 'SpreadElement') {
|
|
94
|
+
const found = property({ node: entry.argument, unit: object.unit }, name, depth + 1);
|
|
95
|
+
if (found) return found;
|
|
96
|
+
} else if (entry.type === 'ObjectProperty' && !entry.computed && nameOf(entry.key) === name) {
|
|
97
|
+
return { node: entry.value, unit: object.unit };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
function scalar(reference) {
|
|
103
|
+
const value = unwrap(reference);
|
|
104
|
+
if (value.node?.type !== 'StringLiteral') throw fail();
|
|
105
|
+
return value.node.value;
|
|
106
|
+
}
|
|
107
|
+
const indexFile = ['notis.config.ts', 'notis.config.js', 'notis.config.mjs'].map(file => join(root, file)).find(existsSync);
|
|
108
|
+
if (!indexFile) throw fail();
|
|
109
|
+
const index = load(indexFile);
|
|
110
|
+
const workspace = index.exports.get('default');
|
|
111
|
+
const definition = scalar(property(property(property(workspace, 'spaces'), localKey), 'definition'));
|
|
112
|
+
const unit = load(resolve(root, definition));
|
|
113
|
+
const resources = property(unit.exports.get('default'), 'resources');
|
|
114
|
+
const edits = new Map();
|
|
115
|
+
for (const [alias, structure] of Object.entries(structures || {})) {
|
|
116
|
+
if (!structure || typeof structure.name !== 'string' || !structure.schema || typeof structure.schema !== 'object' || Array.isArray(structure.schema)) throw usageError('The server returned an incomplete live database structure.');
|
|
117
|
+
const resource = property(resources, alias);
|
|
118
|
+
if (scalar(property(resource, 'kind')) !== 'database') throw fail();
|
|
119
|
+
const create = property(resource, 'create');
|
|
120
|
+
if (!create || !Number.isInteger(create.node.start) || !Number.isInteger(create.node.end)) throw fail();
|
|
121
|
+
const list = edits.get(create.unit.file) || [];
|
|
122
|
+
// Inline declarations are edited in place. Constants retain their shared
|
|
123
|
+
// helper and get one explicit local override, not another wrapper per pull.
|
|
124
|
+
let node = create.node;
|
|
125
|
+
while (['TSAsExpression', 'TSSatisfiesExpression', 'ParenthesizedExpression'].includes(node.type)) node = node.expression;
|
|
126
|
+
if (node.type === 'ObjectExpression') {
|
|
127
|
+
const additions = [];
|
|
128
|
+
for (const [name, value] of [['name', structure.name], ['schema', structure.schema]]) {
|
|
129
|
+
const field = [...node.properties].reverse().find(item => item.type === 'ObjectProperty' && !item.computed && nameOf(item.key) === name);
|
|
130
|
+
const replacement = JSON.stringify(value, null, 2);
|
|
131
|
+
if (field && !field.shorthand) list.push({ start: field.value.start, end: field.value.end, replacement });
|
|
132
|
+
else if (field) list.push({ start: field.start, end: field.end, replacement: `${name}: ${replacement}` });
|
|
133
|
+
else additions.push(`${name}: ${replacement}`);
|
|
134
|
+
}
|
|
135
|
+
if (additions.length) list.push({ start: node.end - 1, end: node.end - 1,
|
|
136
|
+
replacement: `${node.properties.length && !create.unit.source.slice(node.properties.at(-1).end, node.end - 1).includes(',') ? ',' : ''} ${additions.join(', ')} ` });
|
|
137
|
+
} else {
|
|
138
|
+
const original = create.unit.source.slice(create.node.start, create.node.end);
|
|
139
|
+
list.push({ start: create.node.start, end: create.node.end,
|
|
140
|
+
replacement: `({ ...(${original}), name: ${JSON.stringify(structure.name)}, schema: ${JSON.stringify(structure.schema, null, 2)} })` });
|
|
141
|
+
}
|
|
142
|
+
edits.set(create.unit.file, list);
|
|
143
|
+
}
|
|
144
|
+
if (overrides) {
|
|
145
|
+
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)
|
|
146
|
+
|| Object.keys(overrides).some(key => !['record_params', 'resource_keys'].includes(key))) throw fail();
|
|
147
|
+
const recordParams = overrides.record_params || {}, resourceKeys = overrides.resource_keys || {};
|
|
148
|
+
if ([recordParams, resourceKeys].some(value => !value || typeof value !== 'object' || Array.isArray(value))) throw fail();
|
|
149
|
+
// Pin authoring to this selected definition. Shared imports/constants are
|
|
150
|
+
// overridden at their local use site, never rewritten for sibling Spaces.
|
|
151
|
+
const rootDefinition = unwrap(unit.exports.get('default'));
|
|
152
|
+
if (rootDefinition.unit !== unit || rootDefinition.node.type !== 'ObjectExpression') throw fail();
|
|
153
|
+
const rawField = name => {
|
|
154
|
+
const field = [...rootDefinition.node.properties].reverse().find(item => item.type === 'ObjectProperty'
|
|
155
|
+
&& !item.computed && nameOf(item.key) === name);
|
|
156
|
+
if (!field || field.shorthand) throw fail();
|
|
157
|
+
return { node: field.value, unit };
|
|
158
|
+
};
|
|
159
|
+
const text = reference => reference.unit.source.slice(reference.node.start, reference.node.end);
|
|
160
|
+
const patchObject = (reference, fields) => {
|
|
161
|
+
let node = reference.node;
|
|
162
|
+
while (['TSAsExpression', 'TSSatisfiesExpression', 'ParenthesizedExpression'].includes(node.type)) node = node.expression;
|
|
163
|
+
if (node.type !== 'ObjectExpression') {
|
|
164
|
+
return `({ ...(${text(reference)}), ${Object.entries(fields).map(([key, value]) => `${JSON.stringify(key)}: ${value}`).join(', ')} })`;
|
|
165
|
+
}
|
|
166
|
+
let source = text(reference), changes = [], additions = [];
|
|
167
|
+
for (const [key, replacement] of Object.entries(fields)) {
|
|
168
|
+
const field = [...node.properties].reverse().find(item => item.type === 'ObjectProperty' && !item.computed && nameOf(item.key) === key);
|
|
169
|
+
if (field) changes.push({ start: (field.shorthand ? field.start : field.value.start) - reference.node.start,
|
|
170
|
+
end: (field.shorthand ? field.end : field.value.end) - reference.node.start,
|
|
171
|
+
replacement: field.shorthand ? `${JSON.stringify(key)}: ${replacement}` : replacement });
|
|
172
|
+
else additions.push(`${JSON.stringify(key)}: ${replacement}`);
|
|
173
|
+
}
|
|
174
|
+
if (additions.length) changes.push({ start: node.end - 1 - reference.node.start, end: node.end - 1 - reference.node.start,
|
|
175
|
+
replacement: `${node.properties.length && !reference.unit.source.slice(node.properties.at(-1).end, node.end - 1).includes(',') ? ',' : ''} ${additions.join(', ')} ` });
|
|
176
|
+
for (const change of changes.sort((a, b) => b.start - a.start)) source = source.slice(0, change.start) + change.replacement + source.slice(change.end);
|
|
177
|
+
return source;
|
|
178
|
+
};
|
|
179
|
+
const queue = (reference, replacement) => {
|
|
180
|
+
const list = edits.get(reference.unit.file) || [];
|
|
181
|
+
list.push({ start: reference.node.start, end: reference.node.end, replacement }); edits.set(reference.unit.file, list);
|
|
182
|
+
};
|
|
183
|
+
for (const [fieldName, requests] of [['params', recordParams], ['resources', resourceKeys]]) {
|
|
184
|
+
if (!Object.keys(requests).length) continue;
|
|
185
|
+
const reference = rawField(fieldName), object = unwrap(reference), replacements = {};
|
|
186
|
+
for (const [name, expected] of Object.entries(requests)) {
|
|
187
|
+
if (!expected || typeof expected !== 'object' || Array.isArray(expected)) throw fail();
|
|
188
|
+
const current = property(reference, name);
|
|
189
|
+
if (!current) throw fail();
|
|
190
|
+
const source = object.node.type === 'ObjectExpression' && object.unit === unit && current.unit === unit ? text(current)
|
|
191
|
+
: `(${text(reference)})[${JSON.stringify(name)}]`;
|
|
192
|
+
if (fieldName === 'params') {
|
|
193
|
+
if (Object.keys(expected).some(key => !['database', 'main'].includes(key)) || typeof expected.main !== 'boolean'
|
|
194
|
+
|| scalar(property(current, 'type')) !== 'record' || scalar(property(current, 'database')) !== expected.database) throw fail();
|
|
195
|
+
replacements[name] = current.unit === unit && object.node.type === 'ObjectExpression'
|
|
196
|
+
? patchObject(current, { main: JSON.stringify(expected.main) }) : `({ ...(${source}), main: ${JSON.stringify(expected.main)} })`;
|
|
197
|
+
} else {
|
|
198
|
+
if (Object.keys(expected).some(key => !['kind', 'key'].includes(key)) || typeof expected.key !== 'string' || !expected.key
|
|
199
|
+
|| scalar(property(current, 'kind')) !== expected.kind) throw fail();
|
|
200
|
+
const create = property(current, 'create'), key = property(current, 'key');
|
|
201
|
+
if (create) {
|
|
202
|
+
// Copied source archives retain their original create declaration.
|
|
203
|
+
// A reused database (or one whose main moved) is now an existing
|
|
204
|
+
// binding: replace only this definition's resource, never a shared
|
|
205
|
+
// imported creation or a sibling view's source.
|
|
206
|
+
if (expected.kind !== 'database' || key) throw fail();
|
|
207
|
+
replacements[name] = JSON.stringify(expected);
|
|
208
|
+
} else {
|
|
209
|
+
if (!key) throw fail();
|
|
210
|
+
replacements[name] = current.unit === unit && object.node.type === 'ObjectExpression'
|
|
211
|
+
? patchObject(current, { key: JSON.stringify(expected.key) }) : `({ ...(${source}), key: ${JSON.stringify(expected.key)} })`;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
queue(reference, patchObject(reference, replacements));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const rewritten = new Map(staged);
|
|
219
|
+
for (const [file, list] of edits) {
|
|
220
|
+
let source = modules.get(file).source, previous = source.length;
|
|
221
|
+
for (const edit of list.sort((a, b) => b.start - a.start)) {
|
|
222
|
+
if (edit.end > previous) throw fail();
|
|
223
|
+
source = source.slice(0, edit.start) + edit.replacement + source.slice(edit.end); previous = edit.start;
|
|
224
|
+
}
|
|
225
|
+
parse(source, { sourceType: 'module', plugins: ['typescript', 'jsx'] });
|
|
226
|
+
rewritten.set(file, source);
|
|
227
|
+
}
|
|
228
|
+
return rewritten;
|
|
229
|
+
}
|