@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.
Files changed (69) hide show
  1. package/README.md +11 -45
  2. package/config/notis_app_design_rules.json +135 -0
  3. package/dist/agent-hooks/notis-agent-hook.mjs +8893 -10612
  4. package/dist/base-skills/notis-apps/SKILL.md +25 -573
  5. package/dist/base-skills/notis-apps/references/architecture.md +147 -0
  6. package/dist/base-skills/notis-apps/references/design.md +154 -0
  7. package/dist/base-skills/notis-apps/references/release.md +93 -0
  8. package/dist/base-skills/notis-apps/references/sdk.md +60 -0
  9. package/dist/base-skills/notis-apps/references/troubleshooting.md +26 -0
  10. package/dist/base-skills/notis-cli/SKILL.md +19 -267
  11. package/dist/base-skills/notis-cli/references/app-delivery.md +18 -0
  12. package/dist/base-skills/notis-cli/references/native-databases.md +20 -0
  13. package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
  14. package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
  15. package/dist/base-skills/notis-query/SKILL.md +13 -651
  16. package/dist/base-skills/notis-query/references/database-discovery.md +59 -0
  17. package/dist/base-skills/notis-query/references/documents.md +50 -0
  18. package/dist/base-skills/notis-query/references/query.md +543 -0
  19. package/dist/skill-sync/index.js +24 -7
  20. package/dist/skill-sync/index.js.map +4 -4
  21. package/dist/skill-sync-worker.mjs +2989 -0
  22. package/package.json +1 -2
  23. package/skills/notis-apps/cli.md +34 -95
  24. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +1 -1
  25. package/src/cli.js +4 -0
  26. package/src/command-specs/apps.js +322 -1560
  27. package/src/command-specs/diagnostics.js +37 -0
  28. package/src/command-specs/skills.js +23 -5
  29. package/src/runtime/agent-browser.js +169 -1
  30. package/src/runtime/app-boundary-validator.js +221 -0
  31. package/src/runtime/app-platform.js +359 -233
  32. package/src/runtime/app-test-server.js +292 -0
  33. package/src/runtime/profiles.js +5 -2
  34. package/src/runtime/skill-sync/cloud-client.ts +2 -1
  35. package/src/runtime/skill-sync/index.ts +24 -6
  36. package/src/runtime/skill-sync/types.ts +2 -0
  37. package/src/runtime/skill-sync-service.js +109 -0
  38. package/src/skill-sync-worker-entry.js +2 -0
  39. package/src/skill-sync-worker.js +50 -0
  40. package/template/app/page.tsx +45 -44
  41. package/template/components/page-heading.tsx +23 -0
  42. package/template/components/ui/badge.tsx +7 -4
  43. package/template/components/ui/card.tsx +24 -11
  44. package/template/components/ui/native-select.tsx +24 -0
  45. package/template/notis.config.ts +0 -1
  46. package/template/package.json +2 -2
  47. package/template/packages/sdk/package.json +1 -2
  48. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +55 -13
  49. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +3 -1
  50. package/template/packages/sdk/src/config.ts +0 -2
  51. package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +138 -28
  52. package/template/packages/sdk/src/hooks/useLongPressSelection.ts +79 -0
  53. package/template/packages/sdk/src/hooks/useMultiSelect.ts +2 -8
  54. package/template/packages/sdk/src/index.ts +3 -0
  55. package/template/packages/sdk/src/interactions/actions.ts +14 -1
  56. package/template/packages/sdk/src/interactions/shortcuts.tsx +79 -19
  57. package/template/packages/sdk/src/interactions/visibility.ts +13 -0
  58. package/template/packages/sdk/src/interactions.ts +5 -1
  59. package/template/packages/sdk/src/styles.css +28 -1
  60. package/src/runtime/app-dev-build-supervisor.js +0 -47
  61. package/src/runtime/app-dev-build.js +0 -41
  62. package/src/runtime/app-dev-consumers.js +0 -154
  63. package/src/runtime/app-dev-host-lock.js +0 -80
  64. package/src/runtime/app-dev-process-identity.js +0 -111
  65. package/src/runtime/app-dev-roots.js +0 -284
  66. package/src/runtime/app-dev-server.js +0 -1136
  67. package/src/runtime/app-dev-sessions.js +0 -185
  68. package/src/runtime/cli-mode.generated.js +0 -5
  69. 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
+ }
@@ -228,8 +228,11 @@ function processIsAlive(pid) {
228
228
  try {
229
229
  process.kill(pid, 0);
230
230
  return true;
231
- } catch {
232
- return false;
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 shouldWriteCloudSkill(
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
- cloudHash !== localSkill.folderHash
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('&', '&amp;').replaceAll('<', '&lt;')
22
+ .replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
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,2 @@
1
+ import { main } from './skill-sync-worker.js';
2
+ await main();
@@ -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
+ }