@notis_ai/cli 0.2.0-beta.191.1 → 0.2.0-beta.197.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 +485 -86
- package/config/portal-route-roots.json +47 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +32663 -6357
- package/dist/base-skills/notis-apps/SKILL.md +88 -67
- package/dist/base-skills/notis-apps/references/architecture.md +114 -167
- package/dist/base-skills/notis-apps/references/design.md +26 -107
- package/dist/base-skills/notis-apps/references/memory.md +58 -0
- package/dist/base-skills/notis-apps/references/reading.md +52 -74
- package/dist/base-skills/notis-apps/references/release.md +104 -142
- package/dist/base-skills/notis-apps/references/sdk.md +113 -60
- package/dist/base-skills/notis-apps/references/troubleshooting.md +22 -21
- package/dist/base-skills/notis-apps/references/views.md +142 -0
- package/dist/base-skills/notis-cli/SKILL.md +4 -4
- package/dist/base-skills/notis-cli/references/app-delivery.md +12 -11
- package/dist/base-skills/notis-cli/references/native-databases.md +6 -6
- package/dist/base-skills/notis-cli/references/tool-examples.md +4 -4
- package/dist/base-skills/notis-query/SKILL.md +34 -60
- package/dist/base-skills/notis-query/references/database-discovery.md +59 -69
- package/dist/base-skills/notis-query/references/documents.md +63 -50
- package/dist/base-skills/notis-query/references/query.md +57 -524
- package/dist/sdk/src/bundleModule.ts +79 -0
- package/dist/sdk/src/components/DocumentEditor.tsx +6 -3
- package/dist/sdk/src/components/DocumentPage.tsx +141 -0
- package/dist/sdk/src/components/RecordComponents.tsx +33 -0
- package/dist/sdk/src/components/RenderChartData.tsx +19 -0
- package/dist/sdk/src/config.ts +4 -26
- package/dist/sdk/src/documents.ts +2 -0
- package/dist/sdk/src/hooks/useAction.ts +28 -0
- package/dist/sdk/src/hooks/useCloudComputer.ts +35 -4
- package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
- package/dist/sdk/src/hooks/useHandover.ts +5 -4
- 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 +3 -0
- package/dist/sdk/src/hooks/useNotisNavigation.ts +26 -11
- package/dist/sdk/src/hooks/useQuery.ts +7 -4
- package/dist/sdk/src/hooks/useViewParams.ts +121 -0
- package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
- package/dist/sdk/src/index.ts +36 -0
- package/dist/sdk/src/interactions/shortcuts.tsx +17 -3
- package/dist/sdk/src/nativeData.ts +39 -0
- package/dist/sdk/src/presentation.tsx +82 -0
- package/dist/sdk/src/provider.tsx +9 -4
- package/dist/sdk/src/queryCache.ts +5 -3
- package/dist/sdk/src/queryErrors.ts +51 -0
- package/dist/sdk/src/runtime.ts +146 -8
- package/dist/sdk/src/space.ts +425 -0
- package/dist/sdk/src/styles.css +96 -72
- package/dist/sdk/src/vite.ts +11 -1
- package/dist/skill-sync/index.js +458 -68
- package/dist/skill-sync/index.js.map +4 -4
- package/dist/skill-sync-worker.mjs +451 -77
- 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 +11 -5
- package/skills/notis-apps/cli.md +270 -163
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +3 -3
- package/skills/notis-onboarding/BRIEF.md +7 -3
- package/src/command-specs/apps.js +1 -8
- package/src/command-specs/files.js +29 -0
- package/src/command-specs/handover.js +45 -16
- package/src/command-specs/helpers.js +1 -0
- package/src/command-specs/index.js +10 -4
- package/src/command-specs/skills.js +229 -0
- package/src/command-specs/spaces.js +572 -0
- package/src/command-specs/views.js +69 -0
- package/src/runtime/agent-browser.js +30 -7
- package/src/runtime/app-boundary-validator.js +7 -4
- package/src/runtime/app-data-literals.js +82 -0
- package/src/runtime/app-platform.js +321 -253
- package/src/runtime/confined-space-build.js +74 -0
- package/src/runtime/confined-space-verify.js +49 -0
- package/src/runtime/native-record-files.js +47 -0
- package/src/runtime/retired-sdk-files.js +20 -0
- package/src/runtime/skill-sync/cloud-client.ts +26 -4
- package/src/runtime/skill-sync/index.ts +154 -35
- package/src/runtime/skill-sync/local-scanner.ts +125 -17
- 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/sync-plan.ts +16 -9
- package/src/runtime/skill-sync/types.ts +32 -6
- 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/sync-skills.js +1 -1
- package/src/runtime/transport.js +8 -1
- package/src/runtime/view-render.js +49 -0
- package/src/space-harness-host.ts +103 -0
- package/template/app/globals.css +1 -0
- package/src/command-specs/reports.js +0 -97
|
@@ -6,10 +6,13 @@ import os from "os";
|
|
|
6
6
|
import path from "path";
|
|
7
7
|
import { promisify } from "util";
|
|
8
8
|
|
|
9
|
-
import type { CloudSkill, LocalSkill, NotisSyncState, SkillWriteOutcome } from "./types";
|
|
9
|
+
import type { BundleFile, CloudSkill, LocalSkill, NotisSyncState, SkillWriteOutcome } from "./types";
|
|
10
|
+
import { NATIVE_IDENTITY_FILE, assertNativePull, checkedNativeIdentity, isNativeSkill,
|
|
11
|
+
nativeFileDigest, nativeFrontmatterName, type NativeSkillIdentity } from './native-identity';
|
|
10
12
|
|
|
11
13
|
const HOME_DIR = os.homedir();
|
|
12
14
|
const execFileAsync = promisify(execFile);
|
|
15
|
+
class NativeSkillIdentityError extends Error {}
|
|
13
16
|
|
|
14
17
|
export const AGENTS_DIR = path.join(HOME_DIR, ".agents");
|
|
15
18
|
export const LEGACY_AGENTS_SKILLS_DIR = path.join(AGENTS_DIR, "skills");
|
|
@@ -45,6 +48,9 @@ const EXCLUDED_TOP_LEVEL_ROOT_NAMES = new Set([
|
|
|
45
48
|
]);
|
|
46
49
|
|
|
47
50
|
export interface SkillSyncPaths {
|
|
51
|
+
/** Explicit process-independent home for a confined sync invocation. */
|
|
52
|
+
homeDir?: string;
|
|
53
|
+
isolatedNamespace?: string;
|
|
48
54
|
agentsDir: string;
|
|
49
55
|
syncRoot: string;
|
|
50
56
|
legacySkillsDir: string;
|
|
@@ -96,14 +102,21 @@ function sanitizePathSegment(value: string): string {
|
|
|
96
102
|
export function getSkillSyncPathsForUser(
|
|
97
103
|
authUserId: string,
|
|
98
104
|
agentsDir: string = AGENTS_DIR,
|
|
105
|
+
namespace?: string | null,
|
|
106
|
+
syncHome?: string,
|
|
99
107
|
): SkillSyncPaths {
|
|
100
108
|
const safeUserId = sanitizePathSegment(authUserId);
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
const
|
|
109
|
+
const homeDir = syncHome ? path.resolve(syncHome) : undefined;
|
|
110
|
+
const resolvedAgentsDir = homeDir ? path.join(homeDir, '.agents') : path.resolve(agentsDir);
|
|
111
|
+
const syncRoot = homeDir ? path.join(homeDir, '.notis', 'skills') : getDefaultSyncRootForAgentsDir(resolvedAgentsDir);
|
|
112
|
+
const userRoot = namespace
|
|
113
|
+
? path.join(syncRoot, 'environments', sanitizePathSegment(namespace), 'users', safeUserId)
|
|
114
|
+
: path.join(syncRoot, "users", safeUserId);
|
|
104
115
|
const legacyUserRoot = path.join(resolvedAgentsDir, "notis", "users", safeUserId);
|
|
105
116
|
|
|
106
117
|
return {
|
|
118
|
+
...(homeDir ? { homeDir } : {}),
|
|
119
|
+
...(namespace ? { isolatedNamespace: sanitizePathSegment(namespace) } : {}),
|
|
107
120
|
agentsDir: resolvedAgentsDir,
|
|
108
121
|
syncRoot,
|
|
109
122
|
legacySkillsDir: path.join(resolvedAgentsDir, "skills"),
|
|
@@ -174,17 +187,18 @@ function isIgnoredSkillEntry(entry: Dirent): boolean {
|
|
|
174
187
|
return /\.py[co]$/i.test(entry.name);
|
|
175
188
|
}
|
|
176
189
|
|
|
177
|
-
async function listFilesRecursive(dirPath: string): Promise<string[]> {
|
|
190
|
+
async function listFilesRecursive(dirPath: string, rootPath = dirPath, strictLinks = false): Promise<string[]> {
|
|
178
191
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
179
192
|
const nested = await Promise.all(
|
|
180
193
|
entries.map(async (entry) => {
|
|
181
|
-
if (isIgnoredSkillEntry(entry)) {
|
|
194
|
+
if (isIgnoredSkillEntry(entry) || (dirPath === rootPath && entry.name === NATIVE_IDENTITY_FILE)) {
|
|
182
195
|
return [];
|
|
183
196
|
}
|
|
184
197
|
|
|
185
198
|
const fullPath = path.join(dirPath, entry.name);
|
|
199
|
+
if (strictLinks && entry.isSymbolicLink()) throw new Error('Native Skill bundles cannot contain symbolic links.');
|
|
186
200
|
if (entry.isDirectory()) {
|
|
187
|
-
return listFilesRecursive(fullPath);
|
|
201
|
+
return listFilesRecursive(fullPath, rootPath, strictLinks);
|
|
188
202
|
}
|
|
189
203
|
|
|
190
204
|
if (entry.isFile()) {
|
|
@@ -337,17 +351,17 @@ function defaultTopLevelSkillSources(
|
|
|
337
351
|
{ label: "agents", root: paths.legacySkillsDir, priority: 2 },
|
|
338
352
|
{
|
|
339
353
|
label: "codex",
|
|
340
|
-
root: path.join(HOME_DIR, ".codex", "skills"),
|
|
354
|
+
root: path.join(paths.homeDir || HOME_DIR, ".codex", "skills"),
|
|
341
355
|
priority: 3,
|
|
342
356
|
},
|
|
343
357
|
{
|
|
344
358
|
label: "cursor",
|
|
345
|
-
root: path.join(HOME_DIR, ".cursor", "skills"),
|
|
359
|
+
root: path.join(paths.homeDir || HOME_DIR, ".cursor", "skills"),
|
|
346
360
|
priority: 4,
|
|
347
361
|
},
|
|
348
362
|
{
|
|
349
363
|
label: "claude",
|
|
350
|
-
root: path.join(HOME_DIR, ".claude", "skills"),
|
|
364
|
+
root: path.join(paths.homeDir || HOME_DIR, ".claude", "skills"),
|
|
351
365
|
priority: 5,
|
|
352
366
|
},
|
|
353
367
|
);
|
|
@@ -691,6 +705,7 @@ export async function scanLocalSkills(
|
|
|
691
705
|
await ensureCanonicalSkillsDir(paths);
|
|
692
706
|
|
|
693
707
|
const sourceUrls = await readSkillLock(paths);
|
|
708
|
+
const previousState = await readSyncState(paths);
|
|
694
709
|
const entries = await fs.readdir(paths.skillsDir, { withFileTypes: true });
|
|
695
710
|
const skills: Array<LocalSkill | null> = await Promise.all(
|
|
696
711
|
entries.map(async (entry) => {
|
|
@@ -703,7 +718,17 @@ export async function scanLocalSkills(
|
|
|
703
718
|
|
|
704
719
|
const skillDir = path.join(paths.skillsDir, entry.name);
|
|
705
720
|
const skillMdPath = path.join(skillDir, "SKILL.md");
|
|
706
|
-
|
|
721
|
+
let marker: string | null = null;
|
|
722
|
+
// Classify provenance BEFORE reading potentially unavailable content.
|
|
723
|
+
// Failure to scan a managed folder is not evidence that it is absent.
|
|
724
|
+
try { marker = await fs.readFile(path.join(skillDir, NATIVE_IDENTITY_FILE), 'utf8'); }
|
|
725
|
+
catch (error) {
|
|
726
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
727
|
+
throw new NativeSkillIdentityError('Native Skill provenance could not be read; existing files were retained.');
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
const knownNative = marker !== null || Boolean(previousState.skills[entry.name]?.nativeReference)
|
|
731
|
+
|| Object.values(previousState.nativeHistory || {}).some(row => row.names.includes(entry.name));
|
|
707
732
|
try {
|
|
708
733
|
const skillMd = await fs.readFile(skillMdPath, "utf8");
|
|
709
734
|
const { description } = parseFrontMatter(skillMd);
|
|
@@ -715,11 +740,27 @@ export async function scanLocalSkills(
|
|
|
715
740
|
folderHash,
|
|
716
741
|
directoryPath: skillDir,
|
|
717
742
|
};
|
|
743
|
+
if (marker !== null) {
|
|
744
|
+
let identity: NativeSkillIdentity;
|
|
745
|
+
try { identity = checkedNativeIdentity(JSON.parse(marker)); }
|
|
746
|
+
catch { throw new NativeSkillIdentityError('Native Skill provenance is unreadable; do not upload it as a new Skill.'); }
|
|
747
|
+
let current = true;
|
|
748
|
+
for (const [name, expected] of Object.entries(identity.files)) {
|
|
749
|
+
try { if (nativeFileDigest(await fs.readFile(resolveSkillBundleFilePath(skillDir, name))) !== expected) current = false; }
|
|
750
|
+
catch { current = false; }
|
|
751
|
+
}
|
|
752
|
+
const editedName = nativeFrontmatterName(skillMd);
|
|
753
|
+
Object.assign(skill, { cloudId: identity.cloudId, nativeReference: identity.reference,
|
|
754
|
+
nativeScope: identity.scope, nativeBaseFolderHash: identity.folderHash, nativeFilesCurrent: current,
|
|
755
|
+
displayName: editedName && editedName !== identity.frontmatterName ? editedName : identity.displayName });
|
|
756
|
+
}
|
|
718
757
|
if (sourceUrls[entry.name]) {
|
|
719
758
|
skill.sourceUrl = sourceUrls[entry.name];
|
|
720
759
|
}
|
|
721
760
|
return skill;
|
|
722
|
-
} catch {
|
|
761
|
+
} catch (error) {
|
|
762
|
+
if (error instanceof NativeSkillIdentityError) throw error;
|
|
763
|
+
if (knownNative) throw new NativeSkillIdentityError('Native Skill files could not be read; existing files were retained.');
|
|
723
764
|
return null;
|
|
724
765
|
}
|
|
725
766
|
}),
|
|
@@ -736,6 +777,11 @@ function normalizeSyncState(
|
|
|
736
777
|
if (!state || state.version !== 1 || typeof state.skills !== "object") {
|
|
737
778
|
return null;
|
|
738
779
|
}
|
|
780
|
+
if (state.nativeHistory && (typeof state.nativeHistory !== 'object' || Array.isArray(state.nativeHistory)
|
|
781
|
+
|| Object.values(state.nativeHistory).some(row => !row || !Array.isArray(row.names)
|
|
782
|
+
|| row.names.some(name => typeof name !== 'string')))) {
|
|
783
|
+
throw new Error('Native Skill sync provenance is unreadable; existing files were retained.');
|
|
784
|
+
}
|
|
739
785
|
const normalizeStoredAgentTargets = (
|
|
740
786
|
targets: Partial<NotisSyncState["skills"][string]["agentTargets"]> | null | undefined,
|
|
741
787
|
): NotisSyncState["skills"][string]["agentTargets"] => ({
|
|
@@ -747,6 +793,7 @@ function normalizeSyncState(
|
|
|
747
793
|
|
|
748
794
|
return {
|
|
749
795
|
version: 1,
|
|
796
|
+
...(state.nativeHistory ? { nativeHistory: state.nativeHistory } : {}),
|
|
750
797
|
lastSyncedAt:
|
|
751
798
|
typeof state.lastSyncedAt === "string" ? state.lastSyncedAt : null,
|
|
752
799
|
skills: Object.fromEntries(
|
|
@@ -975,13 +1022,19 @@ async function preserveUnmanagedFiles(
|
|
|
975
1022
|
existingDir: string,
|
|
976
1023
|
stagingDir: string,
|
|
977
1024
|
previouslyApplied?: readonly string[],
|
|
1025
|
+
strictConflicts = false,
|
|
978
1026
|
): Promise<void> {
|
|
979
1027
|
const managed = previouslyApplied ? new Set(previouslyApplied) : null;
|
|
980
1028
|
for (const existingFile of await listFilesRecursive(existingDir)) {
|
|
981
1029
|
const relativePath = toPosixRelativePath(existingDir, existingFile);
|
|
982
1030
|
if (managed?.has(relativePath)) continue;
|
|
983
1031
|
const stagedPath = path.join(stagingDir, ...relativePath.split("/"));
|
|
984
|
-
if (await pathExists(stagedPath))
|
|
1032
|
+
if (await pathExists(stagedPath)) {
|
|
1033
|
+
if (strictConflicts && !(await fs.readFile(existingFile)).equals(await fs.readFile(stagedPath))) {
|
|
1034
|
+
throw new Error('An incoming native Skill file conflicts with a local file. Local files were retained.');
|
|
1035
|
+
}
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
985
1038
|
await fs.mkdir(path.dirname(stagedPath), { recursive: true });
|
|
986
1039
|
await fs.copyFile(existingFile, stagedPath);
|
|
987
1040
|
}
|
|
@@ -999,7 +1052,7 @@ async function pathExists(targetPath: string): Promise<boolean> {
|
|
|
999
1052
|
async function replaceSkillDirectoryAtomically(
|
|
1000
1053
|
skillDir: string,
|
|
1001
1054
|
populateDir: (stagingDir: string) => Promise<void>,
|
|
1002
|
-
preserve?: { previouslyApplied?: readonly string[] },
|
|
1055
|
+
preserve?: { previouslyApplied?: readonly string[]; expectedFolderHash?: string; native?: boolean },
|
|
1003
1056
|
): Promise<void> {
|
|
1004
1057
|
const parentDir = path.dirname(skillDir);
|
|
1005
1058
|
const skillName = path.basename(skillDir);
|
|
@@ -1018,15 +1071,43 @@ async function replaceSkillDirectoryAtomically(
|
|
|
1018
1071
|
let cleanupError: Error | null = null;
|
|
1019
1072
|
|
|
1020
1073
|
try {
|
|
1074
|
+
if (preserve?.native && preserve.expectedFolderHash === undefined) {
|
|
1075
|
+
let existing = false;
|
|
1076
|
+
try { await fs.lstat(skillDir); existing = true; }
|
|
1077
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
|
1078
|
+
if (existing) throw new Error('Existing native Skill files need a readable baseline before replacement.');
|
|
1079
|
+
}
|
|
1080
|
+
if (preserve?.expectedFolderHash !== undefined
|
|
1081
|
+
&& (!await pathExists(skillDir) || await computeFolderHash(skillDir) !== preserve.expectedFolderHash)) {
|
|
1082
|
+
throw new Error('Local Skill files changed before sync; the newer local edits were retained.');
|
|
1083
|
+
}
|
|
1021
1084
|
await populateDir(stagingDir);
|
|
1022
1085
|
|
|
1023
1086
|
if (preserve && (await pathExists(skillDir))) {
|
|
1024
|
-
await preserveUnmanagedFiles(skillDir, stagingDir, preserve.previouslyApplied);
|
|
1087
|
+
await preserveUnmanagedFiles(skillDir, stagingDir, preserve.previouslyApplied, preserve.native);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
if (preserve?.native) {
|
|
1091
|
+
const markerPath = path.join(stagingDir, NATIVE_IDENTITY_FILE);
|
|
1092
|
+
const marker = checkedNativeIdentity(JSON.parse(await fs.readFile(markerPath, 'utf8')));
|
|
1093
|
+
marker.folderHash = await computeFolderHash(stagingDir);
|
|
1094
|
+
await fs.writeFile(markerPath, JSON.stringify(marker), { mode: 0o600 });
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
if (preserve?.expectedFolderHash !== undefined
|
|
1098
|
+
&& (!await pathExists(skillDir) || await computeFolderHash(skillDir) !== preserve.expectedFolderHash)) {
|
|
1099
|
+
throw new Error('Local Skill files changed during sync; the newer local edits were retained.');
|
|
1025
1100
|
}
|
|
1026
1101
|
|
|
1027
1102
|
if (await pathExists(skillDir)) {
|
|
1028
1103
|
await fs.rename(skillDir, backupDir);
|
|
1029
1104
|
movedExisting = true;
|
|
1105
|
+
if (preserve?.native && preserve.expectedFolderHash === undefined) {
|
|
1106
|
+
throw new Error('Native Skill files appeared during sync; existing files were retained.');
|
|
1107
|
+
}
|
|
1108
|
+
if (preserve?.expectedFolderHash !== undefined && await computeFolderHash(backupDir) !== preserve.expectedFolderHash) {
|
|
1109
|
+
throw new Error('Local Skill files changed before publication; the newer local edits were retained.');
|
|
1110
|
+
}
|
|
1030
1111
|
}
|
|
1031
1112
|
|
|
1032
1113
|
await fs.rename(stagingDir, skillDir);
|
|
@@ -1080,17 +1161,36 @@ export async function createSkillBundleBase64(
|
|
|
1080
1161
|
}
|
|
1081
1162
|
}
|
|
1082
1163
|
|
|
1164
|
+
export async function captureNativeSkillFiles(skill: LocalSkill): Promise<BundleFile[]> {
|
|
1165
|
+
const hash = createHash('sha256');
|
|
1166
|
+
const files: BundleFile[] = [];
|
|
1167
|
+
for (const filePath of await listFilesRecursive(skill.directoryPath, skill.directoryPath, true)) {
|
|
1168
|
+
const relative = path.relative(skill.directoryPath, filePath);
|
|
1169
|
+
const bytes = await fs.readFile(filePath);
|
|
1170
|
+
hash.update(relative); hash.update('\0'); hash.update(bytes); hash.update('\0');
|
|
1171
|
+
files.push({ path: relative.split(path.sep).join('/'), content_b64: bytes.toString('base64') });
|
|
1172
|
+
}
|
|
1173
|
+
if (hash.digest('hex') !== skill.folderHash) throw new Error('Local Skill files changed before upload. Sync again with the current files.');
|
|
1174
|
+
return files;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1083
1177
|
export async function writeCloudSkillToDisk(
|
|
1084
1178
|
skill: CloudSkill,
|
|
1085
1179
|
bundleBytes?: Buffer,
|
|
1086
1180
|
paths: SkillSyncPaths = DEFAULT_SYNC_PATHS,
|
|
1087
|
-
options: { previouslyApplied?: readonly string[] } = {},
|
|
1181
|
+
options: { previouslyApplied?: readonly string[]; expectedFolderHash?: string } = {},
|
|
1088
1182
|
): Promise<SkillWriteOutcome | false> {
|
|
1183
|
+
if (isNativeSkill(skill)) {
|
|
1184
|
+
assertNativePull({ skills: [{ ...skill, name: 'native-' + skill.id }], skill_sync_protocol: 2, sync_scope: skill.native_scope,
|
|
1185
|
+
sync_enabled: true, last_synced_at: null });
|
|
1186
|
+
if (bundleBytes?.length) throw new Error('Native Skills require their verified complete file response.');
|
|
1187
|
+
}
|
|
1089
1188
|
const skillDir = path.join(
|
|
1090
1189
|
paths.skillsDir,
|
|
1091
1190
|
safeName(skill.name, paths.skillsDir),
|
|
1092
1191
|
);
|
|
1093
|
-
const preserve = { previouslyApplied: options.previouslyApplied
|
|
1192
|
+
const preserve = { previouslyApplied: options.previouslyApplied, expectedFolderHash: options.expectedFolderHash,
|
|
1193
|
+
native: isNativeSkill(skill) };
|
|
1094
1194
|
|
|
1095
1195
|
if (bundleBytes?.length) {
|
|
1096
1196
|
const bundlePath = path.join(
|
|
@@ -1136,6 +1236,14 @@ export async function writeCloudSkillToDisk(
|
|
|
1136
1236
|
);
|
|
1137
1237
|
appliedFiles.push(toPosixRelativePath(stagingDir, filePath));
|
|
1138
1238
|
}
|
|
1239
|
+
if (skill.native_reference) {
|
|
1240
|
+
const identity: NativeSkillIdentity = { schema: 1, cloudId: skill.id, reference: skill.native_reference,
|
|
1241
|
+
displayName: skill.display_name || skill.name, frontmatterName: nativeFrontmatterName(skill.skill_md || ''),
|
|
1242
|
+
scope: skill.native_scope!, folderHash: await computeFolderHash(stagingDir),
|
|
1243
|
+
files: Object.fromEntries((skill.bundle_files || []).map(file => [file.path, nativeFileDigest(Buffer.from(file.content_b64, 'base64'))])) };
|
|
1244
|
+
await fs.writeFile(path.join(stagingDir, NATIVE_IDENTITY_FILE), JSON.stringify(identity), { mode: 0o600 });
|
|
1245
|
+
appliedFiles.push(NATIVE_IDENTITY_FILE);
|
|
1246
|
+
}
|
|
1139
1247
|
}, preserve);
|
|
1140
1248
|
return { written: true, appliedFiles: appliedFiles.sort() };
|
|
1141
1249
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { CloudSkill, NativeSkillReference, SyncedSkill, SyncPullResponse } from './types';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
export const NATIVE_IDENTITY_FILE = '.notis-native-skill.json';
|
|
5
|
+
export interface NativeSkillIdentity {
|
|
6
|
+
schema: 1;
|
|
7
|
+
cloudId: string;
|
|
8
|
+
reference: NativeSkillReference;
|
|
9
|
+
displayName: string;
|
|
10
|
+
frontmatterName: string;
|
|
11
|
+
scope: string;
|
|
12
|
+
folderHash: string;
|
|
13
|
+
files: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
16
|
+
|
|
17
|
+
export function checkedNativeReference(value: unknown, cloudId?: string): NativeSkillReference {
|
|
18
|
+
const reference = value as NativeSkillReference;
|
|
19
|
+
if (!reference || typeof reference !== 'object' || Array.isArray(reference)
|
|
20
|
+
|| Object.keys(reference).sort().join(',') !== 'access_revision,kind,skill_id,version'
|
|
21
|
+
|| reference.kind !== 'native_standalone_skill' || !UUID.test(reference.skill_id)
|
|
22
|
+
|| (cloudId !== undefined && reference.skill_id !== cloudId)
|
|
23
|
+
|| !Number.isSafeInteger(reference.access_revision) || reference.access_revision < 0
|
|
24
|
+
|| !reference.version || Object.keys(reference.version).sort().join(',') !== 'digest,revision'
|
|
25
|
+
|| !Number.isSafeInteger(reference.version.revision) || reference.version.revision < 1
|
|
26
|
+
|| !/^[a-f0-9]{64}$/.test(reference.version.digest)) {
|
|
27
|
+
throw new Error('Native Skill identity is unavailable. Refresh before syncing.');
|
|
28
|
+
}
|
|
29
|
+
return structuredClone(reference);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function nativeDirectory(reference: NativeSkillReference): string {
|
|
33
|
+
return 'native-' + checkedNativeReference(reference).skill_id;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function sameNativeReference(left?: NativeSkillReference, right?: NativeSkillReference): boolean {
|
|
37
|
+
return Boolean(left && right && left.skill_id === right.skill_id
|
|
38
|
+
&& left.access_revision === right.access_revision && left.version.revision === right.version.revision
|
|
39
|
+
&& left.version.digest === right.version.digest);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isNativeSkill(skill: CloudSkill): boolean {
|
|
43
|
+
return skill.source === 'native' || skill.native_reference !== undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function assertNativePull(response: SyncPullResponse): void {
|
|
47
|
+
const ids = new Set<string>();
|
|
48
|
+
for (const skill of response.skills) {
|
|
49
|
+
if (!isNativeSkill(skill)) continue;
|
|
50
|
+
const reference = checkedNativeReference(skill.native_reference, skill.id);
|
|
51
|
+
if (response.skill_sync_protocol !== 2 || skill.name !== nativeDirectory(reference) || ids.has(skill.id)
|
|
52
|
+
|| !/^[a-f0-9]{64}$/.test(response.sync_scope || '') || skill.native_scope !== response.sync_scope
|
|
53
|
+
|| !Array.isArray(skill.bundle_files) || skill.bundle_hydration_failed || typeof skill.skill_md !== 'string') {
|
|
54
|
+
throw new Error('Native Skill inventory is incomplete. Existing local files were retained.');
|
|
55
|
+
}
|
|
56
|
+
const paths = new Set<string>();
|
|
57
|
+
let entrypoint = false;
|
|
58
|
+
for (const file of skill.bundle_files) {
|
|
59
|
+
if (typeof file.path !== 'string' || typeof file.content_b64 !== 'string'
|
|
60
|
+
|| file.path.startsWith('/') || file.path.includes('\\') || file.path.includes('\0')
|
|
61
|
+
|| file.path.split('/').some(part => !part || part === '.' || part === '..')
|
|
62
|
+
|| paths.has(file.path.toLowerCase()) || file.path.toLowerCase() === NATIVE_IDENTITY_FILE) {
|
|
63
|
+
throw new Error('Native Skill bundle has a conflicting managed path.');
|
|
64
|
+
}
|
|
65
|
+
paths.add(file.path.toLowerCase());
|
|
66
|
+
if (file.path === 'SKILL.md') {
|
|
67
|
+
entrypoint = Buffer.from(file.content_b64, 'base64').equals(Buffer.from(skill.skill_md, 'utf8'));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!entrypoint) throw new Error('Native Skill instructions and files do not match.');
|
|
71
|
+
ids.add(skill.id);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function nativeFileDigest(bytes: Buffer): string {
|
|
76
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function nativeFrontmatterName(markdown: string): string {
|
|
80
|
+
const header = markdown.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
81
|
+
return header?.[1].match(/^name\s*:\s*(.+)$/m)?.[1].trim().replace(/^['"]|['"]$/g, '') || '';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function checkedNativeIdentity(value: unknown): NativeSkillIdentity {
|
|
85
|
+
const identity = value as NativeSkillIdentity;
|
|
86
|
+
if (!identity || identity.schema !== 1 || typeof identity.cloudId !== 'string'
|
|
87
|
+
|| typeof identity.displayName !== 'string' || typeof identity.frontmatterName !== 'string'
|
|
88
|
+
|| !/^[a-f0-9]{64}$/.test(identity.scope) || !/^[a-f0-9]{64}$/.test(identity.folderHash)
|
|
89
|
+
|| !identity.files || !Object.keys(identity.files).includes('SKILL.md')
|
|
90
|
+
|| Object.entries(identity.files).some(([name, hash]) => name.startsWith('/') || name.includes('\\')
|
|
91
|
+
|| name.split('/').some(part => !part || part === '.' || part === '..') || !/^[a-f0-9]{64}$/.test(hash))) {
|
|
92
|
+
throw new Error('Native Skill provenance is incomplete. Existing files were retained.');
|
|
93
|
+
}
|
|
94
|
+
return { ...identity, reference: checkedNativeReference(identity.reference, identity.cloudId) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function nativeState(skill: CloudSkill): Partial<SyncedSkill> {
|
|
98
|
+
if (!isNativeSkill(skill)) return {};
|
|
99
|
+
return { nativeReference: checkedNativeReference(skill.native_reference, skill.id),
|
|
100
|
+
...(skill.native_settings ? { nativeSettingsRevision: skill.native_settings.revision } : {}),
|
|
101
|
+
nativeDisplayName: skill.display_name || skill.name, nativeScope: skill.native_scope, nativeUnavailable: false };
|
|
102
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { CloudSkill, LocalSkill, NotisSyncState, SyncPullResponse } from './types';
|
|
2
|
+
import { checkedNativeReference, isNativeSkill, sameNativeReference } from './native-identity';
|
|
3
|
+
|
|
4
|
+
/** Folder names may change; existing managed cloud identity may not. */
|
|
5
|
+
export function bindNativeFolders(response: SyncPullResponse, locals: LocalSkill[], state: NotisSyncState): SyncPullResponse {
|
|
6
|
+
const nativeIds = new Set(response.skills.filter(isNativeSkill).map(skill => skill.id));
|
|
7
|
+
for (const entry of Object.values(state.skills)) if (entry.nativeReference || entry.nativeUnavailable) nativeIds.add(entry.cloudId);
|
|
8
|
+
const byId = new Map<string, LocalSkill>();
|
|
9
|
+
for (const local of locals) {
|
|
10
|
+
const id = local.cloudId || state.skills[local.name]?.cloudId;
|
|
11
|
+
if (!id || (!nativeIds.has(id) && !local.nativeReference)) continue;
|
|
12
|
+
if (local.nativeReference) checkedNativeReference(local.nativeReference, id);
|
|
13
|
+
if (byId.has(id)) throw new Error('Two local folders claim the same native Skill. Keep both copies and resolve the conflict.');
|
|
14
|
+
byId.set(id, local);
|
|
15
|
+
}
|
|
16
|
+
const skills = response.skills.map(skill => isNativeSkill(skill) && byId.has(skill.id)
|
|
17
|
+
? { ...skill, name: byId.get(skill.id)!.name } : skill);
|
|
18
|
+
for (const skill of skills.filter(isNativeSkill)) {
|
|
19
|
+
if (skills.some(other => other.id !== skill.id && other.name === skill.name)) {
|
|
20
|
+
throw new Error('A native Skill and another Skill share a local folder. Existing files were retained.');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { ...response, skills };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function previousNativeState(state: NotisSyncState, skill: CloudSkill) {
|
|
27
|
+
const named = state.skills[skill.name];
|
|
28
|
+
return named?.cloudId === skill.id ? named : Object.values(state.skills).find(entry => entry.cloudId === skill.id);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function nativeLocalReady(skill: CloudSkill, local?: LocalSkill): boolean {
|
|
32
|
+
return Boolean(local?.nativeFilesCurrent && local.folderHash === local.nativeBaseFolderHash
|
|
33
|
+
&& local.cloudId === skill.id && local.nativeScope === skill.native_scope
|
|
34
|
+
&& sameNativeReference(local.nativeReference, skill.native_reference));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function nativeLinkRows(response: SyncPullResponse, locals: LocalSkill[]): CloudSkill[] {
|
|
38
|
+
return response.skills.map(skill => isNativeSkill(skill) && !nativeLocalReady(skill, locals.find(row => row.name === skill.name))
|
|
39
|
+
? { ...skill, status: 'disabled' } : skill);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function retainMissingNativeState(previous: NotisSyncState, next: NotisSyncState): NotisSyncState {
|
|
43
|
+
const present = new Set(Object.values(next.skills).map(row => row.cloudId));
|
|
44
|
+
const retained = Object.fromEntries(Object.entries(previous.skills)
|
|
45
|
+
.filter(([, row]) => (row.nativeReference || row.nativeUnavailable) && !present.has(row.cloudId))
|
|
46
|
+
.map(([name, row]) => [name, { ...row, nativeUnavailable: true, verifiedAgentLinks: {} }]));
|
|
47
|
+
const history = structuredClone(previous.nativeHistory || {});
|
|
48
|
+
for (const [name, row] of [...Object.entries(previous.skills), ...Object.entries(next.skills)]) {
|
|
49
|
+
if (!row.nativeReference && !row.nativeUnavailable) continue;
|
|
50
|
+
const names = history[row.cloudId]?.names || [];
|
|
51
|
+
history[row.cloudId] = { names: [...new Set([...names, name])] };
|
|
52
|
+
}
|
|
53
|
+
return { ...next, skills: { ...retained, ...next.skills },
|
|
54
|
+
...(Object.keys(history).length ? { nativeHistory: history } : {}) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function hasNativeFolderHistory(state: NotisSyncState, name: string): boolean {
|
|
58
|
+
return Object.values(state.nativeHistory || {}).some(row => row.names.includes(name));
|
|
59
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { CloudSkill, LocalSkill, NotisSyncState } from "./types";
|
|
2
|
+
import { isNativeSkill } from './native-identity';
|
|
3
|
+
import { hasNativeFolderHistory } from './native-plan';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* One cloud row per local folder. Several active rows can carry the same skill name
|
|
5
|
-
* (
|
|
6
|
-
*
|
|
7
|
+
* (duplicates created by earlier pushes), and every one of them writes to the same
|
|
8
|
+
* directory. Applying them all in
|
|
7
9
|
* one pass replaced that directory several times per sync and left whichever row came
|
|
8
10
|
* last on disk. The newest revision wins, deterministically.
|
|
9
11
|
*/
|
|
@@ -23,25 +25,30 @@ export function getPushCandidates(
|
|
|
23
25
|
syncState: NotisSyncState,
|
|
24
26
|
cloudCuratedSkillNames: ReadonlySet<string> = new Set(),
|
|
25
27
|
cloudSkillNames?: ReadonlySet<string>,
|
|
26
|
-
|
|
28
|
+
cloudSkills: CloudSkill[] = [],
|
|
27
29
|
): LocalSkill[] {
|
|
28
30
|
return localSkills.filter((skill) => {
|
|
29
31
|
if (cloudCuratedSkillNames.has(skill.name)) {
|
|
30
32
|
return false;
|
|
31
33
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (
|
|
36
|
-
return
|
|
34
|
+
const previous = syncState.skills[skill.name]
|
|
35
|
+
|| (skill.cloudId ? Object.values(syncState.skills).find(row => row.cloudId === skill.cloudId) : undefined);
|
|
36
|
+
const native = cloudSkills.find(row => isNativeSkill(row) && row.id === (skill.cloudId || previous?.cloudId));
|
|
37
|
+
if (native || skill.nativeReference || previous?.nativeReference || previous?.nativeUnavailable) {
|
|
38
|
+
return Boolean(native && skill.nativeReference && skill.nativeScope === native.native_scope
|
|
39
|
+
&& (previous ? previous.folderHash !== skill.folderHash : skill.nativeBaseFolderHash !== skill.folderHash));
|
|
37
40
|
}
|
|
38
|
-
const previous = syncState.skills[skill.name];
|
|
39
41
|
if (!previous) {
|
|
42
|
+
if (hasNativeFolderHistory(syncState, skill.name) || /^native-[a-f0-9-]{36}$/.test(skill.name)) return false;
|
|
40
43
|
return true;
|
|
41
44
|
}
|
|
42
45
|
if (cloudSkillNames && !cloudSkillNames.has(skill.name)) {
|
|
43
46
|
return false;
|
|
44
47
|
}
|
|
45
48
|
return previous.folderHash !== skill.folderHash;
|
|
49
|
+
}).map(skill => {
|
|
50
|
+
const previous = syncState.skills[skill.name]
|
|
51
|
+
|| Object.values(syncState.skills).find(row => row.cloudId === skill.cloudId);
|
|
52
|
+
return { ...skill, ...(skill.cloudId || previous?.cloudId ? { cloudId: skill.cloudId || previous!.cloudId } : {}) };
|
|
46
53
|
});
|
|
47
54
|
}
|
|
@@ -5,8 +5,21 @@ export interface AgentTargets {
|
|
|
5
5
|
codex: boolean;
|
|
6
6
|
}
|
|
7
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
|
+
|
|
8
15
|
export interface SyncedSkill {
|
|
9
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;
|
|
10
23
|
folderHash: string;
|
|
11
24
|
agentTargets: AgentTargets;
|
|
12
25
|
/** Actual readable managed links, never inferred from desired targets. */
|
|
@@ -20,7 +33,7 @@ export interface SyncedSkill {
|
|
|
20
33
|
appliedFiles?: string[];
|
|
21
34
|
/** `skill_folder_hash` of the cloud revision a successful write actually applied.
|
|
22
35
|
* The server may compute that hash with a different construction than the local
|
|
23
|
-
* folder hash (
|
|
36
|
+
* folder hash (bundle-entry hashes do), so only cloud-to-cloud comparison tells
|
|
24
37
|
* "the cloud moved" apart from "the two sides hash differently". */
|
|
25
38
|
appliedCloudFolderHash?: string;
|
|
26
39
|
syncedAt: string;
|
|
@@ -30,6 +43,8 @@ export interface NotisSyncState {
|
|
|
30
43
|
version: 1;
|
|
31
44
|
lastSyncedAt: string | null;
|
|
32
45
|
skills: Record<string, SyncedSkill>;
|
|
46
|
+
/** Historical folder aliases are provenance, never authority or write pins. */
|
|
47
|
+
nativeHistory?: Record<string, { names: string[] }>;
|
|
33
48
|
}
|
|
34
49
|
|
|
35
50
|
/** A successful write, plus the paths it put on disk so the next one can tell
|
|
@@ -47,6 +62,12 @@ export interface LocalSkill {
|
|
|
47
62
|
directoryPath: string;
|
|
48
63
|
sourceUrl?: string;
|
|
49
64
|
bundleFiles?: BundleFile[];
|
|
65
|
+
cloudId?: string;
|
|
66
|
+
nativeReference?: NativeSkillReference;
|
|
67
|
+
displayName?: string;
|
|
68
|
+
nativeScope?: string;
|
|
69
|
+
nativeFilesCurrent?: boolean;
|
|
70
|
+
nativeBaseFolderHash?: string;
|
|
50
71
|
}
|
|
51
72
|
|
|
52
73
|
export interface BundleFile {
|
|
@@ -65,16 +86,18 @@ export interface CloudSkill {
|
|
|
65
86
|
skill_source_url?: string | null;
|
|
66
87
|
bundle_files?: BundleFile[] | null;
|
|
67
88
|
bundle_hydration_failed?: boolean | null;
|
|
68
|
-
/** Set when the skill's content is owned by a Notis app. Its folder is republished
|
|
69
|
-
* by the app, never by a local push. `app_owned` is what the server sends; the raw
|
|
70
|
-
* association id stays server-side. */
|
|
71
|
-
app_owned?: boolean | null;
|
|
72
|
-
owner_app_id?: string | null;
|
|
73
89
|
source: string;
|
|
74
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 };
|
|
75
95
|
}
|
|
76
96
|
|
|
77
97
|
export interface SyncSettings {
|
|
98
|
+
skill_sync_protocol?: number;
|
|
99
|
+
sync_scope?: string;
|
|
100
|
+
sync_namespace?: string | null;
|
|
78
101
|
agent_targets_conditional_updates?: boolean;
|
|
79
102
|
/** Server-verified canonical Notis account id used to scope local mirrors. */
|
|
80
103
|
user_id?: string;
|
|
@@ -94,6 +117,9 @@ export interface EntitlementAccessDetail {
|
|
|
94
117
|
}
|
|
95
118
|
|
|
96
119
|
export interface SyncPullResponse {
|
|
120
|
+
skill_sync_protocol?: number;
|
|
121
|
+
sync_scope?: string;
|
|
122
|
+
sync_namespace?: string | null;
|
|
97
123
|
skills: CloudSkill[];
|
|
98
124
|
sync_enabled: boolean;
|
|
99
125
|
last_synced_at: string | null;
|
|
@@ -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
|
+
}
|