nativemason 0.2.0 → 0.3.0

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.
@@ -364,9 +364,11 @@ export class BridgeApplication {
364
364
  async open(input) {
365
365
  if ((input.nativePort === undefined) !== (input.nativeToken === undefined))
366
366
  throw new BridgeFault('INVALID_INPUT', 'nativePort and nativeToken must be supplied together');
367
+ if (input.nativeHost && !input.nativeHost.startsWith('127.') && !input.nativeToken)
368
+ throw new BridgeFault('INVALID_INPUT', 'LAN pairing requires explicit nativePort and nativeToken');
367
369
  const id = randomUUID(), token = input.nativeToken ?? randomUUID(), port = input.nativePort ?? 19482;
368
370
  const source = this.deps.source(input.projectRoot);
369
- const native = await this.deps.native({ port, token, scenarioId: input.scenarioId, sessionId: id, targetId: input.nativeTarget?.targetId, platform: input.nativeTarget?.platform });
371
+ const native = await this.deps.native({ port, token, host: input.nativeHost, scenarioId: input.scenarioId, sessionId: id, targetId: input.nativeTarget?.targetId, platform: input.nativeTarget?.platform });
370
372
  let browser;
371
373
  try {
372
374
  browser = await this.deps.browser({ ...input, sessionId: id });
@@ -1,12 +1,16 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { createTwoFilesPatch } from 'diff';
3
- import { Node, Project, SyntaxKind } from 'ts-morph';
3
+ import { Node, Project, SyntaxKind, ts } from 'ts-morph';
4
4
  import { isLiteralStyleObject, valueText } from './source-format.js';
5
5
  import { RnStylePatchSchema, SourceRefSchema } from '@nativemason/contracts';
6
6
  export const hashSource = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
7
7
  class Unsupported extends Error {
8
8
  }
9
9
  function reject(message) { throw new Unsupported(message); }
10
+ function hasTrailingComma(sourceText) {
11
+ const scanner = ts.createScanner(ts.ScriptTarget.Latest, true, ts.LanguageVariant.Standard, sourceText);
12
+ return scanner.scan() === ts.SyntaxKind.CommaToken && scanner.scan() === ts.SyntaxKind.EndOfFileToken;
13
+ }
10
14
  function staticLiteral(expression) {
11
15
  if (Node.isStringLiteral(expression) || Node.isNumericLiteral(expression) || Node.isNoSubstitutionTemplateLiteral(expression))
12
16
  return true;
@@ -284,7 +288,7 @@ export function planSourcePatch(sourceText, sourceRef, patch, scope) {
284
288
  const closing = object.getEnd() - 1;
285
289
  const properties = object.getProperties();
286
290
  // Inserting before the closing brace preserves comments and all unrelated text.
287
- const trailingComma = properties.length > 0 && /,\s*(?:\/\*[\s\S]*?\*\/\s*|\/\/[^\n]*\n\s*)*$/.test(sourceText.slice(properties.at(-1).getEnd(), closing));
291
+ const trailingComma = properties.length > 0 && hasTrailingComma(sourceText.slice(properties.at(-1).getEnd(), closing));
288
292
  edits.push({ start: closing, end: closing, text: `${properties.length && !trailingComma ? ',' : ''} ${additions.join(', ')} ` });
289
293
  }
290
294
  else if (!object) {
@@ -92,24 +92,53 @@ export function createTransactionSourcePort(projectRoot) {
92
92
  catch (error) {
93
93
  if (error.code !== 'EEXIST')
94
94
  throw error;
95
- let owner;
95
+ const recovery = path.join(dir, 'transaction.recovery.lock');
96
+ let recoveryHandle;
96
97
  try {
97
- owner = JSON.parse(await readFile(lock, 'utf8'));
98
+ recoveryHandle = await open(recovery, 'wx', 0o600);
99
+ await recoveryHandle.writeFile(JSON.stringify({ pid: process.pid }));
100
+ await recoveryHandle.sync();
101
+ await recoveryHandle.close();
102
+ recoveryHandle = undefined;
98
103
  }
99
- catch {
100
- throw new BridgeFault('INTERNAL_ERROR', 'Transaction lock is unreadable; recovery requires checking the owning process');
104
+ catch (cause) {
105
+ const created = recoveryHandle !== undefined;
106
+ await recoveryHandle?.close().catch(() => undefined);
107
+ if (created)
108
+ await remove(recovery).catch(() => undefined);
109
+ if (cause.code === 'EEXIST' && !created)
110
+ throw new BridgeFault('RECOVERY_CONFLICT', 'Transaction recovery is locked; inspect .nativemason/transaction.recovery.lock for its owner before retrying');
111
+ throw cause;
101
112
  }
102
113
  try {
103
- process.kill(owner.pid, 0);
104
- throw new BridgeFault('INVALID_INPUT', 'Another source transaction is active');
114
+ let owner;
115
+ try {
116
+ const decoded = JSON.parse(await readFile(lock, 'utf8'));
117
+ if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded) || !Number.isSafeInteger(decoded.pid) || (decoded.pid <= 0))
118
+ throw new Error('Invalid lock owner');
119
+ owner = decoded;
120
+ }
121
+ catch (cause) {
122
+ if (cause.code !== 'ENOENT')
123
+ throw new BridgeFault('INTERNAL_ERROR', 'Transaction lock is unreadable; recovery requires checking the owning process');
124
+ }
125
+ if (owner) {
126
+ try {
127
+ process.kill(owner.pid, 0);
128
+ throw new BridgeFault('INVALID_INPUT', 'Another source transaction is active');
129
+ }
130
+ catch (cause) {
131
+ if (cause instanceof BridgeFault)
132
+ throw cause;
133
+ if (cause.code !== 'ESRCH')
134
+ throw cause;
135
+ }
136
+ await remove(lock);
137
+ }
105
138
  }
106
- catch (cause) {
107
- if (cause instanceof BridgeFault)
108
- throw cause;
109
- if (cause.code !== 'ESRCH')
110
- throw cause;
139
+ finally {
140
+ await remove(recovery);
111
141
  }
112
- await remove(lock);
113
142
  return work();
114
143
  }
115
144
  try {
package/dist/mcp/index.js CHANGED
@@ -20,7 +20,7 @@ const descriptions = {
20
20
  close_session: 'Close Chromium and native transport and discard session state.',
21
21
  };
22
22
  export function createMcpServer(application = new BridgeApplication()) {
23
- const server = new McpServer({ name: 'nativemason', version: '0.2.0' });
23
+ const server = new McpServer({ name: 'nativemason', version: '0.3.0' });
24
24
  server.registerPrompt('layout-workflow', { description: 'Measured layout correction workflow' }, async () => ({ messages: [{ role: 'user', content: { type: 'text', text: 'Open a session for the same named scenario on web and native. Inspect measurements and diagnostics; load or infer and review constraints. Use suggest_fix or batch trials, preview the transaction and blast radius, then apply only an accepted candidate. Verify matching committed revisions on the selected native target. Report unavailable evidence; use revert_commit when requested and verify the revert. Close the session.' } }] }));
25
25
  for (const name of Object.keys(ToolSchemas)) {
26
26
  server.registerTool(name, { description: descriptions[name], inputSchema: ToolSchemas[name].shape, outputSchema: ToolMcpOutputSchemas[name] }, async (input) => {
@@ -1,8 +1,11 @@
1
1
  import { randomUUID, timingSafeEqual } from 'node:crypto';
2
2
  import { WebSocketServer, WebSocket } from 'ws';
3
- import { BridgeFault, NativeClientMessageSchema } from '@nativemason/contracts';
3
+ import { BridgeFault, NativeBindHostSchema, NativeClientMessageSchema } from '@nativemason/contracts';
4
4
  export async function createNativeBridge(options) {
5
- const server = new WebSocketServer({ host: '127.0.0.1', port: options.port, maxPayload: 4 * 1024 * 1024 });
5
+ const host = NativeBindHostSchema.safeParse(options.host ?? '127.0.0.1');
6
+ if (!host.success)
7
+ throw new BridgeFault('INVALID_INPUT', 'Native host must be a loopback or private LAN IPv4 interface address');
8
+ const server = new WebSocketServer({ host: host.data, port: options.port, maxPayload: 4 * 1024 * 1024 });
6
9
  await new Promise((resolve, reject) => { server.once('listening', resolve); server.once('error', reject); });
7
10
  let client;
8
11
  let closed = false;
@@ -87,6 +87,8 @@ export function createNativeScenarioPort(options) {
87
87
  if (closed)
88
88
  throw new BridgeFault('SESSION_NOT_FOUND', 'Native scenario driver is closed');
89
89
  const parsed = ScenarioScriptSchema.parse(script);
90
+ if (options.target.platform === 'ios' && options.target.kind === 'physical' && [...parsed.reset, ...parsed.actions].some(action => action.type !== 'hook'))
91
+ throw new BridgeFault('UNSUPPORTED_ACTION', 'Physical iOS supports registered app hooks; Maestro UI actions require an iOS simulator. Navigate manually before capture.');
90
92
  // Reject unsupported static actions before executing earlier hooks or UI actions.
91
93
  for (const action of [...parsed.reset, ...parsed.actions]) {
92
94
  if (action.type === 'hook' && !options.runHook)
@@ -238,6 +240,16 @@ export function createNativeProfilePort(options) {
238
240
  return {
239
241
  recover: () => recover(),
240
242
  async withProfile(profile, work) {
243
+ if (options.target.platform === 'ios' && options.target.kind === 'physical') {
244
+ if (profile.fontScale !== undefined || profile.orientation !== undefined) {
245
+ if (!options.readProfile)
246
+ throw new BridgeFault('UNSUPPORTED_PROFILE', 'Physical iOS profile checks require runtime measurements');
247
+ const actual = await options.readProfile();
248
+ if ((profile.fontScale !== undefined && Math.abs(actual.fontScale - profile.fontScale) > 0.01) || (profile.orientation !== undefined && actual.orientation !== profile.orientation))
249
+ throw new BridgeFault('UNSUPPORTED_PROFILE', 'Change font scale or orientation manually on the iOS device, then capture its measured profile');
250
+ }
251
+ return work();
252
+ }
241
253
  if (active || profileOperations.has(journal))
242
254
  throw new BridgeFault('RECOVERY_CONFLICT', 'Native profile operations must be serialized');
243
255
  active = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nativemason",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "nativemason": "./bin/nativemason.js",
@@ -17,10 +17,10 @@
17
17
  "@babel/core": "^7.28.5",
18
18
  "@babel/types": "^7.28.5",
19
19
  "@modelcontextprotocol/sdk": "^1.24.0",
20
- "@nativemason/contracts": "0.2.0",
20
+ "@nativemason/contracts": "0.3.0",
21
21
  "diff": "^8.0.2",
22
22
  "playwright": "^1.56.1",
23
- "sharp": "^0.34.5",
23
+ "sharp": "^0.35.5",
24
24
  "ts-morph": "^27.0.2",
25
25
  "ws": "^8.18.3"
26
26
  },