appduct 0.14.0 → 0.15.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.
Files changed (55) hide show
  1. package/README.md +52 -22
  2. package/dist/artifact-inspect.d.ts +38 -39
  3. package/dist/bin.js +45 -3
  4. package/dist/cli/result-types.d.ts +27 -1
  5. package/dist/cli/routes/daemon/index.js +32 -1
  6. package/dist/cli/routes/daemon/run.js +962 -521
  7. package/dist/cli/routes/daemon/start.js +40 -1
  8. package/dist/cli/routes/daemon/status.js +41 -1
  9. package/dist/cli/routes/daemon/stop.js +40 -1
  10. package/dist/cli/routes/doctor.js +40 -2
  11. package/dist/cli/routes/events/index.js +32 -1
  12. package/dist/cli/routes/events/ls.js +40 -1
  13. package/dist/cli/routes/events/since.js +40 -1
  14. package/dist/cli/routes/events/tail.js +40 -1
  15. package/dist/cli/routes/init.js +207 -107
  16. package/dist/cli/routes/keygen.js +32 -1
  17. package/dist/cli/routes/mcp.js +135 -24
  18. package/dist/cli/routes/sessions/index.js +32 -1
  19. package/dist/cli/routes/sessions/link.js +141 -21
  20. package/dist/cli/routes/sessions/ls.js +40 -1
  21. package/dist/cli/routes/sessions/revoke.js +40 -1
  22. package/dist/cli/routes/tools/call.js +40 -1
  23. package/dist/cli/routes/tools/describe.js +40 -1
  24. package/dist/cli/routes/tools/index.js +32 -1
  25. package/dist/cli/routes/tools/ls.js +40 -1
  26. package/dist/client/attach-page.d.ts +39 -0
  27. package/dist/client/bootstrap.d.ts +17 -3
  28. package/dist/client/index.d.ts +2 -1
  29. package/dist/client/index.js +286 -112
  30. package/dist/commands/doctor.d.ts +3 -4
  31. package/dist/commands/init.d.ts +8 -2
  32. package/dist/commands/link.d.ts +10 -1
  33. package/dist/daemon/calls.d.ts +1 -1
  34. package/dist/daemon/config.d.ts +6 -0
  35. package/dist/daemon/daemon.d.ts +6 -1
  36. package/dist/daemon/links.d.ts +9 -5
  37. package/dist/daemon/listener.d.ts +28 -1
  38. package/dist/daemon/rpc-errors.d.ts +3 -0
  39. package/dist/daemon/sessions.d.ts +11 -7
  40. package/dist/daemon/tls.d.ts +0 -3
  41. package/dist/devtools-relay/attach-tab.d.ts +33 -0
  42. package/dist/devtools-relay/index.d.ts +8 -0
  43. package/dist/devtools-relay/memory-daemon-sockets.d.ts +20 -0
  44. package/dist/devtools-relay/memory-devtools-browser.d.ts +10 -0
  45. package/dist/devtools-relay/memory-page-channel.d.ts +16 -0
  46. package/dist/devtools-relay/node-daemon-socket.d.ts +3 -0
  47. package/dist/devtools-relay/node-devtools-browser.d.ts +3 -0
  48. package/dist/devtools-relay/ports.d.ts +48 -0
  49. package/dist/devtools-relay/relay.d.ts +16 -0
  50. package/dist/index.js +45 -3
  51. package/dist/link.d.ts +35 -1
  52. package/dist/mcp/connect-tool.d.ts +15 -2
  53. package/dist/native-scheme.d.ts +11 -7
  54. package/dist/scheme.d.ts +5 -1
  55. package/package.json +15 -5
@@ -14,6 +14,7 @@ var KNOWN_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
14
14
  "daemonLogMaxBytes",
15
15
  "eventsLogMaxBytes",
16
16
  "policy",
17
+ "webOrigins",
17
18
  "advertisedIp",
18
19
  "scheme",
19
20
  "restartDaemonOnVersionMismatch"
@@ -74,6 +75,7 @@ var defaultConfig = (paths) => {
74
75
  daemonLogMaxBytes: DEFAULT_DAEMON_LOG_MAX_BYTES,
75
76
  eventsLogMaxBytes: 10 * 1024 * 1024,
76
77
  restartDaemonOnVersionMismatch: false,
78
+ webOrigins: [],
77
79
  policy: {
78
80
  default: "allow",
79
81
  destructive: "allow"
@@ -144,6 +146,12 @@ var loadConfig = async (paths, options = {}) => {
144
146
  "restartDaemonOnVersionMismatch"
145
147
  );
146
148
  }
149
+ if (parsed.webOrigins !== void 0) {
150
+ if (!Array.isArray(parsed.webOrigins)) {
151
+ throw configError("webOrigins", "must be an array of origin strings.");
152
+ }
153
+ config.webOrigins = parsed.webOrigins.map((origin) => requireNonEmptyString(origin, "webOrigins"));
154
+ }
147
155
  if (parsed.policy !== void 0) {
148
156
  if (typeof parsed.policy !== "object" || parsed.policy === null || Array.isArray(parsed.policy)) {
149
157
  throw configError("policy", "must be an object.");
@@ -1069,7 +1077,7 @@ var EXIT_CODE_BY_ERROR_TYPE = {
1069
1077
  // EX_NOINPUT (66): "an input file did not exist or was not readable" — `doctor` throws this for a
1070
1078
  // missing external tool or an unreadable/corrupt artifact, deliberately distinct from every other
1071
1079
  // code here so a release pipeline can't mistake "we couldn't tell" for either a clean pass or an
1072
- // assertion failure (docs/tasks/08-appduct-doctor.md).
1080
+ // assertion failure.
1073
1081
  inspection_error: 66,
1074
1082
  connection_error: 70,
1075
1083
  session_error: 71,
@@ -1829,23 +1837,34 @@ var probeIosProjectYml = async (root) => {
1829
1837
  };
1830
1838
  };
1831
1839
  var PROBES = [
1832
- probeAndroidGradle,
1833
- probeAndroidManifest,
1834
- probeIosInfoPlist,
1835
- probeIosProjectYml
1840
+ ["android", probeAndroidGradle],
1841
+ ["android", probeAndroidManifest],
1842
+ ["ios", probeIosInfoPlist],
1843
+ ["ios", probeIosProjectYml]
1836
1844
  ];
1845
+ var isFlutterProject = async (root) => {
1846
+ return (await readCappedText(join5(root, "pubspec.yaml"))).ok;
1847
+ };
1837
1848
  var discoverNativeScheme = async (root) => {
1838
1849
  const tried = [];
1839
1850
  const results = [];
1840
- for (const probe of PROBES) {
1841
- const outcome = await probe(root);
1842
- tried.push(...outcome.tried);
1843
- if (outcome.result !== void 0) {
1844
- results.push(outcome.result);
1851
+ const flutter = await isFlutterProject(root);
1852
+ const androidRoot = flutter ? join5(root, "android") : root;
1853
+ const iosRoots = flutter ? [join5(root, "ios"), join5(root, "macos")] : [root];
1854
+ for (const [platform, probe] of PROBES) {
1855
+ let roots = [root];
1856
+ if (platform === "android") {
1857
+ roots = [androidRoot];
1858
+ } else if (probe === probeIosInfoPlist) {
1859
+ roots = iosRoots;
1860
+ }
1861
+ for (const probeRoot of roots) {
1862
+ const outcome = await probe(probeRoot);
1863
+ tried.push(...outcome.tried);
1864
+ if (outcome.result !== void 0) {
1865
+ results.push(outcome.result);
1866
+ }
1845
1867
  }
1846
- }
1847
- if (results.length === 0) {
1848
- return { tried };
1849
1868
  }
1850
1869
  const distinctSchemes = new Set(results.map((entry) => entry.scheme));
1851
1870
  if (distinctSchemes.size > 1) {
@@ -1853,7 +1872,7 @@ var discoverNativeScheme = async (root) => {
1853
1872
  "Conflicting deep-link schemes found in this project: " + results.map((entry) => `${entry.origin} says ${JSON.stringify(entry.scheme)}`).join(", but ") + ". Appduct will not guess which one is right \u2014 pass --scheme <scheme> to pick one."
1854
1873
  );
1855
1874
  }
1856
- return { result: results[0], tried };
1875
+ return { results, tried };
1857
1876
  };
1858
1877
 
1859
1878
  // src/scheme.ts
@@ -1916,19 +1935,21 @@ var discoverStaticProjectScheme = async (cwd) => {
1916
1935
  const tried = [`${appJsonPath} ("expo.scheme")`];
1917
1936
  const appJsonScheme = await discoverExpoScheme(cwd);
1918
1937
  if (appJsonScheme !== void 0) {
1919
- return { scheme: appJsonScheme, source: "app-json", origin: appJsonPath, tried };
1938
+ return { scheme: appJsonScheme, source: "app-json", origin: appJsonPath, nativeResults: [], tried };
1920
1939
  }
1921
1940
  const native = await discoverNativeScheme(resolve(cwd));
1922
1941
  tried.push(...native.tried);
1923
- if (native.result !== void 0) {
1942
+ const [first] = native.results;
1943
+ if (first !== void 0) {
1924
1944
  return {
1925
- scheme: native.result.scheme,
1926
- source: native.result.source,
1927
- origin: native.result.origin,
1945
+ scheme: first.scheme,
1946
+ source: first.source,
1947
+ origin: first.origin,
1948
+ nativeResults: native.results,
1928
1949
  tried
1929
1950
  };
1930
1951
  }
1931
- return { tried };
1952
+ return { nativeResults: [], tried };
1932
1953
  };
1933
1954
  var globalConfigDirs = (options = {}) => {
1934
1955
  const excluded = /* @__PURE__ */ new Set([join6(resolve(options.homeDir ?? homedir()), PROJECT_CONFIG_DIR)]);
@@ -2104,6 +2125,25 @@ ${tried.map((location, index) => ` ${index + 1}. ${location}`).join("\n")}
2104
2125
 
2105
2126
  // src/link.ts
2106
2127
  var composeDeepLink = (scheme, result) => `${scheme}:///?appduct=${result.deepLinkPayload}&pin=${encodeURIComponent(result.pin)}`;
2128
+ var assertWebPageUrl = (pageUrl) => {
2129
+ let protocol;
2130
+ try {
2131
+ protocol = new URL(pageUrl).protocol;
2132
+ } catch {
2133
+ }
2134
+ if (protocol !== "http:" && protocol !== "https:") {
2135
+ throw usageError(`"url" must be an http(s) URL (got "${pageUrl}").`);
2136
+ }
2137
+ };
2138
+ var composeWebLink = (pageUrl, result) => {
2139
+ const payload = result.deepLinkPayload;
2140
+ return {
2141
+ sessionId: result.sessionId,
2142
+ url: `${pageUrl.split("#")[0]}#appduct=${payload}`,
2143
+ script: `window.__APPDUCT__.connect("${payload}")`,
2144
+ expiresAt: result.expiresAt
2145
+ };
2146
+ };
2107
2147
 
2108
2148
  // src/qr-terminal.ts
2109
2149
  import { toQR } from "toqr";
@@ -2146,14 +2186,18 @@ var WAIT_FOR_SESSION_TOOL_NAME = "appduct_wait_for_session";
2146
2186
  var OPEN_TARGET_ADDRESS_OVERRIDE = "127.0.0.1";
2147
2187
  var DEFAULT_WAIT_TIMEOUT_MS = 12e4;
2148
2188
  var NO_TARGET = "none";
2189
+ var WEB_TARGET = "web";
2149
2190
  var QR_INSTRUCTIONS = 'This link was NOT delivered to a device \u2014 a human has to open it. Before calling appduct_wait_for_session: show the user the "qr" field verbatim inside a fenced code block (it is pre-rendered terminal art \u2014 do not redraw, summarize, or describe it), show "deepLink" underneath it for a device that cannot scan, and ask the user to scan it with the target device. Only then call appduct_wait_for_session; it blocks until the device connects and gives no output while it waits, so calling it before you have asked will look like a hang.';
2150
2191
  var CONNECT_TOOL_DESCRIPTOR = {
2151
2192
  name: CONNECT_TOOL_NAME,
2152
- description: 'Mint an Appduct session link and get it onto a device. With no "target", auto-detects a booted iOS simulator or attached Android device and delivers the link to it, returning { sessionId, delivered: true, autoDetected: true } \u2014 this is the normal agent path and needs no human. Pass target "android" or "ios-sim" (optionally with "device") to choose explicitly, or target "none" to force the human flow. Delivering to "android" (explicit or auto-detected) needs "appId" (the Android package name) \u2014 without it, more than one installed app declaring the same scheme would show an ambiguous "Open with" chooser on the device instead of failing loudly. Target "ios-device" is an experimental, never-auto-detected path that delivers to a paired physical iPhone/iPad via xcrun devicectl; it needs "appId" (the iOS bundle id), iOS 17+, Xcode 15+, a connected and trusted device with Developer Mode on, a dev-signed build installed, and the phone on the same network as this machine. An "appId" not supplied here falls back to "appId.<platform>" in the nearest .appduct/config.json (see "appduct init"); it is rejected outright with target "ios-sim" or "none", which need no app id. Pass "relaunch": true with ios-device if the app is already running and the delivery does not take. Only when no device is detected (or target is "none") does this return a QR code for a human to scan, along with an "instructions" field saying what to do with it. Follow up with appduct_wait_for_session to know when the device has connected.',
2193
+ description: `Mint an Appduct session link and get it onto a device. With no "target", auto-detects a booted iOS simulator or attached Android device and delivers the link to it, returning { sessionId, delivered: true, autoDetected: true } \u2014 this is the normal agent path and needs no human. Pass target "android" or "ios-sim" (optionally with "device") to choose explicitly, or target "none" to force the human flow. Delivering to "android" (explicit or auto-detected) needs "appId" (the Android package name) \u2014 without it, more than one installed app declaring the same scheme would show an ambiguous "Open with" chooser on the device instead of failing loudly. Target "ios-device" is an experimental, never-auto-detected path that delivers to a paired physical iPhone/iPad via xcrun devicectl; it needs "appId" (the iOS bundle id), iOS 17+, Xcode 15+, a connected and trusted device with Developer Mode on, a dev-signed build installed, and the phone on the same network as this machine. An "appId" not supplied here falls back to "appId.<platform>" in the nearest .appduct/config.json (see "appduct init"); it is rejected outright with target "ios-sim" or "none", which need no app id. Pass "relaunch": true with ios-device if the app is already running and the delivery does not take. Target "web" is for a web page: pass "url" (the page to open) and get back { url, script } \u2014 open "url" in a browser, or run "script" in a page that is already open, then call appduct_wait_for_session. It needs no scheme and no device. To have the daemon attach a page that is already open in a Chrome launched with --remote-debugging-port, also pass "browserUrl" (its debugging endpoint on this machine: 127.0.0.1, [::1] or localhost, such as http://127.0.0.1:9222): the tab whose address starts with "url" is attached and the call returns { sessionId, url, targetId, attached: true }, with no further step; "url" is the tab's own address. When no tab or several match, the error lists the open tabs with their target ids; pass one as "targetId" to pick it. A popup, or a page in a new tab, is not attached. Only when no device is detected (or target is "none") does this return a QR code for a human to scan, along with an "instructions" field saying what to do with it. Follow up with appduct_wait_for_session to know when the device has connected.`,
2153
2194
  inputSchema: {
2154
2195
  type: "object",
2155
2196
  properties: {
2156
- target: { type: "string", enum: ["android", "ios-sim", "ios-device", NO_TARGET] },
2197
+ target: { type: "string", enum: ["android", "ios-sim", "ios-device", NO_TARGET, WEB_TARGET] },
2198
+ url: { type: "string" },
2199
+ browserUrl: { type: "string" },
2200
+ targetId: { type: "string" },
2157
2201
  device: { type: "string" },
2158
2202
  appId: { type: "string" },
2159
2203
  relaunch: { type: "boolean" },
@@ -2192,10 +2236,10 @@ var asOptionalConnectTarget = (value) => {
2192
2236
  if (value === void 0) {
2193
2237
  return void 0;
2194
2238
  }
2195
- if (typeof value !== "string" || !(isOpenTarget(value) || value === NO_TARGET)) {
2239
+ if (typeof value !== "string" || !(isOpenTarget(value) || value === NO_TARGET || value === WEB_TARGET)) {
2196
2240
  throw new McpBuiltinToolError(
2197
2241
  "invalid_request",
2198
- '"target" must be "android", "ios-sim", "ios-device", or "none".'
2242
+ '"target" must be "android", "ios-sim", "ios-device", "web", or "none".'
2199
2243
  );
2200
2244
  }
2201
2245
  return value;
@@ -2273,6 +2317,42 @@ var handleConnectTool = async (rawArgs, deps) => {
2273
2317
  const appId = asOptionalNonEmptyString(args.appId, "appId");
2274
2318
  const relaunch = asOptionalBoolean(args.relaunch, "relaunch");
2275
2319
  const ttlSeconds = asOptionalPositiveNumber(args.ttlSeconds, "ttlSeconds");
2320
+ const url = asOptionalNonEmptyString(args.url, "url");
2321
+ const browserUrl = asOptionalNonEmptyString(args.browserUrl, "browserUrl");
2322
+ const targetId = asOptionalNonEmptyString(args.targetId, "targetId");
2323
+ if ((browserUrl !== void 0 || targetId !== void 0) && requestedTarget !== WEB_TARGET) {
2324
+ throw new McpBuiltinToolError("invalid_request", '"browserUrl" and "targetId" only apply with target "web".');
2325
+ }
2326
+ if (targetId !== void 0 && browserUrl === void 0) {
2327
+ throw new McpBuiltinToolError("invalid_request", '"targetId" needs "browserUrl": it names a tab of that browser.');
2328
+ }
2329
+ if (requestedTarget === WEB_TARGET) {
2330
+ if (url === void 0) {
2331
+ throw new McpBuiltinToolError("invalid_request", '"url" (the page to open) is required with target "web".');
2332
+ }
2333
+ if (device !== void 0 || appId !== void 0 || relaunch !== void 0) {
2334
+ throw new McpBuiltinToolError(
2335
+ "invalid_request",
2336
+ '"device", "appId" and "relaunch" do not apply with target "web".'
2337
+ );
2338
+ }
2339
+ try {
2340
+ assertWebPageUrl(url);
2341
+ } catch (error) {
2342
+ throw new McpBuiltinToolError("invalid_request", error.message);
2343
+ }
2344
+ if (browserUrl !== void 0) {
2345
+ const attached = await deps.call(RPC_METHODS3.webAttach, { url, browserUrl, targetId, ttlSeconds });
2346
+ return { ...attached, attached: true };
2347
+ }
2348
+ return composeWebLink(
2349
+ url,
2350
+ await deps.call(RPC_METHODS3.linkCreate, { ttlSeconds, transport: "web" })
2351
+ );
2352
+ }
2353
+ if (url !== void 0) {
2354
+ throw new McpBuiltinToolError("invalid_request", '"url" only applies with target "web".');
2355
+ }
2276
2356
  if (device !== void 0 && (requestedTarget === void 0 || requestedTarget === NO_TARGET)) {
2277
2357
  throw new McpBuiltinToolError(
2278
2358
  "invalid_request",
@@ -3678,6 +3758,36 @@ var renderInvokeData = (colors, data, flags) => {
3678
3758
  return [colors.green("Result"), formatScalar(data, flags)];
3679
3759
  };
3680
3760
  var renderLinkData = (colors, data, flags, qr) => {
3761
+ if ("attached" in data) {
3762
+ return [
3763
+ colors.green("Page Attached"),
3764
+ ...renderFields(
3765
+ "Page",
3766
+ [
3767
+ ["Session", data.sessionId],
3768
+ ["Tab", data.targetId],
3769
+ ["URL", data.url],
3770
+ ["Expires", new Date(data.expiresAt * 1e3).toISOString()]
3771
+ ],
3772
+ flags
3773
+ )
3774
+ ];
3775
+ }
3776
+ if ("script" in data) {
3777
+ return [
3778
+ colors.green("Link Created"),
3779
+ ...renderFields(
3780
+ "Link",
3781
+ [
3782
+ ["Session", data.sessionId],
3783
+ ["URL", data.url],
3784
+ ["Script", data.script],
3785
+ ["Expires", new Date(data.expiresAt * 1e3).toISOString()]
3786
+ ],
3787
+ flags
3788
+ )
3789
+ ];
3790
+ }
3681
3791
  const lines = [
3682
3792
  colors.green("Link Created"),
3683
3793
  ...renderFields(
@@ -3798,6 +3908,7 @@ var renderDaemonStatusData = (colors, data, flags) => {
3798
3908
  ["PID", data.daemon.pid],
3799
3909
  ["Started at", data.daemon.started_at],
3800
3910
  ["WSS port", data.daemon.wss_port],
3911
+ ["Web port", data.daemon.web_port],
3801
3912
  ["Pinned keys", data.daemon.pinned_keys.length],
3802
3913
  ["Sessions", data.daemon.session_count]
3803
3914
  ],
@@ -93,7 +93,7 @@ var EXIT_CODE_BY_ERROR_TYPE = {
93
93
  // EX_NOINPUT (66): "an input file did not exist or was not readable" — `doctor` throws this for a
94
94
  // missing external tool or an unreadable/corrupt artifact, deliberately distinct from every other
95
95
  // code here so a release pipeline can't mistake "we couldn't tell" for either a clean pass or an
96
- // assertion failure (docs/tasks/08-appduct-doctor.md).
96
+ // assertion failure.
97
97
  inspection_error: 66,
98
98
  connection_error: 70,
99
99
  session_error: 71,
@@ -549,6 +549,36 @@ var renderInvokeData = (colors, data, flags) => {
549
549
  return [colors.green("Result"), formatScalar(data, flags)];
550
550
  };
551
551
  var renderLinkData = (colors, data, flags, qr) => {
552
+ if ("attached" in data) {
553
+ return [
554
+ colors.green("Page Attached"),
555
+ ...renderFields(
556
+ "Page",
557
+ [
558
+ ["Session", data.sessionId],
559
+ ["Tab", data.targetId],
560
+ ["URL", data.url],
561
+ ["Expires", new Date(data.expiresAt * 1e3).toISOString()]
562
+ ],
563
+ flags
564
+ )
565
+ ];
566
+ }
567
+ if ("script" in data) {
568
+ return [
569
+ colors.green("Link Created"),
570
+ ...renderFields(
571
+ "Link",
572
+ [
573
+ ["Session", data.sessionId],
574
+ ["URL", data.url],
575
+ ["Script", data.script],
576
+ ["Expires", new Date(data.expiresAt * 1e3).toISOString()]
577
+ ],
578
+ flags
579
+ )
580
+ ];
581
+ }
552
582
  const lines = [
553
583
  colors.green("Link Created"),
554
584
  ...renderFields(
@@ -669,6 +699,7 @@ var renderDaemonStatusData = (colors, data, flags) => {
669
699
  ["PID", data.daemon.pid],
670
700
  ["Started at", data.daemon.started_at],
671
701
  ["WSS port", data.daemon.wss_port],
702
+ ["Web port", data.daemon.web_port],
672
703
  ["Pinned keys", data.daemon.pinned_keys.length],
673
704
  ["Sessions", data.daemon.session_count]
674
705
  ],
@@ -27,6 +27,7 @@ var KNOWN_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
27
27
  "daemonLogMaxBytes",
28
28
  "eventsLogMaxBytes",
29
29
  "policy",
30
+ "webOrigins",
30
31
  "advertisedIp",
31
32
  "scheme",
32
33
  "restartDaemonOnVersionMismatch"
@@ -87,6 +88,7 @@ var defaultConfig = (paths) => {
87
88
  daemonLogMaxBytes: DEFAULT_DAEMON_LOG_MAX_BYTES,
88
89
  eventsLogMaxBytes: 10 * 1024 * 1024,
89
90
  restartDaemonOnVersionMismatch: false,
91
+ webOrigins: [],
90
92
  policy: {
91
93
  default: "allow",
92
94
  destructive: "allow"
@@ -157,6 +159,12 @@ var loadConfig = async (paths, options = {}) => {
157
159
  "restartDaemonOnVersionMismatch"
158
160
  );
159
161
  }
162
+ if (parsed.webOrigins !== void 0) {
163
+ if (!Array.isArray(parsed.webOrigins)) {
164
+ throw configError("webOrigins", "must be an array of origin strings.");
165
+ }
166
+ config.webOrigins = parsed.webOrigins.map((origin) => requireNonEmptyString(origin, "webOrigins"));
167
+ }
160
168
  if (parsed.policy !== void 0) {
161
169
  if (typeof parsed.policy !== "object" || parsed.policy === null || Array.isArray(parsed.policy)) {
162
170
  throw configError("policy", "must be an object.");
@@ -916,7 +924,7 @@ var EXIT_CODE_BY_ERROR_TYPE = {
916
924
  // EX_NOINPUT (66): "an input file did not exist or was not readable" — `doctor` throws this for a
917
925
  // missing external tool or an unreadable/corrupt artifact, deliberately distinct from every other
918
926
  // code here so a release pipeline can't mistake "we couldn't tell" for either a clean pass or an
919
- // assertion failure (docs/tasks/08-appduct-doctor.md).
927
+ // assertion failure.
920
928
  inspection_error: 66,
921
929
  connection_error: 70,
922
930
  session_error: 71,
@@ -1643,31 +1651,42 @@ var probeIosProjectYml = async (root) => {
1643
1651
  };
1644
1652
  };
1645
1653
  var PROBES = [
1646
- probeAndroidGradle,
1647
- probeAndroidManifest,
1648
- probeIosInfoPlist,
1649
- probeIosProjectYml
1654
+ ["android", probeAndroidGradle],
1655
+ ["android", probeAndroidManifest],
1656
+ ["ios", probeIosInfoPlist],
1657
+ ["ios", probeIosProjectYml]
1650
1658
  ];
1659
+ var isFlutterProject = async (root) => {
1660
+ return (await readCappedText(join4(root, "pubspec.yaml"))).ok;
1661
+ };
1651
1662
  var discoverNativeScheme = async (root) => {
1652
1663
  const tried = [];
1653
1664
  const results = [];
1654
- for (const probe of PROBES) {
1655
- const outcome = await probe(root);
1656
- tried.push(...outcome.tried);
1657
- if (outcome.result !== void 0) {
1658
- results.push(outcome.result);
1665
+ const flutter = await isFlutterProject(root);
1666
+ const androidRoot = flutter ? join4(root, "android") : root;
1667
+ const iosRoots = flutter ? [join4(root, "ios"), join4(root, "macos")] : [root];
1668
+ for (const [platform, probe] of PROBES) {
1669
+ let roots = [root];
1670
+ if (platform === "android") {
1671
+ roots = [androidRoot];
1672
+ } else if (probe === probeIosInfoPlist) {
1673
+ roots = iosRoots;
1674
+ }
1675
+ for (const probeRoot of roots) {
1676
+ const outcome = await probe(probeRoot);
1677
+ tried.push(...outcome.tried);
1678
+ if (outcome.result !== void 0) {
1679
+ results.push(outcome.result);
1680
+ }
1659
1681
  }
1660
1682
  }
1661
- if (results.length === 0) {
1662
- return { tried };
1663
- }
1664
1683
  const distinctSchemes = new Set(results.map((entry) => entry.scheme));
1665
1684
  if (distinctSchemes.size > 1) {
1666
1685
  throw usageError(
1667
1686
  "Conflicting deep-link schemes found in this project: " + results.map((entry) => `${entry.origin} says ${JSON.stringify(entry.scheme)}`).join(", but ") + ". Appduct will not guess which one is right \u2014 pass --scheme <scheme> to pick one."
1668
1687
  );
1669
1688
  }
1670
- return { result: results[0], tried };
1689
+ return { results, tried };
1671
1690
  };
1672
1691
 
1673
1692
  // src/scheme.ts
@@ -1730,19 +1749,21 @@ var discoverStaticProjectScheme = async (cwd) => {
1730
1749
  const tried = [`${appJsonPath} ("expo.scheme")`];
1731
1750
  const appJsonScheme = await discoverExpoScheme(cwd);
1732
1751
  if (appJsonScheme !== void 0) {
1733
- return { scheme: appJsonScheme, source: "app-json", origin: appJsonPath, tried };
1752
+ return { scheme: appJsonScheme, source: "app-json", origin: appJsonPath, nativeResults: [], tried };
1734
1753
  }
1735
1754
  const native = await discoverNativeScheme(resolve(cwd));
1736
1755
  tried.push(...native.tried);
1737
- if (native.result !== void 0) {
1756
+ const [first] = native.results;
1757
+ if (first !== void 0) {
1738
1758
  return {
1739
- scheme: native.result.scheme,
1740
- source: native.result.source,
1741
- origin: native.result.origin,
1759
+ scheme: first.scheme,
1760
+ source: first.source,
1761
+ origin: first.origin,
1762
+ nativeResults: native.results,
1742
1763
  tried
1743
1764
  };
1744
1765
  }
1745
- return { tried };
1766
+ return { nativeResults: [], tried };
1746
1767
  };
1747
1768
  var globalConfigDirs = (options = {}) => {
1748
1769
  const excluded = /* @__PURE__ */ new Set([join5(resolve(options.homeDir ?? homedir()), PROJECT_CONFIG_DIR)]);
@@ -1926,6 +1947,39 @@ ${tried.map((location, index) => ` ${index + 1}. ${location}`).join("\n")}
1926
1947
  // src/link.ts
1927
1948
  var OPEN_TARGET_ADDRESS_OVERRIDE = "127.0.0.1";
1928
1949
  var composeDeepLink = (scheme, result) => `${scheme}:///?appduct=${result.deepLinkPayload}&pin=${encodeURIComponent(result.pin)}`;
1950
+ var assertWebPageUrl = (pageUrl) => {
1951
+ let protocol;
1952
+ try {
1953
+ protocol = new URL(pageUrl).protocol;
1954
+ } catch {
1955
+ }
1956
+ if (protocol !== "http:" && protocol !== "https:") {
1957
+ throw usageError(`"url" must be an http(s) URL (got "${pageUrl}").`);
1958
+ }
1959
+ };
1960
+ var composeWebLink = (pageUrl, result) => {
1961
+ const payload = result.deepLinkPayload;
1962
+ return {
1963
+ sessionId: result.sessionId,
1964
+ url: `${pageUrl.split("#")[0]}#appduct=${payload}`,
1965
+ script: `window.__APPDUCT__.connect("${payload}")`,
1966
+ expiresAt: result.expiresAt
1967
+ };
1968
+ };
1969
+ var mintWebLink = async (options) => {
1970
+ assertWebPageUrl(options.url);
1971
+ const result = await callDaemon(
1972
+ RPC_METHODS2.linkCreate,
1973
+ { ttlSeconds: options.ttlSeconds, transport: "web" },
1974
+ { stateDir: options.stateDir, spawn: options.spawn, autoSpawn: options.autoSpawn }
1975
+ );
1976
+ return composeWebLink(options.url, result);
1977
+ };
1978
+ var attachWebTab = async ({ stateDir, spawn, autoSpawn, ...params }) => {
1979
+ assertWebPageUrl(params.url);
1980
+ const result = await callDaemon(RPC_METHODS2.webAttach, params, { stateDir, spawn, autoSpawn, requestTimeoutMs: 3e4 });
1981
+ return { ...result, attached: true };
1982
+ };
1929
1983
  var mintLink = async (options) => {
1930
1984
  if (options.target !== void 0 && !isOpenTarget(options.target)) {
1931
1985
  throw usageError(
@@ -2013,6 +2067,9 @@ var mintLink = async (options) => {
2013
2067
  // src/commands/link.ts
2014
2068
  var handleLinkCommand = async (options, context) => {
2015
2069
  let openTarget;
2070
+ if (options.browserUrl !== void 0 || options.targetId !== void 0) {
2071
+ throw usageError('"--browser-url" and "--target-id" only apply with "--open web".');
2072
+ }
2016
2073
  if (options.open !== void 0) {
2017
2074
  if (!isOpenTarget(options.open)) {
2018
2075
  throw usageError(
@@ -2046,6 +2103,35 @@ var handleLinkCommand = async (options, context) => {
2046
2103
  });
2047
2104
  return { ok: true, data: result };
2048
2105
  };
2106
+ var handleWebLinkCommand = async (options, context) => {
2107
+ if (options.url === void 0) {
2108
+ throw usageError('"--open web" needs the page URL: appduct sessions link --open web <url>.');
2109
+ }
2110
+ if (options.device !== void 0 || options.appId !== void 0 || options.relaunch !== void 0) {
2111
+ throw usageError('"--device", "--app-id" and "--relaunch" do not apply with "--open web".');
2112
+ }
2113
+ if (options.targetId !== void 0 && options.browserUrl === void 0) {
2114
+ throw usageError('"--target-id" needs "--browser-url": it names a tab of that browser.');
2115
+ }
2116
+ if (options.browserUrl !== void 0) {
2117
+ const attached = await attachWebTab({
2118
+ stateDir: context.stateDir,
2119
+ spawn: context.spawn,
2120
+ ttlSeconds: options.ttlSeconds,
2121
+ url: options.url,
2122
+ browserUrl: options.browserUrl,
2123
+ targetId: options.targetId
2124
+ });
2125
+ return { ok: true, data: attached };
2126
+ }
2127
+ const result = await mintWebLink({
2128
+ stateDir: context.stateDir,
2129
+ spawn: context.spawn,
2130
+ ttlSeconds: options.ttlSeconds,
2131
+ url: options.url
2132
+ });
2133
+ return { ok: true, data: result };
2134
+ };
2049
2135
 
2050
2136
  // src/cli/command-options.ts
2051
2137
  var parsePositiveIntegerOption = (value, flagName) => {
@@ -2404,6 +2490,36 @@ var renderInvokeData = (colors, data, flags) => {
2404
2490
  return [colors.green("Result"), formatScalar(data, flags)];
2405
2491
  };
2406
2492
  var renderLinkData = (colors, data, flags, qr) => {
2493
+ if ("attached" in data) {
2494
+ return [
2495
+ colors.green("Page Attached"),
2496
+ ...renderFields(
2497
+ "Page",
2498
+ [
2499
+ ["Session", data.sessionId],
2500
+ ["Tab", data.targetId],
2501
+ ["URL", data.url],
2502
+ ["Expires", new Date(data.expiresAt * 1e3).toISOString()]
2503
+ ],
2504
+ flags
2505
+ )
2506
+ ];
2507
+ }
2508
+ if ("script" in data) {
2509
+ return [
2510
+ colors.green("Link Created"),
2511
+ ...renderFields(
2512
+ "Link",
2513
+ [
2514
+ ["Session", data.sessionId],
2515
+ ["URL", data.url],
2516
+ ["Script", data.script],
2517
+ ["Expires", new Date(data.expiresAt * 1e3).toISOString()]
2518
+ ],
2519
+ flags
2520
+ )
2521
+ ];
2522
+ }
2407
2523
  const lines = [
2408
2524
  colors.green("Link Created"),
2409
2525
  ...renderFields(
@@ -2524,6 +2640,7 @@ var renderDaemonStatusData = (colors, data, flags) => {
2524
2640
  ["PID", data.daemon.pid],
2525
2641
  ["Started at", data.daemon.started_at],
2526
2642
  ["WSS port", data.daemon.wss_port],
2643
+ ["Web port", data.daemon.web_port],
2527
2644
  ["Pinned keys", data.daemon.pinned_keys.length],
2528
2645
  ["Sessions", data.daemon.session_count]
2529
2646
  ],
@@ -2747,10 +2864,13 @@ var route = async (context) => {
2747
2864
  return executeCommand(
2748
2865
  commandName(context),
2749
2866
  guarded(context)(
2750
- () => handleLinkCommand(
2867
+ () => (options.open === "web" ? handleWebLinkCommand : handleLinkCommand)(
2751
2868
  {
2752
2869
  ttlSeconds: parsePositiveIntegerOption(options.ttl, "--ttl"),
2753
2870
  scheme: typeof options.scheme === "string" ? options.scheme : void 0,
2871
+ url: context.args[0],
2872
+ browserUrl: typeof options.browserUrl === "string" ? options.browserUrl : void 0,
2873
+ targetId: typeof options.targetId === "string" ? options.targetId : void 0,
2754
2874
  open: typeof options.open === "string" ? options.open : void 0,
2755
2875
  device: typeof options.device === "string" ? options.device : void 0,
2756
2876
  // cac camelCases `--app-id`; the dashed spelling is kept as a fallback so a
@@ -24,6 +24,7 @@ var KNOWN_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
24
24
  "daemonLogMaxBytes",
25
25
  "eventsLogMaxBytes",
26
26
  "policy",
27
+ "webOrigins",
27
28
  "advertisedIp",
28
29
  "scheme",
29
30
  "restartDaemonOnVersionMismatch"
@@ -84,6 +85,7 @@ var defaultConfig = (paths) => {
84
85
  daemonLogMaxBytes: DEFAULT_DAEMON_LOG_MAX_BYTES,
85
86
  eventsLogMaxBytes: 10 * 1024 * 1024,
86
87
  restartDaemonOnVersionMismatch: false,
88
+ webOrigins: [],
87
89
  policy: {
88
90
  default: "allow",
89
91
  destructive: "allow"
@@ -154,6 +156,12 @@ var loadConfig = async (paths, options = {}) => {
154
156
  "restartDaemonOnVersionMismatch"
155
157
  );
156
158
  }
159
+ if (parsed.webOrigins !== void 0) {
160
+ if (!Array.isArray(parsed.webOrigins)) {
161
+ throw configError("webOrigins", "must be an array of origin strings.");
162
+ }
163
+ config.webOrigins = parsed.webOrigins.map((origin) => requireNonEmptyString(origin, "webOrigins"));
164
+ }
157
165
  if (parsed.policy !== void 0) {
158
166
  if (typeof parsed.policy !== "object" || parsed.policy === null || Array.isArray(parsed.policy)) {
159
167
  throw configError("policy", "must be an object.");
@@ -923,7 +931,7 @@ var EXIT_CODE_BY_ERROR_TYPE = {
923
931
  // EX_NOINPUT (66): "an input file did not exist or was not readable" — `doctor` throws this for a
924
932
  // missing external tool or an unreadable/corrupt artifact, deliberately distinct from every other
925
933
  // code here so a release pipeline can't mistake "we couldn't tell" for either a clean pass or an
926
- // assertion failure (docs/tasks/08-appduct-doctor.md).
934
+ // assertion failure.
927
935
  inspection_error: 66,
928
936
  connection_error: 70,
929
937
  session_error: 71,
@@ -1376,6 +1384,36 @@ var renderInvokeData = (colors, data, flags) => {
1376
1384
  return [colors.green("Result"), formatScalar(data, flags)];
1377
1385
  };
1378
1386
  var renderLinkData = (colors, data, flags, qr) => {
1387
+ if ("attached" in data) {
1388
+ return [
1389
+ colors.green("Page Attached"),
1390
+ ...renderFields(
1391
+ "Page",
1392
+ [
1393
+ ["Session", data.sessionId],
1394
+ ["Tab", data.targetId],
1395
+ ["URL", data.url],
1396
+ ["Expires", new Date(data.expiresAt * 1e3).toISOString()]
1397
+ ],
1398
+ flags
1399
+ )
1400
+ ];
1401
+ }
1402
+ if ("script" in data) {
1403
+ return [
1404
+ colors.green("Link Created"),
1405
+ ...renderFields(
1406
+ "Link",
1407
+ [
1408
+ ["Session", data.sessionId],
1409
+ ["URL", data.url],
1410
+ ["Script", data.script],
1411
+ ["Expires", new Date(data.expiresAt * 1e3).toISOString()]
1412
+ ],
1413
+ flags
1414
+ )
1415
+ ];
1416
+ }
1379
1417
  const lines = [
1380
1418
  colors.green("Link Created"),
1381
1419
  ...renderFields(
@@ -1496,6 +1534,7 @@ var renderDaemonStatusData = (colors, data, flags) => {
1496
1534
  ["PID", data.daemon.pid],
1497
1535
  ["Started at", data.daemon.started_at],
1498
1536
  ["WSS port", data.daemon.wss_port],
1537
+ ["Web port", data.daemon.web_port],
1499
1538
  ["Pinned keys", data.daemon.pinned_keys.length],
1500
1539
  ["Sessions", data.daemon.session_count]
1501
1540
  ],