@petercjl/ecom-cli 0.3.1 → 0.3.2

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/lib/browser.mjs CHANGED
@@ -12,14 +12,22 @@ export function processProfile(executable,command) {
12
12
  if(executable.endsWith('/Ecom.app/Contents/MacOS/Ecom'))return path.join(os.homedir(),'Library','Application Support','Ecom','Ecom');
13
13
  return null;
14
14
  }
15
- export function discover({profile,instance}={}) {
15
+ export function discover({profile,instance}={}, {allowFixedDevCdp=false}={}) {
16
16
  if(process.platform!=='darwin') throw fail('PLATFORM_UNSUPPORTED','Browser transport currently supports macOS; no debugging fallback is available');
17
17
  const candidates=[];
18
18
  for(const line of ps(['-axo','pid=,uid=,comm=']).split('\n')) {
19
19
  const m=line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
20
20
  if(!m || Number(m[2])!==process.getuid() || !/\/(Ecom Dev|Ecom)\.app\/Contents\/MacOS\/\1$/.test(m[3])) continue;
21
+ const channel=m[3].includes('/Ecom Dev.app/')?'dev':'stable';
22
+ if (!profile && !instance && channel !== 'stable') continue;
21
23
  let command; try {command=ps(['-ww','-p',m[1],'-o','command=']).trim();}catch{continue;}
22
- if(/--(?:remote-debugging|headless|enable-automation)/.test(command)) continue;
24
+ const debugFlags = command.match(/(?:^| )--remote-debugging[^ ]*/g) ?? [];
25
+ if (/--(?:headless|enable-automation)/.test(command)) continue;
26
+ if (debugFlags.length) {
27
+ const fixedPort = debugFlags[0].match(/^ --remote-debugging-port=([1-9][0-9]{0,4})$/);
28
+ if (!allowFixedDevCdp || channel !== 'dev' || debugFlags.length !== 1 ||
29
+ !fixedPort || Number(fixedPort[1]) > 65535) continue;
30
+ }
23
31
  const selected=processProfile(m[3],command);
24
32
  if(!selected || !path.isAbsolute(selected) || (profile && path.resolve(profile)!==path.resolve(selected))) continue;
25
33
  const folder=path.join(selected,'Default','.ecom-native-cli'), file=path.join(folder,'endpoint.json');
@@ -28,9 +36,9 @@ export function discover({profile,instance}={}) {
28
36
  const endpoint=JSON.parse(fs.readFileSync(file,'utf8'));
29
37
  if(endpoint.product!=='Ecom' || endpoint.schemaVersion!==1 || endpoint.pid!==Number(m[1]) || endpoint.executable!==m[3] || endpoint.userDataDir!==selected || !/^[\da-f-]{36}$/.test(endpoint.instanceId) || typeof endpoint.authToken!=='string') continue;
30
38
  if(instance && endpoint.instanceId!==instance) continue;
31
- candidates.push({folder,endpoint,channel:m[3].includes('/Ecom Dev.app/')?'dev':'stable'});
39
+ candidates.push({folder,endpoint,channel});
32
40
  }
33
- if(candidates.length!==1) throw fail(candidates.length?'INSTANCE_AMBIGUOUS':'INSTANCE_NOT_RUNNING',candidates.length?'Multiple Ecom instances; specify --profile or --instance':'Open an Ecom build supporting this CLI in normal desktop mode');
41
+ if(candidates.length!==1) throw fail(candidates.length?'INSTANCE_AMBIGUOUS':'INSTANCE_NOT_RUNNING',candidates.length?'Multiple Ecom instances; specify --profile or --instance':(profile||instance?'Open the selected Ecom instance in normal desktop mode':'Open the stable Ecom app, or explicitly select Ecom Dev with --profile or --instance'));
34
42
  return candidates[0];
35
43
  }
36
44
  const instanceOf=({endpoint,channel})=>({id:endpoint.instanceId,pid:endpoint.pid,channel});
@@ -76,6 +84,41 @@ export async function inspectInConnection(connection,selector) {
76
84
  login:{state:string(row.state)??'unconfigured',service:string(row.authentication?.service),authenticationState:string(row.authentication?.state),verificationSupported:row.verificationSupported===true,checking:row.checking===true,checkedAt:string(row.checkedAt),identityBound:!!row.identity,identityConfirmed:row.state==='ready'&&!!row.identity,expiry:row.expiry?{kind:string(row.expiry.kind),cookieExpiresAt:string(row.expiry.cookieExpiresAt)}:null}};
77
85
  return {ok:true,schemaVersion:1,operationId:body.operationId,instance:instanceOf(connection),container};
78
86
  }
87
+ export async function openContainerHome(options) {
88
+ const connection = discover(options, {allowFixedDevCdp: true});
89
+ const listed = await readMailbox(connection);
90
+ const container = resolveContainer(listed.containers, options.container);
91
+ const body = await callMailbox(connection, {method:'shops.open', containerId:container.id, service:'home'});
92
+ const requestedUrl = homeUrl(body.requestedUrl);
93
+ if (body.containerId !== container.id || body.service !== 'home' ||
94
+ body.navigation !== 'started' || !requestedUrl) {
95
+ throw fail('PROTOCOL_MISMATCH', 'Unexpected container homepage response',
96
+ {operationId:body.operationId, executed:'unknown'});
97
+ }
98
+ return {ok:true, schemaVersion:1, operationId:body.operationId, instance:instanceOf(connection),
99
+ container, service:'home', status:'started', requestedUrl};
100
+ }
101
+
102
+ export async function qianniuLoginStep(action, options) {
103
+ const connection=discover(options);
104
+ const listed=await readMailbox(connection);
105
+ const container=resolveContainer(listed.containers,options.container);
106
+ const body=await callMailbox(connection,{method:'shops.'+action,containerId:container.id,
107
+ ...(action==='open'?{service:'qianniu'}:{})});
108
+ const statuses={
109
+ open:['started'],
110
+ 'login-status':['page-unavailable','loading','login-page','backend-page','ambiguous'],
111
+ 'submit-login':['wrong-page','manual-verification-required','password-form-unavailable',
112
+ 'credentials-not-filled','already-submitted','clicked'],
113
+ };
114
+ const status=action==='open'?body.navigation:body.status;
115
+ if(body.containerId!==container.id||!statuses[action].includes(status)||
116
+ (action==='open'&&(body.service!=='qianniu'||body.requestedUrl!=='https://myseller.taobao.com/'))) {
117
+ throw fail('PROTOCOL_MISMATCH','Unexpected Qianniu response',{operationId:body.operationId,executed:'unknown'});
118
+ }
119
+ return {ok:true,schemaVersion:1,operationId:body.operationId,instance:instanceOf(connection),
120
+ container,service:'qianniu',status};
121
+ }
79
122
  export async function requestSession(options={}) {
80
123
  if(!options.container||!options.url)throw fail('INVALID_ARGUMENT','session request requires --container and --url');
81
124
  const url=validateReadUrl(options.url,options.method??'GET');
@@ -90,25 +133,39 @@ export async function requestInConnection(connection,options) {
90
133
  if(body.containerId!==container.id||body.httpStatus!==200||!Object.hasOwn(body,'data'))throw fail('PROTOCOL_MISMATCH','Session response does not match selected container or JSON response contract',{operationId:body.operationId,executed:'unknown'});
91
134
  return {ok:true,schemaVersion:1,operationId:body.operationId,instance:instanceOf(connection),container,request:{method:'GET',url},httpStatus:body.httpStatus,data:body.data,businessStatus:'not-evaluated'};
92
135
  }
93
- export async function registeredReadInConnection(connection,options,operation,params,registryHash) {
136
+ export async function registeredReadInConnection(connection,options,operation,params,registryHash,route,signedRoute) {
94
137
  if(connection.endpoint.businessReadProtocol!==1)throw fail('BROWSER_UPGRADE_REQUIRED','This operation needs an Ecom build with native business read protocol 1');
95
- if(connection.endpoint.businessReadRegistryHash!==registryHash)throw fail('BROWSER_UPGRADE_REQUIRED','Browser and CLI business catalogs differ; update them together');
138
+ const useRoute=connection.endpoint.businessRouteProtocol===1&&route!==undefined;
139
+ if(signedRoute&&connection.endpoint.signedBusinessRouteProtocol!==1)throw fail('BROWSER_UPGRADE_REQUIRED','This read requires signed route protocol 1');
140
+ if(!signedRoute&&!useRoute&&connection.endpoint.businessReadRegistryHash!==registryHash)throw fail('BROWSER_UPGRADE_REQUIRED','Browser and CLI business catalogs differ; update them together');
96
141
  const listed=await readMailbox(connection),container=resolveContainer(listed.containers,options.container);
97
- const body=await callMailbox(connection,{method:'business.read',containerId:container.id,operation,params},75000);
142
+ const body=await callMailbox(connection,{method:'business.read',containerId:container.id,operation,params,...(useRoute?{route}:{}),...(signedRoute?{signedRoute}:{})},75000);
98
143
  if(body.containerId!==container.id||body.businessOperation!==operation||body.httpStatus!==200||!Object.hasOwn(body,'data'))throw fail('PROTOCOL_MISMATCH','Registered response differs from the pinned container or operation',{operationId:body.operationId,executed:'unknown'});
99
- return {ok:true,schemaVersion:1,operationId:body.operationId,instance:instanceOf(connection),container,operation,httpStatus:body.httpStatus,data:body.data,...(Array.isArray(body.dataFlows)&&body.dataFlows.length?{dataFlows:body.dataFlows}:{}),transport:'native-business-read',identityBinding:'container-bound-account',registryHash};
144
+ return {ok:true,schemaVersion:1,operationId:body.operationId,instance:instanceOf(connection),container,operation,httpStatus:body.httpStatus,data:body.data,...(Array.isArray(body.dataFlows)&&body.dataFlows.length?{dataFlows:body.dataFlows}:{}),transport:'native-business-read',identityBinding:'container-bound-account',...(signedRoute?{signedBusinessRouteProtocol:1}:useRoute?{businessRouteProtocol:1}:{registryHash})};
100
145
  }
101
- export async function openBusinessPageInConnection(connection,options,operation,entryUrl,registryHash) {
102
- if(connection.endpoint.businessPageOpenProtocol!==1||connection.endpoint.businessReadRegistryHash!==registryHash)throw fail('BROWSER_UPGRADE_REQUIRED','Update Ecom to the matching registered-page navigation capability');
146
+ export async function openBusinessPageInConnection(connection,options,operation,entryUrl,registryHash,route) {
147
+ const useRoute=connection.endpoint.businessRouteProtocol===1&&route!==undefined;
148
+ if(connection.endpoint.businessPageOpenProtocol!==1||(!useRoute&&connection.endpoint.businessReadRegistryHash!==registryHash))throw fail('BROWSER_UPGRADE_REQUIRED','Update Ecom to the matching registered-page navigation capability');
103
149
  const listed=await readMailbox(connection),container=resolveContainer(listed.containers,options.container);
104
150
  const background=!options.foreground;
105
- const body=await callMailbox(connection,{method:'shops.open',containerId:container.id,businessOperation:operation,background,reuse:background});
151
+ const body=await callMailbox(connection,{method:'shops.open',containerId:container.id,businessOperation:operation,background,reuse:background,...(useRoute?{route}:{})});
106
152
  if(body.containerId!==container.id||!['started','reused'].includes(body.navigation)||body.requestedUrl!==entryUrl)throw fail('PROTOCOL_MISMATCH','Navigation target differs from the registered container or entry page',{operationId:body.operationId,executed:'unknown'});
107
- return {ok:true,schemaVersion:1,operationId:body.operationId,instance:instanceOf(connection),container,operation,requestedUrl:body.requestedUrl,navigation:body.navigation,background,authentication:'not-verified'};
153
+ let restoration;
154
+ if(body.restoration?.status!==undefined) {
155
+ const r=body.restoration.restore;
156
+ if(!['navigation-started','navigation-queued'].includes(body.restoration.status)||!r||r.containerId!==container.id||!Number.isInteger(r.tabId)||r.tabId<=0||typeof r.requestId!=='string'||!r.requestId||!['queued','loading','foreground-loading','ready','failed'].includes(r.phase))
157
+ throw fail('PROTOCOL_MISMATCH','Page restoration differs from the selected container',{operationId:body.operationId,executed:'unknown'});
158
+ restoration={status:body.restoration.status,restore:{requestId:r.requestId,tabId:r.tabId,containerId:r.containerId,phase:r.phase}};
159
+ }
160
+ return {ok:true,schemaVersion:1,operationId:body.operationId,instance:instanceOf(connection),container,operation,requestedUrl:body.requestedUrl,navigation:body.navigation,background,authentication:'not-verified',...(restoration?{restoration}:{})};
108
161
  }
109
162
  function safeDiagnostics(value) {
110
163
  if(!value||typeof value!=='object')return undefined;
111
164
  const result={};
165
+ if(Array.isArray(value.tabProtection))result.tabProtection=value.tabProtection.slice(0,60).filter(r=>
166
+ r&&['mvp','discard','freeze','connected-freeze'].includes(r.source)&&
167
+ ((Number.isInteger(r.code)&&r.code>=0&&r.code<=30)||(typeof r.code==='string'&&/^[A-Z_]{1,40}$/.test(r.code)))
168
+ ).map(r=>({source:r.source,code:r.code}));
112
169
  if(typeof value.phase==='string'&&/^[a-z-]{1,32}$/.test(value.phase))result.phase=value.phase;
113
170
  if(Number.isInteger(value.netError)&&value.netError<=0&&value.netError>=-9999)result.netError=value.netError;
114
171
  if(['json','html','text','none'].includes(value.bodyKind))result.bodyKind=value.bodyKind;
package/lib/cdp.mjs ADDED
@@ -0,0 +1,79 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {fail} from './common.mjs';
4
+
5
+ export async function cdpCommand(command, options) {
6
+ if (!options.profile || !path.isAbsolute(options.profile)) {
7
+ throw fail('INVALID_ARGUMENT', 'Specify the absolute Ecom Dev --profile path');
8
+ }
9
+ let endpoint;
10
+ try {
11
+ const file = path.join(options.profile, 'DevToolsActivePort');
12
+ const info = await fs.lstat(file);
13
+ if (!info.isFile() || info.size > 256) throw new Error();
14
+ endpoint = (await fs.readFile(file, 'utf8')).trim().split(/\r?\n/);
15
+ } catch {
16
+ throw fail('CDP_NOT_ENABLED', 'No CDP endpoint in this profile. Start Ecom Dev in CDP mode.');
17
+ }
18
+ const [rawPort, browserPath] = endpoint;
19
+ const port = Number(rawPort);
20
+ if (!/^\d{1,5}$/.test(rawPort) || port < 1 || port > 65535 ||
21
+ !/^\/devtools\/browser\/[\da-f-]+$/i.test(browserPath ?? '')) {
22
+ throw fail('CDP_ENDPOINT_INVALID', 'Invalid Ecom CDP endpoint record');
23
+ }
24
+ const host = '127.0.0.1:' + port;
25
+ const httpUrl = 'http://' + host;
26
+ const webSocketDebuggerUrl = 'ws://' + host + browserPath;
27
+ async function read(route) {
28
+ try {
29
+ const response = await fetch(httpUrl + route, {redirect: 'error', signal: AbortSignal.timeout(3000)});
30
+ if (!response.ok) throw new Error();
31
+ return await response.json();
32
+ } catch {
33
+ throw fail('CDP_UNREACHABLE', 'Ecom CDP did not respond on loopback');
34
+ }
35
+ }
36
+ const version = await read('/json/version');
37
+ if (version.webSocketDebuggerUrl !== webSocketDebuggerUrl) {
38
+ throw fail('CDP_ENDPOINT_INVALID', 'The live CDP endpoint does not match the selected profile');
39
+ }
40
+ if (command === 'endpoint') return {ok: true, httpUrl, webSocketDebuggerUrl};
41
+ if (command === 'tabs') return {ok: true, targets: await read('/json/list')};
42
+ const method = options['cdp-method'];
43
+ const target = options['cdp-target'];
44
+ if (!/^[A-Za-z]+\.[A-Za-z][A-Za-z0-9]*$/.test(method ?? '') ||
45
+ (target && !/^[\da-f]+$/i.test(target))) {
46
+ throw fail('INVALID_ARGUMENT', 'Specify a CDP Domain.method and optionally a target ID from cdp tabs');
47
+ }
48
+ let params;
49
+ try {
50
+ params = JSON.parse(options['params-json'] ?? '{}');
51
+ if (!params || typeof params !== 'object' || Array.isArray(params)) throw new Error();
52
+ } catch {
53
+ throw fail('INVALID_ARGUMENT', 'CDP parameters must be a JSON object');
54
+ }
55
+ const url = target ? 'ws://' + host + '/devtools/page/' + target : webSocketDebuggerUrl;
56
+ const socket = new WebSocket(url);
57
+ try {
58
+ const result = await new Promise((resolve, reject) => {
59
+ const deadline = setTimeout(() => reject(fail('CDP_TIMEOUT', 'No CDP reply; execution outcome is unknown')), 15000);
60
+ const finish = (error, value) => {
61
+ clearTimeout(deadline);
62
+ if (error) reject(error);
63
+ else resolve(value);
64
+ };
65
+ socket.addEventListener('open', () => socket.send(JSON.stringify({id: 1, method, params})), {once: true});
66
+ socket.addEventListener('message', event => {
67
+ const reply = JSON.parse(event.data);
68
+ if (reply.id !== 1) return;
69
+ if (reply.error) finish(fail('CDP_COMMAND_FAILED', reply.error.message));
70
+ else finish(null, reply.result);
71
+ });
72
+ socket.addEventListener('error', () => finish(fail('CDP_DISCONNECTED', 'CDP socket failed; check the target before retrying')), {once: true});
73
+ socket.addEventListener('close', () => finish(fail('CDP_DISCONNECTED', 'CDP socket closed before reply')), {once: true});
74
+ });
75
+ return {ok: true, result};
76
+ } finally {
77
+ socket.close();
78
+ }
79
+ }
package/lib/cli.mjs CHANGED
@@ -1,10 +1,16 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import {root,pkg,fail} from './common.mjs';
4
- import {discover,listContainers,inspectContainer,requestSession,validateReadUrl} from './browser.mjs';
4
+ import {discover,listContainers,inspectContainer,requestSession,validateReadUrl,openContainerHome,qianniuLoginStep} from './browser.mjs';
5
+ import {cdpCommand} from './cdp.mjs';
6
+ import {pageCommand,validatePageOptions} from './page.mjs';
7
+ import {searchXhs} from './xhs.mjs';
8
+ import {noteCommand,validateNoteOptions} from './xhs-note.mjs';
9
+ import {readMemory,tabMemoryAction,memoryOperation,validateTabTarget,autoMemory,protectTab,validateAutoOptions,restoreSettings,validateRestoreOptions} from './memory.mjs';
5
10
  import {source,status,installSkill,checkUpdate,updateInstall} from './maintenance.mjs';
6
11
  import {inventory,operationSchema,runRead,openOperation,planMutation,showPlan,authorizePlan,nativeRegistryHash} from './sycm.mjs';
7
12
  export const capabilities=()=>JSON.parse(fs.readFileSync(path.join(root,'capabilities.json'),'utf8'));
13
+ const qianniuCommands=['shops open','shops login-status','shops submit-login'];
8
14
  export function parse(argv) {
9
15
  const positions=[],options={}, caps=capabilities();
10
16
  for(let i=0;i<argv.length;i++) {
@@ -13,8 +19,8 @@ export function parse(argv) {
13
19
  if(word==='--version'&&argv.length===1){positions.push('version');continue;}
14
20
  const key=word.slice(2);
15
21
  if(Object.hasOwn(options,key)) throw fail('INVALID_ARGUMENT','Duplicate option '+word);
16
- if(['json','help','yes','foreground'].includes(key))options[key]=true;
17
- else if(['agent','target','profile','instance','name','version','container','url','method','operation','params-json','plan','confirm'].includes(key)&&argv[i+1]&&!argv[i+1].startsWith('--')) options[key]=argv[++i];
22
+ if(['json','help','yes','foreground','fixture'].includes(key))options[key]=true;
23
+ else if(['agent','target','profile','instance','name','version','container','url','method','operation','params-json','plan','confirm','service','keyword','via','manifest','account-id','preparation','digest','fixture-port','tab','expected-page-version','operation-id','enabled','idle-seconds','keep-awake','lazy-startup','max-background','output','cdp-method','cdp-target'].includes(key)&&argv[i+1]&&!argv[i+1].startsWith('--')) options[key]=argv[++i];
18
24
  else throw fail('INVALID_ARGUMENT','Unknown option or missing value: '+word);
19
25
  }
20
26
  let command=positions.join(' ')||'help';
@@ -23,7 +29,21 @@ export function parse(argv) {
23
29
  if(!caps.commandContracts[command]) throw fail('INVALID_ARGUMENT','Unsupported command; use help --json');
24
30
  if(command!=='help')for(const key of Object.keys(options))if(!caps.commandContracts[command].options['--'+key])throw fail('INVALID_ARGUMENT','Option is not supported by this command: --'+key);
25
31
  if(options.name&&options.name!=='ecom-cli')throw fail('INVALID_ARGUMENT','Unknown Skill name');
32
+ if(command.startsWith('xhs note '))validateNoteOptions(command.slice(9),options);
33
+ if(command==='xhs search'&&(!options.container||typeof options.keyword!=='string'||!options.keyword.trim()||Buffer.byteLength(options.keyword.trim())>180||options.via!=='page'))throw fail('INVALID_ARGUMENT','Specify --container, --keyword (up to 180 UTF-8 bytes), and --via page; Cookie-only search is not yet supported');
34
+ if(['page read','media download'].includes(command))validatePageOptions(command,options);
26
35
  if(command==='containers inspect'&&!options.container)throw fail('INVALID_ARGUMENT','containers inspect requires --container');
36
+ if(['tabs discard','tabs wake','tabs protect'].includes(command))validateTabTarget(options);
37
+ if(command==='tabs protect'&&!['on','off'].includes(options['keep-awake']))throw fail('INVALID_ARGUMENT','Specify --keep-awake on|off');
38
+ if(command==='memory auto set')validateAutoOptions(options);
39
+ if(command==='memory restore set')validateRestoreOptions(options);
40
+ if(command==='memory operation'&&!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(options['operation-id']??''))throw fail('INVALID_ARGUMENT','Specify --operation-id UUID');
41
+ if(command==='shops open'&&(!options.container||!['home','qianniu'].includes(options.service??'home'))) {
42
+ throw fail('INVALID_ARGUMENT','Specify --container and optionally --service home|qianniu');
43
+ }
44
+ if(command!=='shops open'&&qianniuCommands.includes(command)&&(!options.container||options.service!=='qianniu')) {
45
+ throw fail('INVALID_ARGUMENT','Specify --container and --service qianniu');
46
+ }
27
47
  if(command==='session request') {
28
48
  if(!options.container||!options.url)throw fail('INVALID_ARGUMENT','session request requires --container and --url');
29
49
  options.url=validateReadUrl(options.url,options.method??'GET');
@@ -36,14 +56,30 @@ export function parse(argv) {
36
56
  }
37
57
  export async function main(argv) {
38
58
  const {command,options,requested}=parse(argv);
39
- if(command==='help')return {ok:true,package:pkg.name,version:pkg.version,usage:'ecom-cli containers list|inspect | session request | sycm operations|schema|run|open|capabilities|mutation plan|show|apply | skill source|status|install|update | update check|install | doctor; use help --json for required options',...capabilities(),...(capabilities().commandContracts[requested]??{})};
59
+ if(command==='help')return {ok:true,package:pkg.name,version:pkg.version,usage:'ecom-cli containers list|inspect | shops open|login-status|submit-login | xhs search --via page | xhs note preflight|open|upload|fill|verify|status|prepare|submit | cdp endpoint|tabs|call | page read | media download | session request | sycm operations|schema|run|open|capabilities|mutation plan|show|apply | skill source|status|install|update | update check|install | doctor; use help --json for required options',...capabilities(),...(capabilities().commandContracts[requested]??{})};
60
+ if(command.startsWith('cdp '))return cdpCommand(command.slice(4),options);
61
+ if(['page read','media download'].includes(command))return pageCommand(command,options);
40
62
  if(command==='version')return {ok:true,package:pkg.name,version:pkg.version,protocolVersion:1};
63
+ if(['memory status','memory tabs','memory processes','memory containers'].includes(command))return readMemory(options);
64
+ if(['tabs discard','tabs wake'].includes(command))return tabMemoryAction(command.slice(5),options);
65
+ if(command==='memory operation')return memoryOperation(options);
66
+ if(command==='memory restore status'||command==='memory restore set')return restoreSettings(command.slice(15),options);
67
+ if(command==='memory auto status'||command==='memory auto set')return autoMemory(command.slice(12),options);
68
+ if(command==='tabs protect')return protectTab(options);
41
69
  if(command==='capabilities'||command==='schema')return {ok:true,...capabilities()};
42
70
  if(command==='skill source') {const item=source();return {...capabilities(),...item,complete:true,items:[item]};}
43
71
  if(command==='skill status')return status(options);
44
72
  if(command==='skill install'||command==='skill update')return installSkill(options);
45
73
  if(command==='update check')return checkUpdate();
46
74
  if(command==='update install')return updateInstall(options);
75
+ if(command.startsWith('xhs note '))return noteCommand(command.slice(9),options);
76
+ if(command==='xhs search') {
77
+ const update=await checkUpdate({automatic:true});
78
+ if(update.available)process.stderr.write('Ecom CLI update available: '+update.latest+'\n');
79
+ return searchXhs(options);
80
+ }
81
+ if(command==='shops open'&&options.service!=='qianniu')return openContainerHome(options);
82
+ if(qianniuCommands.includes(command))return qianniuLoginStep(command.slice(6),options);
47
83
  if(command==='sycm capabilities')return inventory();
48
84
  if(command==='sycm operations'){const result=inventory();return {ok:true,schemaVersion:1,source:result.source,count:result.operations.length,operations:result.operations};}
49
85
  if(command==='sycm schema')return {ok:true,schemaVersion:1,operation:operationSchema(options.operation)};
@@ -57,7 +93,7 @@ export async function main(argv) {
57
93
  return authorizePlan(options);
58
94
  }
59
95
  if(command==='doctor') {
60
- let instance,error;try{const found=discover(options);instance={id:found.endpoint.instanceId,pid:found.endpoint.pid,channel:found.channel,businessReadProtocol:found.endpoint.businessReadProtocol??0,businessPageOpenProtocol:found.endpoint.businessPageOpenProtocol??0,businessRegistryMatches:found.endpoint.businessReadRegistryHash===nativeRegistryHash()};}catch(e){error={code:e.code,message:e.message};}
96
+ let instance,error;try{const found=discover(options);instance={id:found.endpoint.instanceId,pid:found.endpoint.pid,channel:found.channel,businessReadProtocol:found.endpoint.businessReadProtocol??0,businessRouteProtocol:found.endpoint.businessRouteProtocol??0,signedBusinessRouteProtocol:found.endpoint.signedBusinessRouteProtocol??0,businessPageOpenProtocol:found.endpoint.businessPageOpenProtocol??0,xhsNoteProtocol:found.endpoint.xhsNoteProtocol??0,xhsNoteReviewProtocol:found.endpoint.xhsNoteReviewProtocol??0,businessRegistryMatches:found.endpoint.businessReadRegistryHash===nativeRegistryHash()};}catch(e){error={code:e.code,message:e.message};}
61
97
  return {ok:!error,package:pkg.name,version:pkg.version,node:process.version,platform:process.platform,transport:'private-local-mailbox',debuggingRequired:false,instance,...(error?{error}:{}),skillSource:source().path};
62
98
  }
63
99
  if(['containers list','containers inspect','session request'].includes(command)) {
@@ -0,0 +1,4 @@
1
+ {
2
+ "payload": "{\"version\":1,\"purpose\":\"ecom-dev-dmp-read\",\"id\":\"dmp.competitor-audience\",\"method\":\"POST\",\"path\":\"/api/dmp/insight/tag/chart\",\"expiresAt\":1794376920,\"fields\":{\"appCode\":\"string\",\"needUnknown\":\"boolean\",\"tagId\":\"integer\",\"selectTagOptionSetDTO\":\"dictionary\"}}",
3
+ "signature": "rysjcVdsMFB+ME+NMLGC10i8VW3SsWZgQ8KpOh3bkgeycfW/+klxmAlDmqFW1JB/ju1xs/SSvGHD8PUd61TRDA=="
4
+ }
package/lib/memory.mjs ADDED
@@ -0,0 +1,146 @@
1
+ import {discover,callMailbox} from './browser.mjs';
2
+ import {fail} from './common.mjs';
3
+
4
+ // Validate the native boundary before using estimates or stable target IDs.
5
+ export function validateSnapshot(body) {
6
+ if(body.snapshotVersion!==1||typeof body.snapshotId!=='string'||
7
+ !['macos-physical-footprint','windows-private-bytes'].includes(body.metric)||
8
+ !Array.isArray(body.tabs)||!Array.isArray(body.containers)||!Array.isArray(body.processes)||
9
+ !body.totals||!body.policy||!Number.isFinite(body.sampledAtUnixMs))
10
+ throw fail('PROTOCOL_MISMATCH','Invalid memory snapshot');
11
+ if(body.restoration!==undefined)validateRestoreSettings({restoration:body.restoration});
12
+ const pids=new Set(),tabs=new Set();
13
+ const measurement=x=>x===null||(Number.isFinite(x)&&x>=0);
14
+ for(const t of body.tabs){
15
+ if(!Number.isInteger(t.tabId)||tabs.has(t.tabId)||typeof t.containerId!=='string'||typeof t.pageVersion!=='string'||
16
+ !measurement(t.estimatedMemoryBytes)||!Array.isArray(t.discardReasons)||!Array.isArray(t.freezeReasons)||!Array.isArray(t.connectedFreezeReasons))
17
+ throw fail('PROTOCOL_MISMATCH','Invalid or duplicate memory tab');
18
+ const edits=t.manualDiscard?.editSignals;
19
+ if(edits!==undefined&&(!edits||typeof edits.available!=='boolean'||typeof edits.beforeUnloadDialogPossible!=='boolean'||edits.beforeUnloadBlocks!==false||
20
+ (edits.available?!['hadFormInteraction','hadUserEdits'].every(k=>typeof edits[k]==='boolean'):!['hadFormInteraction','hadUserEdits'].every(k=>edits[k]===null))))
21
+ throw fail('PROTOCOL_MISMATCH','Invalid native edit-protection signals');
22
+ tabs.add(t.tabId);
23
+ }
24
+ for(const p of body.processes){
25
+ if(!Number.isInteger(p.pid)||p.pid<=0||pids.has(p.pid)||!measurement(p.memoryBytes)||!measurement(p.residentBytes)||
26
+ !Array.isArray(p.tabIds)||new Set(p.tabIds).size!==p.tabIds.length||p.tabIds.some(id=>!tabs.has(id)))
27
+ throw fail('PROTOCOL_MISMATCH','Invalid or duplicate memory process');
28
+ pids.add(p.pid);
29
+ }
30
+ const total=body.processes.reduce((n,p)=>n+(p.memoryBytes??0),0);
31
+ if(!['memoryBytes','residentBytes','attributedBytes','unattributedBytes'].every(k=>Number.isFinite(body.totals[k])&&body.totals[k]>=0)||Math.abs(total-body.totals.memoryBytes)>1||
32
+ Math.abs(body.totals.attributedBytes+body.totals.unattributedBytes-total)>1)
33
+ throw fail('PROTOCOL_MISMATCH','Memory totals do not reconcile');
34
+ return body;
35
+ }
36
+ export async function readMemory(options={}) {
37
+ const connection=discover(options);
38
+ return readMemoryInConnection(connection);
39
+ }
40
+ export async function readMemoryInConnection(connection) {
41
+ if(connection.endpoint.memorySnapshotProtocol!==1)
42
+ throw fail('BROWSER_UPGRADE_REQUIRED','This Ecom build does not advertise memory snapshots');
43
+ const body=validateSnapshot(await callMailbox(connection,{method:'memory.snapshot'},15000));
44
+ return {...body,instance:{id:connection.endpoint.instanceId,pid:connection.endpoint.pid,channel:connection.channel}};
45
+ }
46
+
47
+ export function validateTabTarget(options) {
48
+ const tabId=Number(options.tab);
49
+ if(!/^[1-9][0-9]*$/.test(options.tab??'')||!Number.isSafeInteger(tabId)||tabId>2147483647||
50
+ typeof options.container!=='string'||!options.container||options.container.length>128||
51
+ typeof options['expected-page-version']!=='string'||!options['expected-page-version'].trim()||options['expected-page-version'].length>200)
52
+ throw fail('INVALID_ARGUMENT','Specify --tab ID, --container stable-ID (or @default), and --expected-page-version from the same native snapshot');
53
+ return {tabId,containerId:options.container==='@default'?'':options.container,expectedPageVersion:options['expected-page-version']};
54
+ }
55
+ export async function tabMemoryAction(action,options) {
56
+ const target=validateTabTarget(options);
57
+ return tabMemoryActionInConnection(discover(options),action,target);
58
+ }
59
+ export async function tabMemoryActionInConnection(connection,action,target) {
60
+ if(!['discard','wake'].includes(action))throw fail('INVALID_ARGUMENT','Unknown tab memory action');
61
+ if(connection.endpoint.manualTabMemoryProtocol!==1)throw fail('BROWSER_UPGRADE_REQUIRED','This Ecom build does not advertise manual tab sleep');
62
+ const queue=action==='wake'&&connection.endpoint.restoreQueueProtocol===1?{restoreQueueProtocol:1}:{};
63
+ const body=await callMailbox(connection,{method:'tabs.'+action,...target,...queue},15000);
64
+ const statuses=action==='discard'?['discarded','already-discarded']:['navigation-started','already-running',...(connection.endpoint.restoreQueueProtocol===1?['navigation-queued']:[])];
65
+ if(body.tabId!==target.tabId||body.containerId!==target.containerId||body.expectedPageVersion!==target.expectedPageVersion||
66
+ body.action!=='tabs.'+action||!statuses.includes(body.status)||typeof body.executed!=='boolean'||
67
+ (body.executed&&(typeof body.pageVersion!=='string'||body.navigationPreserved!==true)))
68
+ throw fail('PROTOCOL_MISMATCH','Action response does not match the pinned tab',{operationId:body.operationId,executed:'unknown'});
69
+ if(body.status==='navigation-queued'&&(body.restore?.phase!=='queued'||typeof body.restore?.requestId!=='string'))throw fail('PROTOCOL_MISMATCH','Queued restore has no native request identity',{executed:'unknown'});
70
+ return {...body,instance:{id:connection.endpoint.instanceId,pid:connection.endpoint.pid,channel:connection.channel}};
71
+ }
72
+ export async function memoryOperation(options) {
73
+ const connection=discover(options);
74
+ if(connection.endpoint.manualTabMemoryProtocol!==1)throw fail('BROWSER_UPGRADE_REQUIRED','Operation lookup requires a compatible browser');
75
+ const id=options['operation-id'];
76
+ const body=await callMailbox(connection,{method:'memory.operation',targetOperationId:id});
77
+ if(!['found','not-found'].includes(body.status)||(body.status==='found'&&body.result?.operationId!==id))
78
+ throw fail('PROTOCOL_MISMATCH','Operation lookup does not match requested ID',{executed:'unknown'});
79
+ return {...body,instance:{id:connection.endpoint.instanceId,pid:connection.endpoint.pid,channel:connection.channel}};
80
+ }
81
+
82
+ // Automatic settings are profile-persistent. Per-tab exceptions are run-scoped.
83
+ export function validateAutoOptions(options) {
84
+ const idle=Number(options['idle-seconds']);
85
+ if(!['on','off'].includes(options.enabled)||!/^\d+$/.test(options['idle-seconds']??'')||!Number.isSafeInteger(idle)||idle<60||idle>86400)
86
+ throw fail('INVALID_ARGUMENT','Specify --enabled on|off and --idle-seconds 60..86400');
87
+ return {enabled:options.enabled==='on',idleSeconds:idle};
88
+ }
89
+ export function validateAutomatic(body) {
90
+ const a=body.automatic;
91
+ if(!a||a.protocolVersion!==1||typeof a.enabled!=='boolean'||!Number.isInteger(a.idleSeconds)||a.idleSeconds<60||a.idleSeconds>86400||
92
+ a.scanIntervalSeconds!==30||a.maxDiscardsPerScan!==1||typeof a.managed!=='boolean'||typeof a.pausedReason!=='string'||typeof a.running!=='boolean'||
93
+ !Number.isInteger(a.scanCount)||!Number.isInteger(a.discardCount)||!Array.isArray(a.recentActions))
94
+ throw fail('PROTOCOL_MISMATCH','Invalid automatic memory settings',{executed:'unknown'});
95
+ return body;
96
+ }
97
+ export async function autoMemory(action,options) {
98
+ const payload=action==='set'?validateAutoOptions(options):{};
99
+ return autoMemoryInConnection(discover(options),action,payload);
100
+ }
101
+ export async function autoMemoryInConnection(connection,action,payload={}) {
102
+ if(!['status','set'].includes(action))throw fail('INVALID_ARGUMENT','Unknown automatic memory action');
103
+ if(connection.endpoint.autoTabMemoryProtocol!==1)throw fail('BROWSER_UPGRADE_REQUIRED','Automatic memory management requires a compatible browser');
104
+ const body=validateAutomatic(await callMailbox(connection,{method:'memory.auto.'+action,...payload}));
105
+ if(action==='set'&&(body.automatic.enabled!==payload.enabled||body.automatic.idleSeconds!==payload.idleSeconds))
106
+ throw fail('PROTOCOL_MISMATCH','Automatic settings readback differs from requested values',{operationId:body.operationId,executed:'unknown'});
107
+ return {...body,instance:{id:connection.endpoint.instanceId,pid:connection.endpoint.pid,channel:connection.channel}};
108
+ }
109
+ export async function protectTab(options) {
110
+ const target=validateTabTarget(options);
111
+ if(!['on','off'].includes(options['keep-awake']))throw fail('INVALID_ARGUMENT','Specify --keep-awake on|off');
112
+ const connection=discover(options);
113
+ if(connection.endpoint.autoTabMemoryProtocol!==1)throw fail('BROWSER_UPGRADE_REQUIRED','Tab keep-running protection requires a compatible browser');
114
+ const keepAwake=options['keep-awake']==='on';
115
+ const body=await callMailbox(connection,{method:'tabs.protect',...target,keepAwake});
116
+ if(body.tabId!==target.tabId||body.containerId!==target.containerId||body.expectedPageVersion!==target.expectedPageVersion||body.keepAwake!==keepAwake||
117
+ body.scope!=='tab-until-closed-or-browser-restart')
118
+ throw fail('PROTOCOL_MISMATCH','Protection response does not match requested target',{operationId:body.operationId,executed:'unknown'});
119
+ return {...body,instance:{id:connection.endpoint.instanceId,pid:connection.endpoint.pid,channel:connection.channel}};
120
+ }
121
+
122
+ export function validateRestoreOptions(options) {
123
+ const max=Number(options['max-background']);
124
+ if(!['on','off'].includes(options['lazy-startup'])||!/^([1-4])$/.test(options['max-background']??''))
125
+ throw fail('INVALID_ARGUMENT','Specify --lazy-startup on|off and --max-background 1..4');
126
+ return {lazyStartup:options['lazy-startup']==='on',maxBackgroundRestores:max};
127
+ }
128
+ export function validateRestoreSettings(body) {
129
+ const r=body.restoration;
130
+ if(!r||r.protocolVersion!==1||typeof r.lazyStartup!=='boolean'||!Number.isInteger(r.maxBackgroundRestores)||r.maxBackgroundRestores<1||r.maxBackgroundRestores>4||
131
+ !['activeCount','queuedCount','peakActiveCount','deferredCount'].every(k=>Number.isInteger(r[k])&&r[k]>=0)||typeof r.managed!=='boolean'||!Array.isArray(r.recentRequests))
132
+ throw fail('PROTOCOL_MISMATCH','Invalid native restoration queue',{executed:'unknown'});
133
+ return body;
134
+ }
135
+ export async function restoreSettings(action,options) {
136
+ const payload=action==='set'?validateRestoreOptions(options):{};
137
+ return restoreSettingsInConnection(discover(options),action,payload);
138
+ }
139
+ export async function restoreSettingsInConnection(connection,action,payload={}) {
140
+ if(!['status','set'].includes(action))throw fail('INVALID_ARGUMENT','Unknown restore settings action');
141
+ if(connection.endpoint.restoreQueueProtocol!==1)throw fail('BROWSER_UPGRADE_REQUIRED','Restoration queue requires a compatible Ecom browser');
142
+ const body=validateRestoreSettings(await callMailbox(connection,{method:'memory.restore.'+action,...payload}));
143
+ if(action==='set'&&(body.restoration.lazyStartup!==payload.lazyStartup||body.restoration.maxBackgroundRestores!==payload.maxBackgroundRestores))
144
+ throw fail('PROTOCOL_MISMATCH','Restoration settings readback differs from request',{operationId:body.operationId,executed:'unknown'});
145
+ return {...body,instance:{id:connection.endpoint.instanceId,pid:connection.endpoint.pid,channel:connection.channel}};
146
+ }
package/lib/page.mjs ADDED
@@ -0,0 +1,63 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import {callMailbox, discover, readMailbox, resolveContainer} from './browser.mjs';
5
+ import {exists, fail, privatePath} from './common.mjs';
6
+
7
+ export function validatePageOptions(command, options) {
8
+ let url;
9
+ try { url = new URL(options.url); } catch {}
10
+ if (!options.container || !url || url.protocol !== 'https:' ||
11
+ url.username || url.password || url.port || options.url.length > 8192)
12
+ throw fail('INVALID_ARGUMENT', 'Specify a container and a credential-free HTTPS page URL');
13
+ if (command === 'media download' && !options.output)
14
+ throw fail('INVALID_ARGUMENT', 'Specify a new local file with --output');
15
+ }
16
+
17
+ // Douyin's own embed uses the same public work ID as the desktop work URL.
18
+ export function mediaPageUrl(url) {
19
+ const target = new URL(url);
20
+ const work = target.pathname.match(/^\/video\/(\d{16,22})\/?$/);
21
+ if (target.origin !== 'https://www.douyin.com' || !work ||
22
+ target.search || target.hash) return target.href;
23
+ const player = new URL('https://open.douyin.com/player/video');
24
+ player.searchParams.set('vid', work[1]);
25
+ return player.href;
26
+ }
27
+
28
+ export async function pageCommand(command, options) {
29
+ validatePageOptions(command, options);
30
+ const output = command === 'media download' ? path.resolve(options.output) : null;
31
+ if (output && exists(output)) throw fail('OUTPUT_EXISTS', 'Choose a new output file');
32
+ if (output && !fs.statSync(path.dirname(output), {throwIfNoEntry:false})?.isDirectory())
33
+ throw fail('INVALID_ARGUMENT', 'Output parent directory must exist');
34
+ const connection = discover(options);
35
+ if (connection.endpoint.backgroundPageProtocol !== 1)
36
+ throw fail('BROWSER_UPGRADE_REQUIRED', 'Use an Ecom build with background page protocol 1');
37
+ const {containers} = await readMailbox(connection);
38
+ const {id} = resolveContainer(containers, options.container);
39
+ const pageUrl = output ? mediaPageUrl(options.url) : new URL(options.url).href;
40
+ const result = await callMailbox(connection, {
41
+ method: output ? 'media.download' : 'page.read',
42
+ containerId: id,
43
+ url: pageUrl,
44
+ }, output ? 92000 : 29000);
45
+ if (result.containerId !== id)
46
+ throw fail('PROTOCOL_MISMATCH', 'Response did not match the selected container');
47
+ if (!output) return result;
48
+ const {artifact, ...receipt} = result;
49
+ const temporaryRoot = fs.realpathSync(os.tmpdir());
50
+ if (typeof artifact !== 'string' || !path.isAbsolute(artifact) ||
51
+ !fs.realpathSync(artifact).startsWith(temporaryRoot + path.sep))
52
+ throw fail('PROTOCOL_MISMATCH', 'Invalid media artifact path');
53
+ privatePath(artifact);
54
+ try {
55
+ if (!Number.isSafeInteger(result.bytes) || result.bytes <= 0 ||
56
+ fs.statSync(artifact).size !== result.bytes)
57
+ throw fail('PROTOCOL_MISMATCH', 'Invalid media artifact size');
58
+ fs.copyFileSync(artifact, output, fs.constants.COPYFILE_EXCL);
59
+ } finally {
60
+ fs.unlinkSync(artifact);
61
+ }
62
+ return {...receipt, output, pageUrl};
63
+ }