@jskit-ai/jskit-cli 0.2.71 → 0.2.73

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.
@@ -1,8 +1,20 @@
1
+ import path from "node:path";
2
+ import { spawn } from "node:child_process";
3
+ import { importFreshModuleFromAbsolutePath } from "@jskit-ai/kernel/server/support";
1
4
  import {
2
5
  ensureArray,
3
6
  ensureObject,
4
7
  sortStrings
5
8
  } from "../../shared/collectionUtils.js";
9
+ import {
10
+ fileExists
11
+ } from "../appCommands/shared.js";
12
+ import {
13
+ ensureMobileConfigStub,
14
+ collectCapacitorShellInstallIssues,
15
+ ensureAndroidManifestDeepLinks,
16
+ ensureAndroidNativeShellIdentity
17
+ } from "../mobileShellSupport.js";
6
18
  import {
7
19
  isHelpToken,
8
20
  renderAddCatalogHelp,
@@ -13,6 +25,7 @@ import {
13
25
  ensureLocalMainPlacementComponentProvisioning,
14
26
  resolveProvisionableLocalPlacementComponentTokens
15
27
  } from "./tabLinkItemProvisioning.js";
28
+ import { resolvePackageTemplateRoot } from "../../cliRuntime/packageTemplateResolution.js";
16
29
 
17
30
  const COMPONENT_TOKEN_PATTERN = /\bcomponentToken\s*:\s*["']([^"']+)["']/g;
18
31
 
@@ -35,6 +48,262 @@ function collectPlacementComponentTokensFromManagedRecords(installedPackageRecor
35
48
  return sortStrings([...collectedTokens]);
36
49
  }
37
50
 
51
+ function renderWrappedShellCommand(binaryName, args = [], {
52
+ maxWidth = 100,
53
+ continuationIndent = " "
54
+ } = {}) {
55
+ const tokens = [String(binaryName || "").trim(), ...ensureArray(args).map((entry) => String(entry || "").trim()).filter(Boolean)];
56
+ if (tokens.length < 1 || !tokens[0]) {
57
+ return "$";
58
+ }
59
+
60
+ let currentLine = "$";
61
+ const renderedLines = [];
62
+ for (const token of tokens) {
63
+ const prefix = currentLine === "$" ? " " : " ";
64
+ if ((`${currentLine}${prefix}${token}`).length <= maxWidth || currentLine === "$") {
65
+ currentLine = `${currentLine}${prefix}${token}`;
66
+ continue;
67
+ }
68
+
69
+ renderedLines.push(`${currentLine} \\`);
70
+ currentLine = `${continuationIndent}${token}`;
71
+ }
72
+
73
+ renderedLines.push(currentLine);
74
+ return renderedLines.join("\n");
75
+ }
76
+
77
+ async function runLocalProjectBinary(binaryName, args = [], {
78
+ appRoot,
79
+ io,
80
+ pathModule = path,
81
+ createCliError,
82
+ explanation = "",
83
+ dryRun = false
84
+ } = {}) {
85
+ const renderedArgs = Array.isArray(args) ? args.join(" ") : "";
86
+ if (explanation) {
87
+ io?.stdout?.write(`${explanation}\n`);
88
+ io?.stdout?.write(`${renderWrappedShellCommand(binaryName, args)}\n`);
89
+ }
90
+ if (dryRun === true) {
91
+ io?.stdout?.write(`[dry-run] ${binaryName}${renderedArgs ? ` ${renderedArgs}` : ""}\n`);
92
+ return;
93
+ }
94
+
95
+ const localBinDirectory = pathModule.join(appRoot, "node_modules", ".bin");
96
+ const inheritedPath = String(process.env.PATH || "");
97
+ const spawnedEnv = {
98
+ ...process.env,
99
+ PATH: `${localBinDirectory}${pathModule.delimiter}${inheritedPath}`
100
+ };
101
+
102
+ await new Promise((resolve, reject) => {
103
+ const child = spawn(binaryName, Array.isArray(args) ? args : [], {
104
+ cwd: appRoot,
105
+ env: spawnedEnv,
106
+ stdio: "inherit"
107
+ });
108
+
109
+ child.on("error", (error) => {
110
+ if (error?.code === "ENOENT") {
111
+ reject(
112
+ createCliError(
113
+ `Could not find local "${binaryName}" in node_modules/.bin. Re-run the package install after dependencies are installed.`
114
+ )
115
+ );
116
+ return;
117
+ }
118
+ reject(error);
119
+ });
120
+ child.on("exit", (code) => {
121
+ if (code === 0) {
122
+ resolve();
123
+ return;
124
+ }
125
+ reject(createCliError(`${binaryName} ${args.join(" ")} failed with exit code ${code}.`));
126
+ });
127
+ });
128
+ }
129
+
130
+ async function installAppDependenciesForHook({
131
+ appRoot,
132
+ appPackageJson,
133
+ io,
134
+ pathModule = path,
135
+ createCliError,
136
+ dryRun = false,
137
+ runDevlinks = false
138
+ } = {}) {
139
+ const packageScripts =
140
+ appPackageJson?.scripts && typeof appPackageJson.scripts === "object"
141
+ ? appPackageJson.scripts
142
+ : {};
143
+
144
+ await runLocalProjectBinary("npm", ["install"], {
145
+ appRoot,
146
+ io,
147
+ pathModule,
148
+ createCliError,
149
+ explanation: "[mobile] Installing app dependencies for the mobile shell:",
150
+ dryRun
151
+ });
152
+
153
+ if (runDevlinks === true && Object.prototype.hasOwnProperty.call(packageScripts, "devlinks")) {
154
+ await runLocalProjectBinary("npm", ["run", "--if-present", "devlinks"], {
155
+ appRoot,
156
+ io,
157
+ pathModule,
158
+ createCliError,
159
+ explanation: "[mobile] Refreshing local JSKIT package links:",
160
+ dryRun
161
+ });
162
+ }
163
+ }
164
+
165
+ function validateHookResult(result = {}, { packageId = "", hookLabel = "" } = {}) {
166
+ if (typeof result === "undefined" || result === null) {
167
+ return {};
168
+ }
169
+ if (typeof result !== "object" || Array.isArray(result)) {
170
+ throw new Error(`${packageId} ${hookLabel} must return an object when it returns a value.`);
171
+ }
172
+ return result;
173
+ }
174
+
175
+ async function loadInstallHook({
176
+ packageEntry,
177
+ appRoot,
178
+ hookSpec,
179
+ hookLabel = ""
180
+ } = {}) {
181
+ const entrypoint = String(hookSpec?.entrypoint || "").trim();
182
+ const exportName = String(hookSpec?.export || "").trim() || "default";
183
+ if (!entrypoint) {
184
+ return null;
185
+ }
186
+
187
+ const templateRoot = await resolvePackageTemplateRoot({
188
+ packageEntry,
189
+ appRoot
190
+ });
191
+ const absoluteEntrypointPath = path.resolve(templateRoot, entrypoint);
192
+ if (!(await fileExists(absoluteEntrypointPath))) {
193
+ throw new Error(`${packageEntry.packageId} ${hookLabel} entrypoint not found at ${entrypoint}.`);
194
+ }
195
+
196
+ let moduleNamespace = null;
197
+ try {
198
+ moduleNamespace = await importFreshModuleFromAbsolutePath(absoluteEntrypointPath);
199
+ } catch (error) {
200
+ throw new Error(
201
+ `Unable to load ${hookLabel} entrypoint ${entrypoint} for ${packageEntry.packageId}: ${String(error?.message || error || "unknown error")}`
202
+ );
203
+ }
204
+
205
+ const handler = exportName === "default" ? moduleNamespace?.default : moduleNamespace?.[exportName];
206
+ if (typeof handler !== "function") {
207
+ throw new Error(`${packageEntry.packageId} ${hookLabel} export "${exportName}" is not a function.`);
208
+ }
209
+
210
+ return handler;
211
+ }
212
+
213
+ function createInstallHookHelpers({
214
+ ctx,
215
+ appRoot,
216
+ io,
217
+ appPackageJson,
218
+ commandOptions = {}
219
+ } = {}) {
220
+ return Object.freeze({
221
+ ensureManagedMobileConfig: async ({ dryRun = false } = {}) =>
222
+ await ensureMobileConfigStub({
223
+ ctx,
224
+ appRoot,
225
+ packageJson: appPackageJson,
226
+ dryRun,
227
+ stdout: io?.stdout
228
+ }),
229
+ installAppDependencies: async ({ dryRun = false } = {}) =>
230
+ await installAppDependenciesForHook({
231
+ appRoot,
232
+ appPackageJson,
233
+ io,
234
+ pathModule: ctx.path,
235
+ createCliError: ctx.createCliError,
236
+ dryRun,
237
+ runDevlinks: commandOptions.devlinks === true
238
+ }),
239
+ runProjectBinary: async (binaryName, args = [], { dryRun = false, explanation = "" } = {}) =>
240
+ await runLocalProjectBinary(binaryName, args, {
241
+ appRoot,
242
+ io,
243
+ pathModule: ctx.path,
244
+ createCliError: ctx.createCliError,
245
+ explanation,
246
+ dryRun
247
+ }),
248
+ collectCapacitorShellInstallIssues: async () =>
249
+ await collectCapacitorShellInstallIssues({
250
+ ctx,
251
+ appRoot
252
+ }),
253
+ ensureAndroidManifestDeepLinks: async ({ dryRun = false } = {}) =>
254
+ await ensureAndroidManifestDeepLinks({
255
+ ctx,
256
+ appRoot,
257
+ dryRun,
258
+ stdout: io?.stdout
259
+ }),
260
+ ensureAndroidNativeShellIdentity: async ({ dryRun = false } = {}) =>
261
+ await ensureAndroidNativeShellIdentity({
262
+ ctx,
263
+ appRoot,
264
+ dryRun,
265
+ stdout: io?.stdout
266
+ }),
267
+ fileExists
268
+ });
269
+ }
270
+
271
+ async function invokeInstallHook({
272
+ packageEntry,
273
+ appRoot,
274
+ hookSpec,
275
+ hookLabel,
276
+ hookContext,
277
+ createCliError
278
+ } = {}) {
279
+ if (!hookSpec || Object.keys(ensureObject(hookSpec)).length < 1) {
280
+ return {};
281
+ }
282
+
283
+ const handler = await loadInstallHook({
284
+ packageEntry,
285
+ appRoot,
286
+ hookSpec,
287
+ hookLabel
288
+ });
289
+ if (!handler) {
290
+ return {};
291
+ }
292
+
293
+ let result = null;
294
+ try {
295
+ result = await handler(hookContext);
296
+ } catch (error) {
297
+ throw createCliError(
298
+ `${packageEntry.packageId} ${hookLabel} failed: ${String(error?.message || error || "unknown error")}`
299
+ );
300
+ }
301
+ return validateHookResult(result, {
302
+ packageId: packageEntry.packageId,
303
+ hookLabel
304
+ });
305
+ }
306
+
38
307
  async function runPackageAddCommand(ctx = {}, { positional, options, cwd, io }) {
39
308
  const {
40
309
  createCliError,
@@ -264,6 +533,7 @@ async function runPackageAddCommand(ctx = {}, { positional, options, cwd, io })
264
533
 
265
534
  const packagesToInstall = [];
266
535
  const resolvedOptionsByPackage = {};
536
+ const installReasonByPackage = {};
267
537
  const reportTemplateFetchStatus = createCatalogFetchStatusReporter(io, {
268
538
  enabled: options.json !== true
269
539
  });
@@ -274,6 +544,8 @@ async function runPackageAddCommand(ctx = {}, { positional, options, cwd, io })
274
544
  const existingInstall = ensureObject(lock.installedPackages[packageId]);
275
545
  const existingVersion = String(existingInstall.version || "").trim();
276
546
  const isDirectTargetPackage = targetType === "package" && packageId === resolvedTargetPackageId;
547
+ const hasInstallLifecycleHooks =
548
+ Object.keys(ensureObject(ensureObject(ensureObject(packageEntry.descriptor).lifecycle).install)).length > 0;
277
549
  const packageInlineOptions = targetType === "bundle"
278
550
  ? resolveBundleInlineOptionsForPackage(packageEntry, options.inlineOptions)
279
551
  : isDirectTargetPackage
@@ -282,6 +554,7 @@ async function runPackageAddCommand(ctx = {}, { positional, options, cwd, io })
282
554
  const hasPackageInlineOptions = Object.keys(packageInlineOptions).length > 0;
283
555
  const shouldReapplyInstalledPackage =
284
556
  (isDirectTargetPackage && (forceReapplyTarget || hasInlineOptions)) ||
557
+ (isDirectTargetPackage && invocationMode === "add" && hasInstallLifecycleHooks && Boolean(existingVersion)) ||
285
558
  (targetType === "bundle" && hasPackageInlineOptions);
286
559
  const shouldSkipGenerateDependencyReinstall =
287
560
  invocationMode === "generate" &&
@@ -294,6 +567,11 @@ async function runPackageAddCommand(ctx = {}, { positional, options, cwd, io })
294
567
  continue;
295
568
  }
296
569
  packagesToInstall.push(packageId);
570
+ installReasonByPackage[packageId] = !existingVersion
571
+ ? "install"
572
+ : existingVersion === packageEntry.version
573
+ ? "reapply"
574
+ : "upgrade";
297
575
  const lockEntryOptions = ensureObject(existingInstall.options);
298
576
  resolvedOptionsByPackage[packageId] = await resolvePackageOptions(
299
577
  packageEntry,
@@ -308,6 +586,95 @@ async function runPackageAddCommand(ctx = {}, { positional, options, cwd, io })
308
586
 
309
587
  const touchedFiles = new Set();
310
588
  const installedPackageRecords = [];
589
+ const prepareHookWarnings = [];
590
+ const installHookHelpers = createInstallHookHelpers({
591
+ ctx,
592
+ appRoot,
593
+ io,
594
+ appPackageJson: packageJson,
595
+ commandOptions: options
596
+ });
597
+
598
+ for (const packageId of packagesToInstall) {
599
+ const packageEntry = combinedPackageRegistry.get(packageId);
600
+ const hookResult = await invokeInstallHook({
601
+ packageEntry,
602
+ appRoot,
603
+ hookSpec: ensureObject(ensureObject(ensureObject(packageEntry.descriptor).lifecycle).install).prepare,
604
+ hookLabel: "lifecycle.install.prepare",
605
+ createCliError,
606
+ hookContext: {
607
+ appRoot,
608
+ appPackageJson: packageJson,
609
+ lock,
610
+ packageEntry,
611
+ packageOptions: resolvedOptionsByPackage[packageId],
612
+ io,
613
+ dryRun: options.dryRun === true,
614
+ reason: installReasonByPackage[packageId],
615
+ skipManagedFinalize: options.forceReapplyTarget === true && options.runNpmInstall !== true,
616
+ helpers: installHookHelpers
617
+ }
618
+ });
619
+ for (const warning of ensureArray(hookResult.warnings)) {
620
+ const normalizedWarning = String(warning || "").trim();
621
+ if (normalizedWarning) {
622
+ prepareHookWarnings.push(normalizedWarning);
623
+ }
624
+ }
625
+ for (const touchedPath of ensureArray(hookResult.touchedFiles)) {
626
+ const normalizedPath = String(touchedPath || "").trim();
627
+ if (normalizedPath) {
628
+ touchedFiles.add(normalizedPath);
629
+ }
630
+ }
631
+ if (hookResult.stopInstall === true) {
632
+ if (options.dryRun !== true) {
633
+ throw createCliError(`${packageEntry.packageId} lifecycle.install.prepare requested stopInstall outside dry-run.`);
634
+ }
635
+ const touchedFileList = sortStrings([...touchedFiles]);
636
+ const installWarnings = sortStrings([...new Set(prepareHookWarnings)]);
637
+ const stopMessage = String(hookResult.stopMessage || "").trim();
638
+ if (options.json) {
639
+ io.stdout.write(`${JSON.stringify({
640
+ targetType: invocationMode === "generate" ? "generator" : targetType,
641
+ targetId,
642
+ resolvedPackages: resolvedPackageIds,
643
+ touchedFiles: touchedFileList,
644
+ lockPath: normalizeRelativePath(appRoot, lockPath),
645
+ externalDependencies,
646
+ dryRun: options.dryRun,
647
+ installed: [],
648
+ warnings: installWarnings,
649
+ stoppedAfterPrepare: true,
650
+ message: stopMessage
651
+ }, null, 2)}\n`);
652
+ } else {
653
+ io.stdout.write(
654
+ `${renderResolvedSummary(
655
+ `${invocationMode === "generate" ? "Generated with" : targetType === "bundle" ? "Added bundle" : "Added package"}`,
656
+ targetId,
657
+ resolvedPackageIds,
658
+ touchedFileList,
659
+ appRoot,
660
+ lockPath,
661
+ externalDependencies
662
+ )}\n`
663
+ );
664
+ if (installWarnings.length > 0) {
665
+ io.stdout.write(`Warnings (${installWarnings.length}):\n`);
666
+ for (const warning of installWarnings) {
667
+ io.stdout.write(`- ${warning}\n`);
668
+ }
669
+ }
670
+ if (stopMessage) {
671
+ io.stdout.write(`${stopMessage}\n`);
672
+ }
673
+ io.stdout.write("Dry run enabled: no files were written.\n");
674
+ }
675
+ return 0;
676
+ }
677
+ }
311
678
 
312
679
  for (const packageId of packagesToInstall) {
313
680
  const packageEntry = combinedPackageRegistry.get(packageId);
@@ -319,7 +686,8 @@ async function runPackageAddCommand(ctx = {}, { positional, options, cwd, io })
319
686
  lock,
320
687
  packageRegistry: combinedPackageRegistry,
321
688
  touchedFiles,
322
- reportTemplateFetchStatus
689
+ reportTemplateFetchStatus,
690
+ dryRun: options.dryRun === true
323
691
  });
324
692
  installedPackageRecords.push(managedRecord);
325
693
  }
@@ -368,15 +736,60 @@ async function runPackageAddCommand(ctx = {}, { positional, options, cwd, io })
368
736
  : "Added package";
369
737
  const installWarnings = installedPackageRecords
370
738
  .flatMap((record) => ensureArray(ensureObject(record).warnings))
739
+ .concat(prepareHookWarnings)
371
740
  .map((value) => String(value || "").trim())
372
741
  .filter(Boolean);
742
+ const finalizeHookRecords = packagesToInstall
743
+ .map((packageId) => {
744
+ const packageEntry = combinedPackageRegistry.get(packageId);
745
+ const finalizeSpec = ensureObject(ensureObject(ensureObject(packageEntry.descriptor).lifecycle).install).finalize;
746
+ if (Object.keys(ensureObject(finalizeSpec)).length < 1) {
747
+ return null;
748
+ }
749
+ return {
750
+ packageEntry,
751
+ hookSpec: finalizeSpec,
752
+ packageOptions: resolvedOptionsByPackage[packageId],
753
+ reason: installReasonByPackage[packageId]
754
+ };
755
+ })
756
+ .filter(Boolean)
757
+ .sort((left, right) => Number(Boolean(right.hookSpec?.managesNpmInstall)) - Number(Boolean(left.hookSpec?.managesNpmInstall)));
758
+ const managesNpmInstall = finalizeHookRecords.some((record) => record.hookSpec?.managesNpmInstall === true);
373
759
 
374
760
  if (!options.dryRun) {
375
761
  await writeJsonFile(packageJsonPath, packageJson);
376
762
  await writeJsonFile(lockPath, lock);
377
- if (options.runNpmInstall) {
763
+ if (options.runNpmInstall && !managesNpmInstall) {
378
764
  await runNpmInstall(appRoot, io.stderr);
379
765
  }
766
+ for (const finalizeRecord of finalizeHookRecords) {
767
+ const hookResult = await invokeInstallHook({
768
+ packageEntry: finalizeRecord.packageEntry,
769
+ appRoot,
770
+ hookSpec: finalizeRecord.hookSpec,
771
+ hookLabel: "lifecycle.install.finalize",
772
+ createCliError,
773
+ hookContext: {
774
+ appRoot,
775
+ appPackageJson: packageJson,
776
+ lock,
777
+ packageEntry: finalizeRecord.packageEntry,
778
+ packageOptions: finalizeRecord.packageOptions,
779
+ io,
780
+ dryRun: false,
781
+ reason: finalizeRecord.reason,
782
+ skipManagedFinalize: options.forceReapplyTarget === true && options.runNpmInstall !== true,
783
+ helpers: installHookHelpers
784
+ }
785
+ });
786
+ for (const warning of ensureArray(hookResult.warnings)) {
787
+ const normalizedWarning = String(warning || "").trim();
788
+ if (normalizedWarning) {
789
+ installWarnings.push(normalizedWarning);
790
+ }
791
+ }
792
+ }
380
793
  }
381
794
 
382
795
  if (options.json) {
@@ -99,7 +99,8 @@ async function runPackageMigrationsCommand(ctx = {}, { positional, options, cwd,
99
99
  packageOptions: resolvedOptions,
100
100
  appRoot,
101
101
  lock,
102
- touchedFiles
102
+ touchedFiles,
103
+ dryRun: options.dryRun === true
103
104
  });
104
105
  migratedRecords.push(managedRecord);
105
106
  for (const warning of ensureArray(ensureObject(managedRecord).warnings)) {
@@ -14,6 +14,7 @@ function parseArgs(argv, { createCliError } = {}) {
14
14
  options: {
15
15
  dryRun: false,
16
16
  runNpmInstall: false,
17
+ devlinks: false,
17
18
  full: false,
18
19
  expanded: false,
19
20
  details: false,
@@ -39,6 +40,7 @@ function parseArgs(argv, { createCliError } = {}) {
39
40
  const options = {
40
41
  dryRun: false,
41
42
  runNpmInstall: false,
43
+ devlinks: false,
42
44
  full: false,
43
45
  expanded: false,
44
46
  details: false,
@@ -69,6 +71,10 @@ function parseArgs(argv, { createCliError } = {}) {
69
71
  options.runNpmInstall = true;
70
72
  continue;
71
73
  }
74
+ if (token === "--devlinks") {
75
+ options.devlinks = true;
76
+ continue;
77
+ }
72
78
  if (token === "--full") {
73
79
  options.full = true;
74
80
  continue;
@@ -1,6 +1,7 @@
1
1
  const OPTION_FLAG_LABELS = Object.freeze({
2
2
  dryRun: "--dry-run",
3
3
  runNpmInstall: "--run-npm-install",
4
+ devlinks: "--devlinks",
4
5
  full: "--full",
5
6
  expanded: "--expanded",
6
7
  details: "--details",
@@ -167,6 +168,32 @@ const COMMAND_DESCRIPTORS = Object.freeze({
167
168
  allowedValueOptionNames: Object.freeze([]),
168
169
  canDelegateInlineOptions: (positional = []) => Array.isArray(positional) && positional.length > 0
169
170
  }),
171
+ mobile: Object.freeze({
172
+ command: "mobile",
173
+ aliases: Object.freeze([]),
174
+ showInOverview: true,
175
+ summary: "Run JSKIT-managed mobile-shell helpers.",
176
+ minimalUse: "jskit mobile add capacitor",
177
+ parameters: Object.freeze([
178
+ Object.freeze({
179
+ name: "<subcommand>",
180
+ description: "add (more mobile helpers will live here as Stage 1 expands)."
181
+ })
182
+ ]),
183
+ defaults: Object.freeze([
184
+ "The first supported flow is jskit mobile add capacitor.",
185
+ "Use jskit mobile <subcommand> help for subcommand-specific usage.",
186
+ "--dry-run is accepted by jskit mobile add/sync/run/build.",
187
+ "--devlinks runs npm run --if-present devlinks after install/sync maintenance for development-only relinking."
188
+ ]),
189
+ fullUse: "jskit mobile <subcommand> [help] [--dry-run] [--<option> <value>...]",
190
+ showHelpOnBareInvocation: true,
191
+ handlerName: "commandMobile",
192
+ allowedFlagKeys: Object.freeze(["dryRun", "devlinks"]),
193
+ inlineOptionMode: "delegate",
194
+ allowedValueOptionNames: Object.freeze([]),
195
+ canDelegateInlineOptions: (positional = []) => Array.isArray(positional) && positional.length > 0
196
+ }),
170
197
  add: Object.freeze({
171
198
  command: "add",
172
199
  aliases: Object.freeze([]),
@@ -187,13 +214,14 @@ const COMMAND_DESCRIPTORS = Object.freeze({
187
214
  "No npm install runs unless --run-npm-install is passed.",
188
215
  "Short ids resolve to @jskit-ai/<id> when available.",
189
216
  "Running without args lists bundles and runtime packages.",
190
- "Existing matching version is skipped unless options force reapply."
217
+ "Existing matching version is skipped unless options force reapply.",
218
+ "--devlinks runs npm run --if-present devlinks after install when the app defines that script."
191
219
  ]),
192
220
  fullUse:
193
- "jskit add <package|bundle> <id> [--<option> <value>...] [--dry-run] [--run-npm-install] [--json] [--verbose]",
221
+ "jskit add <package|bundle> <id> [--<option> <value>...] [--dry-run] [--run-npm-install] [--devlinks] [--json] [--verbose]",
194
222
  showHelpOnBareInvocation: false,
195
223
  handlerName: "commandAdd",
196
- allowedFlagKeys: Object.freeze(["dryRun", "runNpmInstall", "json", "verbose"]),
224
+ allowedFlagKeys: Object.freeze(["dryRun", "runNpmInstall", "devlinks", "json", "verbose"]),
197
225
  inlineOptionMode: "delegate",
198
226
  allowedValueOptionNames: Object.freeze([]),
199
227
  canDelegateInlineOptions: canDelegateAddInlineOptions
@@ -3,6 +3,7 @@ import { createListCommands } from "../commandHandlers/list.js";
3
3
  import { createShowCommand } from "../commandHandlers/show.js";
4
4
  import { createPackageCommands } from "../commandHandlers/package.js";
5
5
  import { createAppCommands } from "../commandHandlers/app.js";
6
+ import { createMobileCommands } from "../commandHandlers/mobile.js";
6
7
  import { createHealthCommands } from "../commandHandlers/health.js";
7
8
  import { createCompletionCommands } from "../commandHandlers/completion.js";
8
9
 
@@ -25,6 +26,7 @@ function createCommandHandlers(deps = {}) {
25
26
  commandRemove
26
27
  } = createPackageCommands(commandContext);
27
28
  const { commandApp } = createAppCommands(commandContext);
29
+ const { commandMobile } = createMobileCommands(commandContext, { commandAdd });
28
30
  const { commandDoctor, commandLintDescriptors } = createHealthCommands(commandContext);
29
31
  const { commandCompletion } = createCompletionCommands(commandContext);
30
32
 
@@ -35,6 +37,7 @@ function createCommandHandlers(deps = {}) {
35
37
  commandCompletion,
36
38
  commandShow,
37
39
  commandApp,
40
+ commandMobile,
38
41
  commandCreate,
39
42
  commandAdd,
40
43
  commandGenerate,