@notis_ai/cli 0.2.0-beta.156.1 → 0.2.0-beta.158.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 +11 -45
- package/config/notis_app_design_rules.json +135 -0
- package/dist/agent-hooks/notis-agent-hook.mjs +8893 -10612
- package/dist/base-skills/notis-apps/SKILL.md +25 -573
- package/dist/base-skills/notis-apps/references/architecture.md +147 -0
- package/dist/base-skills/notis-apps/references/design.md +154 -0
- package/dist/base-skills/notis-apps/references/release.md +93 -0
- package/dist/base-skills/notis-apps/references/sdk.md +60 -0
- package/dist/base-skills/notis-apps/references/troubleshooting.md +26 -0
- package/dist/base-skills/notis-cli/SKILL.md +19 -267
- package/dist/base-skills/notis-cli/references/app-delivery.md +18 -0
- package/dist/base-skills/notis-cli/references/native-databases.md +20 -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 +13 -651
- package/dist/base-skills/notis-query/references/database-discovery.md +59 -0
- package/dist/base-skills/notis-query/references/documents.md +50 -0
- package/dist/base-skills/notis-query/references/query.md +543 -0
- package/dist/skill-sync/index.js +24 -7
- package/dist/skill-sync/index.js.map +4 -4
- package/dist/skill-sync-worker.mjs +2989 -0
- package/package.json +1 -2
- package/skills/notis-apps/cli.md +34 -95
- package/skills/notis-cli/AGENT_INSTRUCTIONS.md +1 -1
- package/src/cli.js +4 -0
- package/src/command-specs/apps.js +322 -1560
- package/src/command-specs/diagnostics.js +37 -0
- package/src/command-specs/skills.js +23 -5
- package/src/runtime/agent-browser.js +169 -1
- package/src/runtime/app-boundary-validator.js +221 -0
- package/src/runtime/app-platform.js +359 -233
- package/src/runtime/app-test-server.js +292 -0
- package/src/runtime/profiles.js +5 -2
- package/src/runtime/skill-sync/cloud-client.ts +2 -1
- package/src/runtime/skill-sync/index.ts +24 -6
- package/src/runtime/skill-sync/types.ts +2 -0
- package/src/runtime/skill-sync-service.js +109 -0
- package/src/skill-sync-worker-entry.js +2 -0
- package/src/skill-sync-worker.js +50 -0
- package/template/app/page.tsx +45 -44
- package/template/components/page-heading.tsx +23 -0
- package/template/components/ui/badge.tsx +7 -4
- package/template/components/ui/card.tsx +24 -11
- package/template/components/ui/native-select.tsx +24 -0
- package/template/notis.config.ts +0 -1
- package/template/package.json +2 -2
- package/template/packages/sdk/package.json +1 -2
- package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +55 -13
- package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +3 -1
- package/template/packages/sdk/src/config.ts +0 -2
- package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +138 -28
- package/template/packages/sdk/src/hooks/useLongPressSelection.ts +79 -0
- package/template/packages/sdk/src/hooks/useMultiSelect.ts +2 -8
- package/template/packages/sdk/src/index.ts +3 -0
- package/template/packages/sdk/src/interactions/actions.ts +14 -1
- package/template/packages/sdk/src/interactions/shortcuts.tsx +79 -19
- package/template/packages/sdk/src/interactions/visibility.ts +13 -0
- package/template/packages/sdk/src/interactions.ts +5 -1
- package/template/packages/sdk/src/styles.css +28 -1
- package/src/runtime/app-dev-build-supervisor.js +0 -47
- package/src/runtime/app-dev-build.js +0 -41
- 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 -1136
- package/src/runtime/app-dev-sessions.js +0 -185
- package/src/runtime/cli-mode.generated.js +0 -5
- package/src/runtime/cli-mode.js +0 -34
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/** Temporary, explicit app verification/screenshot server. Never mounts a Workspace app. */
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { exportNameFromPath, getBundleDir, loadAppConfig, readManifest } from './app-platform.js';
|
|
7
|
+
|
|
8
|
+
const CONTENT_TYPES = { '.js': 'application/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.map': 'application/json; charset=utf-8' };
|
|
9
|
+
const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
10
|
+
const REPO_ROOT = resolve(CLI_ROOT, '../..');
|
|
11
|
+
const HARNESS_TEMPLATE_PATH = join(CLI_ROOT, 'template', '.harness', 'index.html.tmpl');
|
|
12
|
+
const FALLBACK_REACT_VERSION = '19.0.0';
|
|
13
|
+
|
|
14
|
+
function extFor(pathname) {
|
|
15
|
+
const idx = pathname.lastIndexOf('.');
|
|
16
|
+
return idx === -1 ? '' : pathname.slice(idx);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isAllowedOrigin(origin) {
|
|
20
|
+
if (!origin) return true;
|
|
21
|
+
try {
|
|
22
|
+
const parsed = new URL(origin);
|
|
23
|
+
if (parsed.protocol === 'notis-app:') return true;
|
|
24
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
|
|
25
|
+
return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(parsed.hostname);
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function corsHeaders(origin) {
|
|
32
|
+
const allowOrigin = origin && isAllowedOrigin(origin) ? origin : '*';
|
|
33
|
+
return {
|
|
34
|
+
'Access-Control-Allow-Origin': allowOrigin,
|
|
35
|
+
'Access-Control-Allow-Methods': 'GET, HEAD, POST, OPTIONS',
|
|
36
|
+
'Access-Control-Allow-Headers': 'Content-Type, Cache-Control',
|
|
37
|
+
'Cache-Control': 'no-store',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function safeJoin(baseDir, relPath) {
|
|
42
|
+
const normalized = relPath.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
43
|
+
if (normalized.includes('..')) return null;
|
|
44
|
+
return join(baseDir, normalized);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function scriptJson(value) {
|
|
48
|
+
return JSON.stringify(value)
|
|
49
|
+
.replace(/</g, '\\u003c')
|
|
50
|
+
.replace(/\u2028/g, '\\u2028')
|
|
51
|
+
.replace(/\u2029/g, '\\u2029');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readJsonFile(path) {
|
|
55
|
+
if (!existsSync(path)) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function reactVersionFromPeer(peerRange) {
|
|
66
|
+
if (typeof peerRange !== 'string' || !peerRange) {
|
|
67
|
+
return FALLBACK_REACT_VERSION;
|
|
68
|
+
}
|
|
69
|
+
const exact = peerRange.match(/\d+\.\d+\.\d+/);
|
|
70
|
+
if (exact && !/[<>=~^*x]/i.test(peerRange.replace(exact[0], ''))) {
|
|
71
|
+
return exact[0];
|
|
72
|
+
}
|
|
73
|
+
if (peerRange.includes('19') || peerRange.includes('18')) {
|
|
74
|
+
return FALLBACK_REACT_VERSION;
|
|
75
|
+
}
|
|
76
|
+
return FALLBACK_REACT_VERSION;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function resolveHarnessReactVersion(projectDir) {
|
|
80
|
+
const candidates = [
|
|
81
|
+
join(projectDir, 'node_modules', '@notis', 'sdk', 'package.json'),
|
|
82
|
+
join(REPO_ROOT, 'packages', 'sdk', 'package.json'),
|
|
83
|
+
join(CLI_ROOT, 'template', 'packages', 'sdk', 'package.json'),
|
|
84
|
+
];
|
|
85
|
+
for (const candidate of candidates) {
|
|
86
|
+
const pkg = readJsonFile(candidate);
|
|
87
|
+
const peer = pkg?.peerDependencies?.react;
|
|
88
|
+
if (peer) {
|
|
89
|
+
return reactVersionFromPeer(peer);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return FALLBACK_REACT_VERSION;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function titleFromSlug(slug) {
|
|
96
|
+
return String(slug || '')
|
|
97
|
+
.replace(/[-_]+/g, ' ')
|
|
98
|
+
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function normalizeDatabaseDescriptors(databases) {
|
|
102
|
+
return (Array.isArray(databases) ? databases : [])
|
|
103
|
+
.map((entry) => {
|
|
104
|
+
if (typeof entry === 'string') {
|
|
105
|
+
return {
|
|
106
|
+
slug: entry,
|
|
107
|
+
title: titleFromSlug(entry),
|
|
108
|
+
description: null,
|
|
109
|
+
icon: null,
|
|
110
|
+
properties: [],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (entry && typeof entry === 'object' && typeof entry.slug === 'string') {
|
|
114
|
+
return {
|
|
115
|
+
slug: entry.slug,
|
|
116
|
+
title: entry.title || titleFromSlug(entry.slug),
|
|
117
|
+
description: entry.description || null,
|
|
118
|
+
icon: entry.icon || null,
|
|
119
|
+
properties: Array.isArray(entry.properties) ? entry.properties : [],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
})
|
|
124
|
+
.filter(Boolean);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeToolDescriptors(tools) {
|
|
128
|
+
return (Array.isArray(tools) ? tools : [])
|
|
129
|
+
.map((entry) => {
|
|
130
|
+
if (typeof entry === 'string') {
|
|
131
|
+
return { name: entry };
|
|
132
|
+
}
|
|
133
|
+
if (entry && typeof entry === 'object' && typeof entry.name === 'string') {
|
|
134
|
+
return entry;
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
})
|
|
138
|
+
.filter(Boolean);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function defaultRouteForManifest(manifest) {
|
|
142
|
+
const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
|
|
143
|
+
return routes.find((route) => route?.default) || routes[0] || null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function findHarnessRoute(manifest, routeSlug) {
|
|
147
|
+
const routes = Array.isArray(manifest?.routes) ? manifest.routes : [];
|
|
148
|
+
if (!routeSlug) {
|
|
149
|
+
return defaultRouteForManifest(manifest);
|
|
150
|
+
}
|
|
151
|
+
return routes.find((route) => route?.slug === routeSlug) || null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function buildHarnessDescriptor({ state, manifest, appConfig, route, scenario = null }) {
|
|
155
|
+
const databases = normalizeDatabaseDescriptors(
|
|
156
|
+
Array.isArray(appConfig?.databases) && appConfig.databases.length
|
|
157
|
+
? appConfig.databases
|
|
158
|
+
: manifest.databases,
|
|
159
|
+
);
|
|
160
|
+
const tools = normalizeToolDescriptors(
|
|
161
|
+
Array.isArray(appConfig?.tools) && appConfig.tools.length
|
|
162
|
+
? appConfig.tools
|
|
163
|
+
: manifest.tools,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
app: {
|
|
168
|
+
id: state.appId || 'harness-app',
|
|
169
|
+
slug: state.slug,
|
|
170
|
+
name: manifest.app?.name || appConfig?.name || state.slug,
|
|
171
|
+
icon: manifest.app?.icon || appConfig?.icon || null,
|
|
172
|
+
description: manifest.app?.description || appConfig?.description || null,
|
|
173
|
+
},
|
|
174
|
+
route: {
|
|
175
|
+
slug: route.slug,
|
|
176
|
+
path: route.path || '/',
|
|
177
|
+
name: route.name || titleFromSlug(route.slug),
|
|
178
|
+
icon: route.icon || null,
|
|
179
|
+
parentSlug: route.parentSlug || null,
|
|
180
|
+
default: Boolean(route.default),
|
|
181
|
+
resourceDeepLinks: route.resourceDeepLinks === true,
|
|
182
|
+
collection: route.collection || null,
|
|
183
|
+
},
|
|
184
|
+
databases,
|
|
185
|
+
context: { collectionItem: null, resourceId: null, screenshotScenario: scenario },
|
|
186
|
+
tools,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function plainObject(value) {
|
|
191
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Resolve the fixture payload injected into one harness page load.
|
|
196
|
+
*
|
|
197
|
+
* A scenario may override individual `tools` / `requests` keys on top of the
|
|
198
|
+
* file-level defaults, which is how one route renders both its populated and
|
|
199
|
+
* its empty state. Each capture is its own page load, so a shallow per-key
|
|
200
|
+
* merge is all the isolation a scenario needs.
|
|
201
|
+
*/
|
|
202
|
+
function harnessFixtures(projectDir, scenario) {
|
|
203
|
+
const fixtureConfig = readJsonFile(join(projectDir, 'metadata', 'screenshot-fixtures.json')) || {};
|
|
204
|
+
const scenarios = plainObject(fixtureConfig.scenarios);
|
|
205
|
+
const selected = scenario ? plainObject(scenarios[scenario]) : null;
|
|
206
|
+
return {
|
|
207
|
+
tools: { ...plainObject(fixtureConfig.tools), ...plainObject(selected?.tools) },
|
|
208
|
+
requests: { ...plainObject(fixtureConfig.requests), ...plainObject(selected?.requests) },
|
|
209
|
+
scenario: selected && Object.keys(selected).length > 0 ? selected : null,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function renderHarnessHtml({ state, manifest, appConfig, route, harnessOptions, scenario = null }) {
|
|
214
|
+
const template = readFileSync(HARNESS_TEMPLATE_PATH, 'utf-8');
|
|
215
|
+
const descriptor = buildHarnessDescriptor({ state, manifest, appConfig, route, scenario });
|
|
216
|
+
const routeExport = route.export_name || route.exportName || exportNameFromPath(route.path || '/');
|
|
217
|
+
const replacements = {
|
|
218
|
+
'{{REACT_VERSION}}': resolveHarnessReactVersion(state.projectDir),
|
|
219
|
+
'{{ROUTE_EXPORT}}': scriptJson(routeExport),
|
|
220
|
+
'{{RUNTIME_DESCRIPTOR}}': scriptJson(descriptor),
|
|
221
|
+
'{{MODE}}': scriptJson(harnessOptions.mode || 'stub'),
|
|
222
|
+
'{{API_BASE}}': scriptJson(harnessOptions.apiBase || null),
|
|
223
|
+
'{{JWT}}': scriptJson(harnessOptions.jwt || null),
|
|
224
|
+
'{{FIXTURES}}': scriptJson(harnessFixtures(state.projectDir, scenario)),
|
|
225
|
+
};
|
|
226
|
+
let html = template;
|
|
227
|
+
for (const [token, value] of Object.entries(replacements)) {
|
|
228
|
+
html = html.replaceAll(token, value);
|
|
229
|
+
}
|
|
230
|
+
return html;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
/** Serve only explicitly supplied built projects, without discovery or filesystem watchers. */
|
|
235
|
+
export async function startAppTestServer({ apps, port, harness = {} }) {
|
|
236
|
+
if (!Array.isArray(apps) || !apps.length) throw new Error('At least one built app is required.');
|
|
237
|
+
const states = new Map();
|
|
238
|
+
for (const app of apps) {
|
|
239
|
+
states.set(app.slug, {
|
|
240
|
+
...app,
|
|
241
|
+
manifest: readManifest(app.projectDir),
|
|
242
|
+
appConfig: await loadAppConfig(app.projectDir),
|
|
243
|
+
bundleDir: getBundleDir(app.projectDir),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
const server = createServer((req, res) => {
|
|
247
|
+
const headers = corsHeaders(req.headers.origin || '');
|
|
248
|
+
const respond = (status, content, contentType = 'text/plain; charset=utf-8') => {
|
|
249
|
+
res.writeHead(status, { ...headers, 'Content-Type': contentType });
|
|
250
|
+
res.end(req.method === 'HEAD' ? undefined : content);
|
|
251
|
+
};
|
|
252
|
+
try {
|
|
253
|
+
if (req.headers.origin && !isAllowedOrigin(req.headers.origin)) return respond(403, 'origin not allowed');
|
|
254
|
+
if (req.method === 'OPTIONS') return respond(204, '');
|
|
255
|
+
if (!['GET', 'HEAD'].includes(req.method)) return respond(405, 'method not allowed');
|
|
256
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
257
|
+
if (url.pathname === '/healthz') return respond(200, JSON.stringify({ ok: true }), 'application/json');
|
|
258
|
+
const match = url.pathname.match(/^\/a\/([^/]+)\/(.*)$/);
|
|
259
|
+
const state = match && states.get(decodeURIComponent(match[1]));
|
|
260
|
+
if (!state) return respond(404, 'not found');
|
|
261
|
+
if (match[2] === 'harness') {
|
|
262
|
+
const route = findHarnessRoute(state.manifest, url.searchParams.get('route') || '');
|
|
263
|
+
if (!route) return respond(404, 'unknown route');
|
|
264
|
+
return respond(200, renderHarnessHtml({ state, manifest: state.manifest, appConfig: state.appConfig, route, harnessOptions: harness, scenario: url.searchParams.get('scenario') }), 'text/html; charset=utf-8');
|
|
265
|
+
}
|
|
266
|
+
if (match[2].startsWith('bundle/')) {
|
|
267
|
+
const relativePath = decodeURIComponent(match[2].slice('bundle/'.length));
|
|
268
|
+
const file = safeJoin(state.bundleDir, relativePath);
|
|
269
|
+
if (!file || !existsSync(file) || !statSync(file).isFile()) return respond(404, 'not found');
|
|
270
|
+
return respond(200, readFileSync(file), CONTENT_TYPES[extFor(file)] || 'application/octet-stream');
|
|
271
|
+
}
|
|
272
|
+
return respond(404, 'not found');
|
|
273
|
+
} catch (error) {
|
|
274
|
+
return respond(500, error instanceof Error ? error.message : String(error));
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
await new Promise((accept, reject) => {
|
|
278
|
+
server.once('error', reject);
|
|
279
|
+
server.listen(port, '127.0.0.1', () => { server.off('error', reject); accept(); });
|
|
280
|
+
});
|
|
281
|
+
let closing;
|
|
282
|
+
return {
|
|
283
|
+
port: server.address().port,
|
|
284
|
+
close() {
|
|
285
|
+
closing ||= new Promise((accept) => {
|
|
286
|
+
server.close(accept);
|
|
287
|
+
server.closeAllConnections?.();
|
|
288
|
+
});
|
|
289
|
+
return closing;
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
package/src/runtime/profiles.js
CHANGED
|
@@ -228,8 +228,11 @@ function processIsAlive(pid) {
|
|
|
228
228
|
try {
|
|
229
229
|
process.kill(pid, 0);
|
|
230
230
|
return true;
|
|
231
|
-
} catch {
|
|
232
|
-
|
|
231
|
+
} catch (error) {
|
|
232
|
+
// Sandboxed children may be allowed to observe the worktree lease but not
|
|
233
|
+
// signal its dev.sh supervisor. POSIX EPERM proves that the PID exists;
|
|
234
|
+
// ESRCH (and every other probe failure) does not.
|
|
235
|
+
return error?.code === 'EPERM';
|
|
233
236
|
}
|
|
234
237
|
}
|
|
235
238
|
|
|
@@ -9,6 +9,7 @@ async function requestJson<T>(
|
|
|
9
9
|
options: { method?: string; body?: JsonBody } = {},
|
|
10
10
|
): Promise<T> {
|
|
11
11
|
const response = await fetch(url, {
|
|
12
|
+
signal: AbortSignal.timeout(90_000),
|
|
12
13
|
method: options.method || 'POST',
|
|
13
14
|
headers: {
|
|
14
15
|
'Content-Type': 'application/json',
|
|
@@ -59,7 +60,7 @@ export async function pushChangedSkills(
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
export async function downloadSkillBundle(bundleUrl: string): Promise<Buffer> {
|
|
62
|
-
const response = await fetch(bundleUrl);
|
|
63
|
+
const response = await fetch(bundleUrl, { signal: AbortSignal.timeout(90_000) });
|
|
63
64
|
if (!response.ok) {
|
|
64
65
|
const text = await response.text();
|
|
65
66
|
throw new Error(`GET ${bundleUrl} → ${response.status}: ${text}`);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import type {
|
|
2
3
|
AgentTargets,
|
|
3
4
|
LocalSkill,
|
|
@@ -33,6 +34,7 @@ import {
|
|
|
33
34
|
} from "./symlink-manager";
|
|
34
35
|
import { getPushCandidates } from "./sync-plan";
|
|
35
36
|
import { writeCloudSkillWithBundleFallback } from "./write-cloud-skill";
|
|
37
|
+
export { fetchSyncSettings } from './cloud-client';
|
|
36
38
|
|
|
37
39
|
export interface RunSkillSyncResult {
|
|
38
40
|
syncEnabled: boolean;
|
|
@@ -147,7 +149,17 @@ function decodeJwtSubject(jwt: string): string | null {
|
|
|
147
149
|
}
|
|
148
150
|
}
|
|
149
151
|
|
|
150
|
-
function
|
|
152
|
+
function cloudContentHash(skill: SyncPullResponse['skills'][number]): string {
|
|
153
|
+
return createHash('sha256').update(JSON.stringify({
|
|
154
|
+
md: skill.skill_md,
|
|
155
|
+
hash: skill.skill_folder_hash,
|
|
156
|
+
source: skill.skill_source_url,
|
|
157
|
+
files: skill.bundle_files?.slice().sort((a, b) => a.path.localeCompare(b.path)),
|
|
158
|
+
hydrationFailed: skill.bundle_hydration_failed === true,
|
|
159
|
+
})).digest('hex');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function shouldWriteCloudSkill(
|
|
151
163
|
cloudSkill: SyncPullResponse["skills"][number],
|
|
152
164
|
localSkills: Map<string, LocalSkill>,
|
|
153
165
|
previousState: NotisSyncState,
|
|
@@ -159,17 +171,20 @@ function shouldWriteCloudSkill(
|
|
|
159
171
|
return true;
|
|
160
172
|
}
|
|
161
173
|
|
|
174
|
+
const previous = previousState.skills[skillName];
|
|
175
|
+
if (previous?.folderHash === localSkill.folderHash
|
|
176
|
+
&& previous.cloudContentHash === cloudContentHash(cloudSkill)) return false;
|
|
177
|
+
|
|
162
178
|
if (cloudSkill.source === "curated") {
|
|
163
179
|
return cloudHash ? cloudHash !== localSkill.folderHash : true;
|
|
164
180
|
}
|
|
165
181
|
|
|
166
|
-
const previous = previousState.skills[skillName];
|
|
167
182
|
const localChangedSinceLastSync =
|
|
168
183
|
!previous || previous.folderHash !== localSkill.folderHash;
|
|
169
184
|
return (
|
|
170
185
|
!localChangedSinceLastSync &&
|
|
171
|
-
Boolean(cloudHash) &&
|
|
172
|
-
|
|
186
|
+
((Boolean(cloudHash) && cloudHash !== localSkill.folderHash)
|
|
187
|
+
|| Boolean(previous?.cloudContentHash && previous.cloudContentHash !== cloudContentHash(cloudSkill)))
|
|
173
188
|
);
|
|
174
189
|
}
|
|
175
190
|
|
|
@@ -178,6 +193,7 @@ function buildSyncState(
|
|
|
178
193
|
localSkills: LocalSkill[],
|
|
179
194
|
lastSyncedAt: string | null,
|
|
180
195
|
verifiedAgentLinks: Record<string, Partial<AgentTargets>> = {},
|
|
196
|
+
failedContentNames: ReadonlySet<string> = new Set(),
|
|
181
197
|
): NotisSyncState {
|
|
182
198
|
const localSkillMap = toSkillMap(localSkills);
|
|
183
199
|
const skills = Object.fromEntries(
|
|
@@ -191,6 +207,8 @@ function buildSyncState(
|
|
|
191
207
|
agentTargets: normalizeAgentTargets(skill.agent_targets),
|
|
192
208
|
verifiedAgentLinks: skill.status === "active" ? verifiedAgentLinks[skill.name] ?? {} : {},
|
|
193
209
|
cloudUpdatedAt: skill.updated_at,
|
|
210
|
+
...(!failedContentNames.has(skill.name) && !skill.skill_source_url
|
|
211
|
+
? { cloudContentHash: cloudContentHash(skill) } : {}),
|
|
194
212
|
syncedAt: lastSyncedAt || new Date().toISOString(),
|
|
195
213
|
},
|
|
196
214
|
];
|
|
@@ -392,7 +410,7 @@ export async function materializeCloudSkillsForLocalShell(
|
|
|
392
410
|
for (const failure of failedDownloads) delete verifiedLinks[failure.name];
|
|
393
411
|
|
|
394
412
|
await deps.writeSyncState(
|
|
395
|
-
buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks),
|
|
413
|
+
buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks, new Set(failedDownloads.map(item => item.name))),
|
|
396
414
|
syncPaths,
|
|
397
415
|
);
|
|
398
416
|
|
|
@@ -629,7 +647,7 @@ export async function runSkillSync(
|
|
|
629
647
|
const lastSyncedAt = pullResponse.last_synced_at || new Date().toISOString();
|
|
630
648
|
|
|
631
649
|
await deps.writeSyncState(
|
|
632
|
-
buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks),
|
|
650
|
+
buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks, new Set(failedDownloads.map(item => item.name))),
|
|
633
651
|
syncPaths,
|
|
634
652
|
);
|
|
635
653
|
|
|
@@ -12,6 +12,8 @@ export interface SyncedSkill {
|
|
|
12
12
|
/** Actual readable managed links, never inferred from desired targets. */
|
|
13
13
|
verifiedAgentLinks?: Partial<AgentTargets>;
|
|
14
14
|
cloudUpdatedAt?: string;
|
|
15
|
+
/** Content accepted on disk; independent of server-specific folder hash formats. */
|
|
16
|
+
cloudContentHash?: string;
|
|
15
17
|
syncedAt: string;
|
|
16
18
|
}
|
|
17
19
|
|
|
@@ -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,50 @@
|
|
|
1
|
+
import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { resolveRuntimeProfile } from './runtime/profiles.js';
|
|
5
|
+
import { ensureFreshOAuthCredential } from './runtime/oauth.js';
|
|
6
|
+
import { runSkillSync, fetchSyncSettings } from '../dist/skill-sync/index.js';
|
|
7
|
+
import { withSkillSyncLock } from './runtime/sync-skills.js';
|
|
8
|
+
|
|
9
|
+
export async function runAutomaticSkillSync({ profile, apiBase, userId }, {
|
|
10
|
+
resolveRuntime = resolveRuntimeProfile, refresh = ensureFreshOAuthCredential,
|
|
11
|
+
settings = fetchSyncSettings, sync = runSkillSync, lock = withSkillSyncLock,
|
|
12
|
+
} = {}) {
|
|
13
|
+
const runtime = resolveRuntime({ profile }, { requireAuth: true });
|
|
14
|
+
if (runtime.credentialKind !== 'oauth' || runtime.apiBase !== apiBase
|
|
15
|
+
|| runtime.oauthUserId !== userId) throw new Error('Automatic skill sync account changed; run notis skills sync to rebind');
|
|
16
|
+
await refresh(runtime);
|
|
17
|
+
const saved = await settings(runtime.apiBase, runtime.jwt);
|
|
18
|
+
if (saved.user_id !== userId) throw new Error('Automatic skill sync identity mismatch');
|
|
19
|
+
// Do not gather, unlink foreign accounts or write anything when opted out.
|
|
20
|
+
if (!saved.sync_enabled) return { status: 'disabled' };
|
|
21
|
+
const result = await lock(() => sync(runtime.apiBase, runtime.jwt, {
|
|
22
|
+
fetchSyncSettings: async () => saved,
|
|
23
|
+
}, { honorSyncEnabled: true }));
|
|
24
|
+
return { status: (result.failedLinks?.length || result.failedPushes?.length) ? 'partial' : 'synced', ...result };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function main(args = process.argv.slice(2)) {
|
|
28
|
+
const [profile, apiBase, userId] = args;
|
|
29
|
+
const root = join(homedir(), '.notis', 'skills', 'service');
|
|
30
|
+
const record = (value) => {
|
|
31
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
32
|
+
const target = join(root, 'status.json');
|
|
33
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
34
|
+
writeFileSync(temporary, JSON.stringify({ ...value, profile, at: new Date().toISOString() }, null, 2), { mode: 0o600 });
|
|
35
|
+
renameSync(temporary, target);
|
|
36
|
+
};
|
|
37
|
+
const deadline = setTimeout(() => {
|
|
38
|
+
record({ status: 'error', code: 'sync_timeout' });
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}, 240_000);
|
|
41
|
+
try {
|
|
42
|
+
record(await runAutomaticSkillSync({ profile, apiBase, userId }));
|
|
43
|
+
} catch (error) {
|
|
44
|
+
// Persist only a classified error, never a bearer, signed URL or response body.
|
|
45
|
+
record({ status: 'error', code: error.code || 'sync_failed' });
|
|
46
|
+
process.exitCode = 1;
|
|
47
|
+
} finally {
|
|
48
|
+
clearTimeout(deadline);
|
|
49
|
+
}
|
|
50
|
+
}
|