@jskit-ai/jskit-cli 0.2.50 → 0.2.51

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/jskit-cli",
3
- "version": "0.2.50",
3
+ "version": "0.2.51",
4
4
  "description": "Bundle and package orchestration CLI for JSKIT apps.",
5
5
  "type": "module",
6
6
  "files": [
@@ -20,9 +20,9 @@
20
20
  "test": "node --test"
21
21
  },
22
22
  "dependencies": {
23
- "@jskit-ai/jskit-catalog": "0.1.49",
24
- "@jskit-ai/kernel": "0.1.41",
25
- "@jskit-ai/shell-web": "0.1.40"
23
+ "@jskit-ai/jskit-catalog": "0.1.50",
24
+ "@jskit-ai/kernel": "0.1.42",
25
+ "@jskit-ai/shell-web": "0.1.41"
26
26
  },
27
27
  "engines": {
28
28
  "node": "20.x"
@@ -11,19 +11,41 @@ import {
11
11
 
12
12
  const LOCK_RELATIVE_PATH = ".jskit/lock.json";
13
13
  const LOCK_VERSION = 1;
14
+ const APP_ROOT_MARKER_RELATIVE_PATHS = Object.freeze([
15
+ LOCK_RELATIVE_PATH,
16
+ "app.json"
17
+ ]);
18
+
19
+ async function directoryLooksLikeJskitAppRoot(directoryPath) {
20
+ for (const relativePath of APP_ROOT_MARKER_RELATIVE_PATHS) {
21
+ if (await fileExists(path.join(directoryPath, relativePath))) {
22
+ return true;
23
+ }
24
+ }
25
+ return false;
26
+ }
14
27
 
15
28
  async function resolveAppRootFromCwd(cwd) {
16
29
  const startDirectory = path.resolve(String(cwd || process.cwd()));
17
30
  let currentDirectory = startDirectory;
31
+ let fallbackPackageRoot = "";
18
32
 
19
33
  while (true) {
20
34
  const packageJsonPath = path.join(currentDirectory, "package.json");
21
35
  if (await fileExists(packageJsonPath)) {
22
- return currentDirectory;
36
+ if (!fallbackPackageRoot) {
37
+ fallbackPackageRoot = currentDirectory;
38
+ }
39
+ if (await directoryLooksLikeJskitAppRoot(currentDirectory)) {
40
+ return currentDirectory;
41
+ }
23
42
  }
24
43
 
25
44
  const parentDirectory = path.dirname(currentDirectory);
26
45
  if (parentDirectory === currentDirectory) {
46
+ if (fallbackPackageRoot) {
47
+ return fallbackPackageRoot;
48
+ }
27
49
  throw createCliError(
28
50
  `Could not locate package.json starting from ${startDirectory}. Run jskit from an app directory (or a child directory of one).`
29
51
  );
@@ -1,6 +1,10 @@
1
1
  import path from "node:path";
2
2
  import { pathToFileURL } from "node:url";
3
3
  import { access, readdir, readFile } from "node:fs/promises";
4
+ import {
5
+ buildAppCommandOptionMeta,
6
+ listAppCommandDefinitions
7
+ } from "../commandHandlers/appCommandCatalog.js";
4
8
  import {
5
9
  COMMAND_IDS,
6
10
  isKnownCommandName,
@@ -1028,6 +1032,35 @@ async function completeCommand({ appRoot, words, cword, catalogModule }) {
1028
1032
  if (command === "generate") {
1029
1033
  return completeGenerateCommand({ appRoot, words, cword, catalogModule });
1030
1034
  }
1035
+ if (command === "app") {
1036
+ const appCommandNames = listAppCommandDefinitions().map((entry) => entry.name);
1037
+ const currentTokenForApp = words[cword] ?? "";
1038
+ const subcommandName = normalizeText(words[2]);
1039
+ if (cword <= 2) {
1040
+ return filterByPrefix(appCommandNames, currentTokenForApp);
1041
+ }
1042
+
1043
+ if (!subcommandName || !appCommandNames.includes(subcommandName)) {
1044
+ return filterByPrefix(appCommandNames, currentTokenForApp);
1045
+ }
1046
+
1047
+ const previousTokenForApp = words[cword - 1] ?? "";
1048
+ const optionMetaForApp = buildAppCommandOptionMeta(subcommandName);
1049
+ return completeGenericContext({
1050
+ appRoot,
1051
+ currentToken: currentTokenForApp,
1052
+ previousToken: previousTokenForApp,
1053
+ optionMeta: optionMetaForApp,
1054
+ tokensBeforeCurrent: words.slice(3, cword),
1055
+ optionNames: uniqueSorted(Object.keys(optionMetaForApp)),
1056
+ positionalCompleter: async ({ positionalIndex, currentToken: positionalCurrent }) => {
1057
+ if (positionalIndex === 0) {
1058
+ return filterByPrefix(["help"], positionalCurrent);
1059
+ }
1060
+ return [];
1061
+ }
1062
+ });
1063
+ }
1031
1064
 
1032
1065
  const optionMeta = buildCommandOptionMeta(command, catalogModule);
1033
1066
  const optionNames = uniqueSorted(Object.keys(optionMeta));
@@ -0,0 +1,161 @@
1
+ import {
2
+ createColorFormatter,
3
+ writeWrappedLines
4
+ } from "../shared/outputFormatting.js";
5
+ import {
6
+ APP_SCRIPT_WRAPPERS,
7
+ buildAppCommandOptionMeta,
8
+ listAppCommandDefinitions,
9
+ resolveAppCommandDefinition
10
+ } from "./appCommandCatalog.js";
11
+ import { runAppAdoptManagedScriptsCommand } from "./appCommands/adoptManagedScripts.js";
12
+ import { runAppLinkLocalPackagesCommand } from "./appCommands/linkLocalPackages.js";
13
+ import { runAppReleaseCommand } from "./appCommands/release.js";
14
+ import { runAppUpdatePackagesCommand } from "./appCommands/updatePackages.js";
15
+ import { runAppVerifyCommand } from "./appCommands/verify.js";
16
+
17
+ function renderAppHelp(stream, definition = null) {
18
+ const color = createColorFormatter(stream);
19
+ const lines = [];
20
+
21
+ if (!definition) {
22
+ lines.push(`Command: ${color.emphasis("app")}`);
23
+ lines.push("");
24
+ lines.push(color.heading("1) Minimal use"));
25
+ lines.push(" jskit app <subcommand>");
26
+ lines.push("");
27
+ lines.push(color.heading("2) Subcommands"));
28
+ for (const entry of listAppCommandDefinitions()) {
29
+ lines.push(` - ${color.item(entry.name)}: ${entry.summary}`);
30
+ }
31
+ lines.push("");
32
+ lines.push(color.heading("3) Notes"));
33
+ lines.push(" - The scaffold keeps npm run shortcuts such as verify and jskit:update,");
34
+ lines.push(" but the maintained behavior lives here in jskit app.");
35
+ lines.push(" - Use jskit app <subcommand> help for subcommand-specific usage.");
36
+ lines.push("");
37
+ lines.push(color.heading("4) Scaffold wrappers"));
38
+ for (const [scriptName, scriptCommand] of Object.entries(APP_SCRIPT_WRAPPERS)) {
39
+ lines.push(` - ${scriptName}: ${scriptCommand}`);
40
+ }
41
+ writeWrappedLines({
42
+ stdout: stream,
43
+ lines
44
+ });
45
+ return;
46
+ }
47
+
48
+ lines.push(`App subcommand: ${color.emphasis(definition.name)}`);
49
+ lines.push("");
50
+ lines.push(color.heading("1) Summary"));
51
+ lines.push(` ${definition.summary}`);
52
+ lines.push("");
53
+ lines.push(color.heading("2) Use"));
54
+ lines.push(` ${definition.usage}`);
55
+
56
+ if (definition.options.length > 0) {
57
+ lines.push("");
58
+ lines.push(color.heading("3) Options"));
59
+ for (const optionRow of definition.options) {
60
+ lines.push(` - ${optionRow.label}: ${optionRow.description}`);
61
+ }
62
+ }
63
+
64
+ if (definition.defaults.length > 0) {
65
+ lines.push("");
66
+ lines.push(color.heading(definition.options.length > 0 ? "4) Defaults" : "3) Defaults"));
67
+ for (const defaultLine of definition.defaults) {
68
+ lines.push(` - ${defaultLine}`);
69
+ }
70
+ }
71
+
72
+ writeWrappedLines({
73
+ stdout: stream,
74
+ lines
75
+ });
76
+ }
77
+
78
+ function createAppCommands(ctx = {}) {
79
+ const {
80
+ createCliError,
81
+ resolveAppRootFromCwd
82
+ } = ctx;
83
+
84
+ async function commandApp({ positional = [], options = {}, cwd = "", stdout, stderr }) {
85
+ const appRoot = await resolveAppRootFromCwd(cwd);
86
+ const firstToken = String(positional[0] || "").trim();
87
+ const secondToken = String(positional[1] || "").trim();
88
+
89
+ if (!firstToken) {
90
+ renderAppHelp(stdout);
91
+ return 0;
92
+ }
93
+
94
+ if (firstToken === "help") {
95
+ renderAppHelp(stdout, resolveAppCommandDefinition(secondToken));
96
+ return 0;
97
+ }
98
+
99
+ const definition = resolveAppCommandDefinition(firstToken);
100
+ if (!definition) {
101
+ throw createCliError(`Unknown app subcommand: ${firstToken}.`, {
102
+ renderUsage: () => renderAppHelp(stderr)
103
+ });
104
+ }
105
+
106
+ if (secondToken === "help") {
107
+ renderAppHelp(stdout, definition);
108
+ return 0;
109
+ }
110
+
111
+ const optionMeta = buildAppCommandOptionMeta(definition.name);
112
+ const supportedOptionNames = new Set(Object.keys(optionMeta));
113
+ const inlineOptionNames = Object.keys(options?.inlineOptions && typeof options.inlineOptions === "object" ? options.inlineOptions : {});
114
+ const unknownInlineOptionNames = inlineOptionNames.filter((optionName) => !supportedOptionNames.has(optionName));
115
+ if (unknownInlineOptionNames.length > 0) {
116
+ throw createCliError(
117
+ `Unknown option${unknownInlineOptionNames.length === 1 ? "" : "s"} for jskit app ${definition.name}: ${unknownInlineOptionNames.map((optionName) => `--${optionName}`).join(", ")}.`,
118
+ {
119
+ renderUsage: () => renderAppHelp(stderr, definition)
120
+ }
121
+ );
122
+ }
123
+ if (options?.dryRun === true && !supportedOptionNames.has("dry-run")) {
124
+ throw createCliError(`Unknown option for jskit app ${definition.name}: --dry-run.`, {
125
+ renderUsage: () => renderAppHelp(stderr, definition)
126
+ });
127
+ }
128
+
129
+ if (positional.length > 1) {
130
+ throw createCliError(`Unexpected positional arguments for jskit app ${definition.name}: ${positional.slice(1).join(" ")}`, {
131
+ renderUsage: () => renderAppHelp(stderr, definition)
132
+ });
133
+ }
134
+
135
+ if (definition.name === "verify") {
136
+ return runAppVerifyCommand(ctx, { appRoot, options, stdout, stderr });
137
+ }
138
+ if (definition.name === "update-packages") {
139
+ return runAppUpdatePackagesCommand(ctx, { appRoot, options, stdout, stderr });
140
+ }
141
+ if (definition.name === "link-local-packages") {
142
+ return runAppLinkLocalPackagesCommand(ctx, { appRoot, options, stdout, stderr });
143
+ }
144
+ if (definition.name === "release") {
145
+ return runAppReleaseCommand(ctx, { appRoot, options, stdout, stderr });
146
+ }
147
+ if (definition.name === "adopt-managed-scripts") {
148
+ return runAppAdoptManagedScriptsCommand(ctx, { appRoot, options, stdout, stderr });
149
+ }
150
+
151
+ throw createCliError(`Unhandled app subcommand: ${definition.name}.`, {
152
+ renderUsage: () => renderAppHelp(stderr, definition)
153
+ });
154
+ }
155
+
156
+ return {
157
+ commandApp
158
+ };
159
+ }
160
+
161
+ export { createAppCommands };
@@ -0,0 +1,170 @@
1
+ const APP_SCRIPT_WRAPPERS = Object.freeze({
2
+ verify: "jskit app verify && npm run --if-present verify:app",
3
+ "jskit:update": "jskit app update-packages",
4
+ devlinks: "jskit app link-local-packages",
5
+ release: "jskit app release"
6
+ });
7
+
8
+ const LEGACY_APP_SCRIPT_VALUES = Object.freeze({
9
+ verify: Object.freeze([
10
+ "npm run lint && npm run test && npm run test:client && npm run build && npx jskit doctor"
11
+ ]),
12
+ "jskit:update": Object.freeze([
13
+ "bash ./scripts/update-jskit-packages.sh"
14
+ ]),
15
+ devlinks: Object.freeze([
16
+ "bash ./scripts/link-local-jskit-packages.sh"
17
+ ]),
18
+ release: Object.freeze([
19
+ "bash ./scripts/release.sh"
20
+ ])
21
+ });
22
+
23
+ const LEGACY_APP_SCRIPT_FILES = Object.freeze([
24
+ "scripts/update-jskit-packages.sh",
25
+ "scripts/link-local-jskit-packages.sh",
26
+ "scripts/release.sh"
27
+ ]);
28
+
29
+ const APP_COMMAND_DEFINITIONS = Object.freeze({
30
+ verify: Object.freeze({
31
+ name: "verify",
32
+ summary: "Run the JSKIT baseline app verification flow.",
33
+ usage: "jskit app verify",
34
+ options: Object.freeze([]),
35
+ defaults: Object.freeze([
36
+ "Runs npm scripts lint, test, test:client, and build only when those scripts are present.",
37
+ "Runs jskit doctor after the normal app checks.",
38
+ "The scaffolded npm run verify wrapper can append npm run --if-present verify:app afterwards."
39
+ ])
40
+ }),
41
+ "update-packages": Object.freeze({
42
+ name: "update-packages",
43
+ summary: "Update installed @jskit-ai dependencies and refresh managed migrations.",
44
+ usage: "jskit app update-packages [--registry <url>] [--dry-run]",
45
+ options: Object.freeze([
46
+ Object.freeze({
47
+ label: "--registry <url>",
48
+ description: "Use a custom npm registry when resolving and installing @jskit-ai packages."
49
+ }),
50
+ Object.freeze({
51
+ label: "--dry-run",
52
+ description: "Show the npm install plan without mutating package.json, lockfiles, or migrations."
53
+ })
54
+ ]),
55
+ defaults: Object.freeze([
56
+ "Runtime and dev @jskit-ai dependencies are updated separately so package.json sections stay correct.",
57
+ "Each package is moved to the latest available major.x range and npm saves the resolved exact version.",
58
+ "Managed migrations are refreshed afterwards unless --dry-run is used."
59
+ ])
60
+ }),
61
+ "link-local-packages": Object.freeze({
62
+ name: "link-local-packages",
63
+ summary: "Link local @jskit-ai workspace packages into the current app for live development.",
64
+ usage: "jskit app link-local-packages [--repo-root <path>]",
65
+ options: Object.freeze([
66
+ Object.freeze({
67
+ label: "--repo-root <path>",
68
+ description: "Explicit jskit-ai monorepo checkout to link from. If omitted, JSKIT_REPO_ROOT or nearby jskit-ai directories are used."
69
+ })
70
+ ]),
71
+ defaults: Object.freeze([
72
+ "Links packages from both the monorepo packages/ and tooling/ directories.",
73
+ "Refreshes node_modules/.bin entries for linked packages that publish binaries.",
74
+ "Clears node_modules/.vite so Vite does not keep stale prebundled paths."
75
+ ])
76
+ }),
77
+ release: Object.freeze({
78
+ name: "release",
79
+ summary: "Run the JSKIT release helper for an app repository.",
80
+ usage: "jskit app release [--registry <url>] [--dry-run]",
81
+ options: Object.freeze([
82
+ Object.freeze({
83
+ label: "--registry <url>",
84
+ description: "Use a custom npm registry for the internal package-refresh step before opening a release PR."
85
+ }),
86
+ Object.freeze({
87
+ label: "--dry-run",
88
+ description: "Preview the release flow without syncing main, updating packages, or opening a PR."
89
+ })
90
+ ]),
91
+ defaults: Object.freeze([
92
+ "Requires a clean worktree, the main branch, git, npm, and gh auth.",
93
+ "Syncs local main, runs jskit app update-packages, commits resulting changes, opens a PR, merges it, then re-syncs local main.",
94
+ "If update-packages produces no changes, release exits without opening a PR."
95
+ ])
96
+ }),
97
+ "adopt-managed-scripts": Object.freeze({
98
+ name: "adopt-managed-scripts",
99
+ summary: "Rewrite legacy scaffolded maintenance scripts to the modern jskit app wrappers.",
100
+ usage: "jskit app adopt-managed-scripts [--dry-run] [--force]",
101
+ options: Object.freeze([
102
+ Object.freeze({
103
+ label: "--dry-run",
104
+ description: "Preview the script rewrites without writing package.json."
105
+ }),
106
+ Object.freeze({
107
+ label: "--force",
108
+ description: "Replace customized script values too, and remove the legacy copied maintenance scripts if they exist."
109
+ })
110
+ ]),
111
+ defaults: Object.freeze([
112
+ "Known scaffolded script values are rewritten automatically.",
113
+ "Customized script values are reported and left alone unless --force is used.",
114
+ "This command is the migration path for existing apps that still carry copied JSKIT maintenance scripts."
115
+ ])
116
+ })
117
+ });
118
+
119
+ function listAppCommandDefinitions() {
120
+ return Object.values(APP_COMMAND_DEFINITIONS)
121
+ .sort((left, right) => left.name.localeCompare(right.name));
122
+ }
123
+
124
+ function resolveAppCommandDefinition(rawName = "") {
125
+ const normalizedName = String(rawName || "").trim();
126
+ if (!normalizedName) {
127
+ return null;
128
+ }
129
+ return APP_COMMAND_DEFINITIONS[normalizedName] || null;
130
+ }
131
+
132
+ function buildAppCommandOptionMeta(subcommandName = "") {
133
+ const definition = resolveAppCommandDefinition(subcommandName);
134
+ const optionMeta = {
135
+ help: { inputType: "flag" }
136
+ };
137
+
138
+ if (!definition) {
139
+ return optionMeta;
140
+ }
141
+
142
+ if (
143
+ definition.name === "update-packages" ||
144
+ definition.name === "adopt-managed-scripts" ||
145
+ definition.name === "release"
146
+ ) {
147
+ optionMeta["dry-run"] = { inputType: "flag" };
148
+ }
149
+ if (definition.name === "adopt-managed-scripts") {
150
+ optionMeta.force = { inputType: "flag" };
151
+ }
152
+ if (definition.name === "update-packages" || definition.name === "release") {
153
+ optionMeta.registry = { inputType: "text" };
154
+ }
155
+ if (definition.name === "link-local-packages") {
156
+ optionMeta["repo-root"] = { inputType: "text" };
157
+ }
158
+
159
+ return optionMeta;
160
+ }
161
+
162
+ export {
163
+ APP_SCRIPT_WRAPPERS,
164
+ LEGACY_APP_SCRIPT_VALUES,
165
+ LEGACY_APP_SCRIPT_FILES,
166
+ APP_COMMAND_DEFINITIONS,
167
+ listAppCommandDefinitions,
168
+ resolveAppCommandDefinition,
169
+ buildAppCommandOptionMeta
170
+ };
@@ -0,0 +1,132 @@
1
+ import path from "node:path";
2
+ import { rm } from "node:fs/promises";
3
+ import {
4
+ APP_SCRIPT_WRAPPERS,
5
+ LEGACY_APP_SCRIPT_FILES,
6
+ LEGACY_APP_SCRIPT_VALUES
7
+ } from "../appCommandCatalog.js";
8
+ import { fileExists, isTruthyFlag } from "./shared.js";
9
+
10
+ function shouldRewriteScript(currentValue = "", scriptName = "", force = false) {
11
+ const desiredValue = APP_SCRIPT_WRAPPERS[scriptName];
12
+ const normalizedCurrentValue = String(currentValue || "").trim();
13
+ if (!normalizedCurrentValue) {
14
+ return {
15
+ rewrite: true,
16
+ reason: "missing"
17
+ };
18
+ }
19
+ if (normalizedCurrentValue === desiredValue) {
20
+ return {
21
+ rewrite: false,
22
+ reason: "already-current"
23
+ };
24
+ }
25
+ if ((LEGACY_APP_SCRIPT_VALUES[scriptName] || []).includes(normalizedCurrentValue)) {
26
+ return {
27
+ rewrite: true,
28
+ reason: "legacy"
29
+ };
30
+ }
31
+ if (force) {
32
+ return {
33
+ rewrite: true,
34
+ reason: "force"
35
+ };
36
+ }
37
+ return {
38
+ rewrite: false,
39
+ reason: "customized"
40
+ };
41
+ }
42
+
43
+ async function runAppAdoptManagedScriptsCommand(ctx = {}, { appRoot = "", options = {}, stdout }) {
44
+ const {
45
+ loadAppPackageJson,
46
+ writeJsonFile
47
+ } = ctx;
48
+
49
+ const dryRun = options?.dryRun === true;
50
+ const force = isTruthyFlag(options?.inlineOptions?.force);
51
+ const {
52
+ packageJsonPath,
53
+ packageJson
54
+ } = await loadAppPackageJson(appRoot);
55
+
56
+ const packageJsonClone = JSON.parse(JSON.stringify(packageJson || {}));
57
+ const scripts = packageJsonClone.scripts && typeof packageJsonClone.scripts === "object"
58
+ ? packageJsonClone.scripts
59
+ : {};
60
+ packageJsonClone.scripts = scripts;
61
+
62
+ const changedScripts = [];
63
+ const skippedScripts = [];
64
+
65
+ for (const [scriptName, desiredValue] of Object.entries(APP_SCRIPT_WRAPPERS)) {
66
+ const currentValue = String(scripts[scriptName] || "");
67
+ const rewritePolicy = shouldRewriteScript(currentValue, scriptName, force);
68
+ if (!rewritePolicy.rewrite) {
69
+ if (rewritePolicy.reason === "customized") {
70
+ skippedScripts.push({
71
+ scriptName,
72
+ currentValue
73
+ });
74
+ }
75
+ continue;
76
+ }
77
+
78
+ scripts[scriptName] = desiredValue;
79
+ changedScripts.push({
80
+ scriptName,
81
+ previousValue: currentValue,
82
+ nextValue: desiredValue,
83
+ reason: rewritePolicy.reason
84
+ });
85
+ }
86
+
87
+ const removableLegacyFiles = [];
88
+ if (force) {
89
+ for (const relativePath of LEGACY_APP_SCRIPT_FILES) {
90
+ const absolutePath = path.join(appRoot, relativePath);
91
+ if (await fileExists(absolutePath)) {
92
+ removableLegacyFiles.push({
93
+ relativePath,
94
+ absolutePath
95
+ });
96
+ }
97
+ }
98
+ }
99
+
100
+ if (!dryRun && changedScripts.length > 0) {
101
+ await writeJsonFile(packageJsonPath, packageJsonClone);
102
+ }
103
+
104
+ if (!dryRun && force) {
105
+ for (const { absolutePath } of removableLegacyFiles) {
106
+ await rm(absolutePath, { recursive: true, force: true });
107
+ }
108
+ }
109
+
110
+ if (changedScripts.length < 1 && skippedScripts.length < 1 && removableLegacyFiles.length < 1) {
111
+ stdout.write("[adopt-managed-scripts] package.json already uses the managed JSKIT wrappers.\n");
112
+ return 0;
113
+ }
114
+
115
+ for (const record of changedScripts) {
116
+ stdout.write(`[adopt-managed-scripts] ${dryRun ? "would rewrite" : "rewrote"} script ${record.scriptName} (${record.reason}).\n`);
117
+ }
118
+ for (const record of skippedScripts) {
119
+ stdout.write(`[adopt-managed-scripts] kept customized script ${record.scriptName}: ${record.currentValue}\n`);
120
+ }
121
+ for (const record of removableLegacyFiles) {
122
+ stdout.write(`[adopt-managed-scripts] ${dryRun ? "would remove" : "removed"} ${record.relativePath}\n`);
123
+ }
124
+
125
+ if (skippedScripts.length > 0 && !force) {
126
+ stdout.write("[adopt-managed-scripts] rerun with --force to replace customized maintenance wrappers too.\n");
127
+ }
128
+
129
+ return 0;
130
+ }
131
+
132
+ export { runAppAdoptManagedScriptsCommand };
@@ -0,0 +1,63 @@
1
+ import path from "node:path";
2
+ import { mkdir, rm, symlink } from "node:fs/promises";
3
+ import {
4
+ discoverLocalPackageMap,
5
+ fileExists,
6
+ linkPackageBinEntries,
7
+ resolveLocalRepoRoot,
8
+ resolveSymlinkType
9
+ } from "./shared.js";
10
+
11
+ async function runAppLinkLocalPackagesCommand(ctx = {}, { appRoot = "", options = {}, stdout }) {
12
+ const { createCliError } = ctx;
13
+ const explicitRepoRoot = String(options?.inlineOptions?.["repo-root"] || "").trim();
14
+ const scopeDirectory = path.join(appRoot, "node_modules", "@jskit-ai");
15
+ const viteCacheDirectory = path.join(appRoot, "node_modules", ".vite");
16
+
17
+ if (!(await fileExists(scopeDirectory))) {
18
+ throw createCliError(`[link-local] @jskit-ai scope not found at ${scopeDirectory} (run npm install first).`, {
19
+ exitCode: 1
20
+ });
21
+ }
22
+
23
+ const repoRoot = await resolveLocalRepoRoot({ appRoot, explicitRepoRoot });
24
+ if (!repoRoot) {
25
+ throw createCliError("[link-local] no JSKIT repository found. Set JSKIT_REPO_ROOT or use --repo-root.", {
26
+ exitCode: 1
27
+ });
28
+ }
29
+
30
+ if (!(await fileExists(path.join(repoRoot, "packages"))) || !(await fileExists(path.join(repoRoot, "tooling")))) {
31
+ throw createCliError(`[link-local] JSKIT repo root is not valid: ${repoRoot}`, {
32
+ exitCode: 1
33
+ });
34
+ }
35
+
36
+ const packageMap = await discoverLocalPackageMap(repoRoot);
37
+ let linkedCount = 0;
38
+ await mkdir(scopeDirectory, { recursive: true });
39
+
40
+ for (const [packageDirName, sourceDir] of [...packageMap.entries()].sort((left, right) => left[0].localeCompare(right[0]))) {
41
+ const targetPath = path.join(scopeDirectory, packageDirName);
42
+ await rm(targetPath, { recursive: true, force: true });
43
+ await symlink(sourceDir, targetPath, resolveSymlinkType());
44
+ stdout.write(`[link-local] linked @jskit-ai/${packageDirName} -> ${sourceDir}\n`);
45
+ await linkPackageBinEntries({
46
+ appRoot,
47
+ packageDirName,
48
+ sourceDir,
49
+ stdout
50
+ });
51
+ linkedCount += 1;
52
+ }
53
+
54
+ if (await fileExists(viteCacheDirectory)) {
55
+ await rm(viteCacheDirectory, { recursive: true, force: true });
56
+ stdout.write(`[link-local] cleared Vite cache at ${viteCacheDirectory}\n`);
57
+ }
58
+
59
+ stdout.write(`[link-local] done. linked=${linkedCount}\n`);
60
+ return 0;
61
+ }
62
+
63
+ export { runAppLinkLocalPackagesCommand };
@@ -0,0 +1,174 @@
1
+ import { runAppUpdatePackagesCommand } from "./updatePackages.js";
2
+ import {
3
+ formatUtcReleaseTimestamp,
4
+ runExternalCommand
5
+ } from "./shared.js";
6
+
7
+ async function runAppReleaseCommand(ctx = {}, { appRoot = "", options = {}, stdout, stderr }) {
8
+ const { createCliError } = ctx;
9
+
10
+ runExternalCommand("gh", ["auth", "status"], {
11
+ cwd: appRoot,
12
+ stdout,
13
+ stderr,
14
+ quiet: true,
15
+ createCliError
16
+ });
17
+
18
+ const worktreeStatus = runExternalCommand("git", ["status", "--porcelain"], {
19
+ cwd: appRoot,
20
+ stdout,
21
+ stderr,
22
+ quiet: true,
23
+ createCliError
24
+ });
25
+ if (String(worktreeStatus.stdout || "").trim()) {
26
+ throw createCliError("[release] worktree is not clean. Commit or stash changes first.", {
27
+ exitCode: 1
28
+ });
29
+ }
30
+
31
+ const branchResult = runExternalCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
32
+ cwd: appRoot,
33
+ stdout,
34
+ stderr,
35
+ quiet: true,
36
+ createCliError
37
+ });
38
+ const currentBranch = String(branchResult.stdout || "").trim();
39
+ if (currentBranch !== "main") {
40
+ throw createCliError(`[release] current branch is "${currentBranch}". Switch to main first.`, {
41
+ exitCode: 1
42
+ });
43
+ }
44
+
45
+ if (options?.dryRun) {
46
+ stdout.write("[release] dry-run mode: would sync main, run jskit app update-packages, and open a PR if changes appear.\n");
47
+ return 0;
48
+ }
49
+
50
+ stdout.write("[release] syncing local main...\n");
51
+ runExternalCommand("git", ["fetch", "origin", "main"], {
52
+ cwd: appRoot,
53
+ stdout,
54
+ stderr,
55
+ createCliError
56
+ });
57
+ runExternalCommand("git", ["pull", "--ff-only", "origin", "main"], {
58
+ cwd: appRoot,
59
+ stdout,
60
+ stderr,
61
+ createCliError
62
+ });
63
+
64
+ stdout.write("[release] running package refresh...\n");
65
+ const registryUrl = String(options?.inlineOptions?.registry || "").trim();
66
+ await runAppUpdatePackagesCommand(ctx, {
67
+ appRoot,
68
+ options: {
69
+ ...options,
70
+ dryRun: false,
71
+ inlineOptions: registryUrl
72
+ ? { registry: registryUrl }
73
+ : {}
74
+ },
75
+ stdout,
76
+ stderr
77
+ });
78
+
79
+ const postUpdateStatus = runExternalCommand("git", ["status", "--porcelain"], {
80
+ cwd: appRoot,
81
+ stdout,
82
+ stderr,
83
+ quiet: true,
84
+ createCliError
85
+ });
86
+ if (!String(postUpdateStatus.stdout || "").trim()) {
87
+ stdout.write("[release] no changes produced by jskit app update-packages. Nothing to release.\n");
88
+ return 0;
89
+ }
90
+
91
+ const { branchStamp, pretty } = formatUtcReleaseTimestamp();
92
+ const branchName = `release/${branchStamp}`;
93
+ const commitMessage = `chore: release ${pretty}`;
94
+ const prTitle = `Release ${pretty}`;
95
+ const prBody = "Automated release commit generated by `jskit app release`.";
96
+
97
+ stdout.write(`[release] creating branch ${branchName}...\n`);
98
+ runExternalCommand("git", ["switch", "-c", branchName], {
99
+ cwd: appRoot,
100
+ stdout,
101
+ stderr,
102
+ createCliError
103
+ });
104
+ runExternalCommand("git", ["add", "-A"], {
105
+ cwd: appRoot,
106
+ stdout,
107
+ stderr,
108
+ createCliError
109
+ });
110
+ runExternalCommand("git", ["commit", "-m", commitMessage], {
111
+ cwd: appRoot,
112
+ stdout,
113
+ stderr,
114
+ createCliError
115
+ });
116
+ runExternalCommand("git", ["push", "-u", "origin", branchName], {
117
+ cwd: appRoot,
118
+ stdout,
119
+ stderr,
120
+ createCliError
121
+ });
122
+
123
+ const prCreateResult = runExternalCommand("gh", [
124
+ "pr",
125
+ "create",
126
+ "--base",
127
+ "main",
128
+ "--head",
129
+ branchName,
130
+ "--title",
131
+ prTitle,
132
+ "--body",
133
+ prBody
134
+ ], {
135
+ cwd: appRoot,
136
+ stdout,
137
+ stderr,
138
+ quiet: true,
139
+ createCliError
140
+ });
141
+ const prUrl = String(prCreateResult.stdout || "").trim();
142
+ if (!prUrl) {
143
+ throw createCliError("[release] gh pr create did not return a PR URL.", {
144
+ exitCode: 1
145
+ });
146
+ }
147
+
148
+ stdout.write(`[release] created PR: ${prUrl}\n`);
149
+ runExternalCommand("gh", ["pr", "merge", prUrl, "--merge", "--delete-branch"], {
150
+ cwd: appRoot,
151
+ stdout,
152
+ stderr,
153
+ createCliError
154
+ });
155
+
156
+ stdout.write("[release] merged. syncing local main...\n");
157
+ runExternalCommand("git", ["switch", "main"], {
158
+ cwd: appRoot,
159
+ stdout,
160
+ stderr,
161
+ createCliError
162
+ });
163
+ runExternalCommand("git", ["pull", "--ff-only", "origin", "main"], {
164
+ cwd: appRoot,
165
+ stdout,
166
+ stderr,
167
+ createCliError
168
+ });
169
+ stdout.write("[release] done.\n");
170
+
171
+ return 0;
172
+ }
173
+
174
+ export { runAppReleaseCommand };
@@ -0,0 +1,275 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import path from "node:path";
3
+ import { access, mkdir, readFile, readdir, rm, symlink } from "node:fs/promises";
4
+
5
+ function normalizeText(value = "") {
6
+ return String(value || "").trim();
7
+ }
8
+
9
+ async function fileExists(filePath = "") {
10
+ try {
11
+ await access(filePath);
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ function isTruthyFlag(rawValue = "") {
19
+ const normalizedValue = normalizeText(rawValue).toLowerCase();
20
+ return normalizedValue === "true" || normalizedValue === "1" || normalizedValue === "yes";
21
+ }
22
+
23
+ function ensureCommandSucceeded(result, label, { createCliError, cwd = "", stdout, stderr, quiet = false } = {}) {
24
+ if (!quiet) {
25
+ const capturedStdout = String(result?.stdout || "");
26
+ const capturedStderr = String(result?.stderr || "");
27
+ if (capturedStdout) {
28
+ stdout?.write(capturedStdout);
29
+ }
30
+ if (capturedStderr) {
31
+ stderr?.write(capturedStderr);
32
+ }
33
+ }
34
+
35
+ if (result?.error) {
36
+ if (result.error.code === "ENOENT") {
37
+ throw createCliError(`${label} is not available in PATH.`, {
38
+ exitCode: 1
39
+ });
40
+ }
41
+ throw result.error;
42
+ }
43
+
44
+ if (result?.status !== 0) {
45
+ const suffix = cwd ? ` (cwd: ${cwd})` : "";
46
+ throw createCliError(`${label} failed with exit code ${result?.status ?? 1}${suffix}.`, {
47
+ exitCode: Number.isInteger(result?.status) ? result.status : 1
48
+ });
49
+ }
50
+
51
+ return result;
52
+ }
53
+
54
+ function runExternalCommand(
55
+ command,
56
+ args = [],
57
+ {
58
+ cwd = "",
59
+ env = {},
60
+ stdout,
61
+ stderr,
62
+ quiet = false,
63
+ createCliError
64
+ } = {}
65
+ ) {
66
+ const result = spawnSync(command, Array.isArray(args) ? args : [], {
67
+ cwd: cwd || process.cwd(),
68
+ encoding: "utf8",
69
+ env: {
70
+ ...process.env,
71
+ ...env
72
+ }
73
+ });
74
+ return ensureCommandSucceeded(result, command, {
75
+ createCliError,
76
+ cwd,
77
+ stdout,
78
+ stderr,
79
+ quiet
80
+ });
81
+ }
82
+
83
+ function formatUtcReleaseTimestamp(date = new Date()) {
84
+ const year = date.getUTCFullYear();
85
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
86
+ const day = String(date.getUTCDate()).padStart(2, "0");
87
+ const hours = String(date.getUTCHours()).padStart(2, "0");
88
+ const minutes = String(date.getUTCMinutes()).padStart(2, "0");
89
+ const seconds = String(date.getUTCSeconds()).padStart(2, "0");
90
+
91
+ return {
92
+ branchStamp: `${year}${month}${day}-${hours}${minutes}${seconds}`,
93
+ pretty: `${year}-${month}-${day} ${hours}:${minutes} UTC`
94
+ };
95
+ }
96
+
97
+ function resolveLocalJskitBin(appRoot = "") {
98
+ const binName = process.platform === "win32" ? "jskit.cmd" : "jskit";
99
+ return path.join(appRoot, "node_modules", ".bin", binName);
100
+ }
101
+
102
+ async function runLocalJskit(
103
+ appRoot,
104
+ args = [],
105
+ {
106
+ stdout,
107
+ stderr,
108
+ createCliError,
109
+ quiet = false
110
+ } = {}
111
+ ) {
112
+ const localJskitBin = resolveLocalJskitBin(appRoot);
113
+ if (!(await fileExists(localJskitBin))) {
114
+ throw createCliError(`Local jskit binary not found at ${path.relative(appRoot, localJskitBin)}. Run npm install first.`, {
115
+ exitCode: 1
116
+ });
117
+ }
118
+
119
+ return runExternalCommand(localJskitBin, args, {
120
+ cwd: appRoot,
121
+ stdout,
122
+ stderr,
123
+ quiet,
124
+ createCliError
125
+ });
126
+ }
127
+
128
+ async function resolveLocalRepoRoot({ appRoot = "", explicitRepoRoot = "" } = {}) {
129
+ const explicit = normalizeText(explicitRepoRoot);
130
+ if (explicit) {
131
+ return explicit;
132
+ }
133
+
134
+ const envRepoRoot = normalizeText(process.env.JSKIT_REPO_ROOT);
135
+ if (envRepoRoot) {
136
+ return envRepoRoot;
137
+ }
138
+
139
+ let currentDirectory = path.dirname(appRoot);
140
+ while (true) {
141
+ const candidateRoot = path.join(currentDirectory, "jskit-ai");
142
+ if (await fileExists(path.join(candidateRoot, "packages")) && await fileExists(path.join(candidateRoot, "tooling"))) {
143
+ return candidateRoot;
144
+ }
145
+ const parentDirectory = path.dirname(currentDirectory);
146
+ if (parentDirectory === currentDirectory) {
147
+ return "";
148
+ }
149
+ currentDirectory = parentDirectory;
150
+ }
151
+ }
152
+
153
+ async function discoverLocalPackageMap(repoRoot = "") {
154
+ const packageMap = new Map();
155
+ const parentDirectories = [
156
+ path.join(repoRoot, "packages"),
157
+ path.join(repoRoot, "tooling")
158
+ ];
159
+
160
+ for (const parentDirectory of parentDirectories) {
161
+ if (!(await fileExists(parentDirectory))) {
162
+ continue;
163
+ }
164
+
165
+ const entries = await readdir(parentDirectory, { withFileTypes: true });
166
+ entries.sort((left, right) => left.name.localeCompare(right.name));
167
+
168
+ for (const entry of entries) {
169
+ if (!entry.isDirectory() || entry.name.startsWith(".")) {
170
+ continue;
171
+ }
172
+
173
+ const packageRoot = path.join(parentDirectory, entry.name);
174
+ const packageJsonPath = path.join(packageRoot, "package.json");
175
+ if (!(await fileExists(packageJsonPath))) {
176
+ continue;
177
+ }
178
+
179
+ let packageJson = {};
180
+ try {
181
+ packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
182
+ } catch {
183
+ continue;
184
+ }
185
+
186
+ const packageId = normalizeText(packageJson?.name);
187
+ if (!packageId.startsWith("@jskit-ai/")) {
188
+ continue;
189
+ }
190
+
191
+ const packageDirName = packageId.slice("@jskit-ai/".length);
192
+ if (!packageDirName || packageDirName.includes("/")) {
193
+ continue;
194
+ }
195
+
196
+ if (!packageMap.has(packageDirName)) {
197
+ packageMap.set(packageDirName, packageRoot);
198
+ }
199
+ }
200
+ }
201
+
202
+ return packageMap;
203
+ }
204
+
205
+ async function linkPackageBinEntries({
206
+ appRoot,
207
+ packageDirName,
208
+ sourceDir,
209
+ stdout
210
+ } = {}) {
211
+ const packageJsonPath = path.join(sourceDir, "package.json");
212
+ if (!(await fileExists(packageJsonPath))) {
213
+ return;
214
+ }
215
+
216
+ let packageJson = {};
217
+ try {
218
+ packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
219
+ } catch {
220
+ return;
221
+ }
222
+
223
+ const rawBin = packageJson?.bin;
224
+ let binEntries = [];
225
+ if (typeof rawBin === "string") {
226
+ binEntries = [[packageDirName, rawBin]];
227
+ } else if (rawBin && typeof rawBin === "object" && !Array.isArray(rawBin)) {
228
+ binEntries = Object.entries(rawBin);
229
+ }
230
+
231
+ if (binEntries.length < 1) {
232
+ return;
233
+ }
234
+
235
+ const binDirectory = path.join(appRoot, "node_modules", ".bin");
236
+ const packageRoot = path.join(appRoot, "node_modules", "@jskit-ai", packageDirName);
237
+ await mkdir(binDirectory, { recursive: true });
238
+
239
+ for (const [rawBinName, rawBinTarget] of binEntries) {
240
+ const binName = normalizeText(rawBinName);
241
+ const binTarget = normalizeText(rawBinTarget);
242
+ if (!binName || !binTarget) {
243
+ continue;
244
+ }
245
+
246
+ const absoluteTarget = path.join(packageRoot, binTarget);
247
+ if (!(await fileExists(absoluteTarget))) {
248
+ continue;
249
+ }
250
+
251
+ const binPath = path.join(binDirectory, binName);
252
+ await rm(binPath, { recursive: true, force: true });
253
+ const relativeTarget = path.relative(binDirectory, absoluteTarget) || absoluteTarget;
254
+ await symlink(relativeTarget, binPath);
255
+ stdout?.write(`[link-local] linked bin ${binName} -> ${relativeTarget}\n`);
256
+ }
257
+ }
258
+
259
+ function resolveSymlinkType() {
260
+ return process.platform === "win32" ? "junction" : "dir";
261
+ }
262
+
263
+ export {
264
+ fileExists,
265
+ normalizeText,
266
+ isTruthyFlag,
267
+ runExternalCommand,
268
+ formatUtcReleaseTimestamp,
269
+ resolveLocalJskitBin,
270
+ runLocalJskit,
271
+ resolveLocalRepoRoot,
272
+ discoverLocalPackageMap,
273
+ linkPackageBinEntries,
274
+ resolveSymlinkType
275
+ };
@@ -0,0 +1,102 @@
1
+ import { runLocalJskit, runExternalCommand } from "./shared.js";
2
+
3
+ function collectJskitPackageNames(packageMap = {}) {
4
+ return Object.keys(packageMap && typeof packageMap === "object" ? packageMap : {})
5
+ .filter((name) => String(name || "").startsWith("@jskit-ai/"))
6
+ .sort((left, right) => left.localeCompare(right));
7
+ }
8
+
9
+ function resolveMajorRangeFromVersion(packageName = "", rawVersion = "", createCliError) {
10
+ const normalizedVersion = String(rawVersion || "").trim();
11
+ const match = normalizedVersion.match(/^(\d+)\.\d+\.\d+(?:[.+-][0-9A-Za-z.-]+)?$/u);
12
+ if (!match) {
13
+ throw createCliError(`Invalid latest version for ${packageName}: ${normalizedVersion || "<empty>"}.`, {
14
+ exitCode: 1
15
+ });
16
+ }
17
+ return `${match[1]}.x`;
18
+ }
19
+
20
+ function resolveRegistryArgs(registryUrl = "") {
21
+ const normalizedRegistryUrl = String(registryUrl || "").trim();
22
+ if (!normalizedRegistryUrl) {
23
+ return [];
24
+ }
25
+ return ["--registry", normalizedRegistryUrl];
26
+ }
27
+
28
+ function resolveInstallSpecs(packageNames = [], resolveMajorRange) {
29
+ return packageNames.map((packageName) => `${packageName}@${resolveMajorRange(packageName)}`);
30
+ }
31
+
32
+ async function runAppUpdatePackagesCommand(ctx = {}, { appRoot = "", options = {}, stdout, stderr }) {
33
+ const {
34
+ createCliError,
35
+ loadAppPackageJson
36
+ } = ctx;
37
+
38
+ const { packageJson } = await loadAppPackageJson(appRoot);
39
+ const runtimePackages = collectJskitPackageNames(packageJson?.dependencies);
40
+ const devPackages = collectJskitPackageNames(packageJson?.devDependencies);
41
+ const registryUrl = String(options?.inlineOptions?.registry || "").trim();
42
+ const registryArgs = resolveRegistryArgs(registryUrl);
43
+ const dryRun = options?.dryRun === true;
44
+
45
+ if (runtimePackages.length < 1 && devPackages.length < 1) {
46
+ stdout.write("[jskit:update] no @jskit-ai packages found in dependencies.\n");
47
+ return 0;
48
+ }
49
+
50
+ const resolveMajorRange = (packageName) => {
51
+ const result = runExternalCommand("npm", ["view", ...registryArgs, packageName, "version"], {
52
+ cwd: appRoot,
53
+ stdout,
54
+ stderr,
55
+ quiet: true,
56
+ createCliError
57
+ });
58
+ return resolveMajorRangeFromVersion(packageName, result.stdout, createCliError);
59
+ };
60
+
61
+ const runtimeSpecs = resolveInstallSpecs(runtimePackages, resolveMajorRange);
62
+ const devSpecs = resolveInstallSpecs(devPackages, resolveMajorRange);
63
+ const dryRunArgs = dryRun ? ["--dry-run"] : [];
64
+
65
+ if (dryRun) {
66
+ stdout.write("[jskit:update] dry-run mode enabled.\n");
67
+ }
68
+
69
+ if (runtimeSpecs.length > 0) {
70
+ stdout.write(`[jskit:update] updating runtime packages: ${runtimeSpecs.join(" ")}\n`);
71
+ runExternalCommand("npm", ["install", "--save-exact", ...registryArgs, ...dryRunArgs, ...runtimeSpecs], {
72
+ cwd: appRoot,
73
+ stdout,
74
+ stderr,
75
+ createCliError
76
+ });
77
+ }
78
+
79
+ if (devSpecs.length > 0) {
80
+ stdout.write(`[jskit:update] updating dev packages: ${devSpecs.join(" ")}\n`);
81
+ runExternalCommand("npm", ["install", "--save-dev", "--save-exact", ...registryArgs, ...dryRunArgs, ...devSpecs], {
82
+ cwd: appRoot,
83
+ stdout,
84
+ stderr,
85
+ createCliError
86
+ });
87
+ }
88
+
89
+ if (!dryRun) {
90
+ stdout.write("[jskit:update] generating managed migrations for changed packages.\n");
91
+ await runLocalJskit(appRoot, ["migrations", "changed"], {
92
+ stdout,
93
+ stderr,
94
+ createCliError
95
+ });
96
+ }
97
+
98
+ stdout.write("[jskit:update] done.\n");
99
+ return 0;
100
+ }
101
+
102
+ export { runAppUpdatePackagesCommand };
@@ -0,0 +1,35 @@
1
+ import { runExternalCommand, runLocalJskit } from "./shared.js";
2
+
3
+ const BASELINE_VERIFY_SCRIPTS = Object.freeze([
4
+ "lint",
5
+ "test",
6
+ "test:client",
7
+ "build"
8
+ ]);
9
+
10
+ async function runAppVerifyCommand(ctx = {}, { appRoot = "", options = {}, stdout, stderr }) {
11
+ const { createCliError } = ctx;
12
+
13
+ if (options?.dryRun) {
14
+ throw createCliError("jskit app verify does not support --dry-run.", { exitCode: 1 });
15
+ }
16
+
17
+ for (const scriptName of BASELINE_VERIFY_SCRIPTS) {
18
+ runExternalCommand("npm", ["run", "--if-present", scriptName], {
19
+ cwd: appRoot,
20
+ stdout,
21
+ stderr,
22
+ createCliError
23
+ });
24
+ }
25
+
26
+ await runLocalJskit(appRoot, ["doctor"], {
27
+ stdout,
28
+ stderr,
29
+ createCliError
30
+ });
31
+
32
+ return 0;
33
+ }
34
+
35
+ export { runAppVerifyCommand };
@@ -126,6 +126,47 @@ const COMMAND_DESCRIPTORS = Object.freeze({
126
126
  inlineOptionMode: "enumerated",
127
127
  allowedValueOptionNames: Object.freeze(["scope", "package-id", "description"])
128
128
  }),
129
+ app: Object.freeze({
130
+ command: "app",
131
+ aliases: Object.freeze([]),
132
+ showInOverview: true,
133
+ summary: "Run JSKIT-managed app maintenance helpers.",
134
+ minimalUse: "jskit app verify",
135
+ parameters: Object.freeze([
136
+ Object.freeze({
137
+ name: "<subcommand>",
138
+ description: "verify | update-packages | link-local-packages | release | adopt-managed-scripts."
139
+ })
140
+ ]),
141
+ defaults: Object.freeze([
142
+ "The scaffold keeps npm run shortcuts such as verify and jskit:update, but their maintained behavior lives under jskit app.",
143
+ "Use jskit app <subcommand> help for subcommand-specific usage.",
144
+ "--dry-run is accepted by update-packages, adopt-managed-scripts, and release."
145
+ ]),
146
+ examples: Object.freeze([
147
+ Object.freeze({
148
+ label: "Scaffolded maintenance shortcuts",
149
+ lines: Object.freeze([
150
+ "jskit app verify",
151
+ "jskit app update-packages"
152
+ ])
153
+ }),
154
+ Object.freeze({
155
+ label: "Existing app migration",
156
+ lines: Object.freeze([
157
+ "jskit app adopt-managed-scripts --dry-run",
158
+ "jskit app adopt-managed-scripts --force"
159
+ ])
160
+ })
161
+ ]),
162
+ fullUse: "jskit app <subcommand> [help] [--dry-run] [--<option> <value>...]",
163
+ showHelpOnBareInvocation: true,
164
+ handlerName: "commandApp",
165
+ allowedFlagKeys: Object.freeze(["dryRun"]),
166
+ inlineOptionMode: "delegate",
167
+ allowedValueOptionNames: Object.freeze([]),
168
+ canDelegateInlineOptions: (positional = []) => Array.isArray(positional) && positional.length > 0
169
+ }),
129
170
  add: Object.freeze({
130
171
  command: "add",
131
172
  aliases: Object.freeze([]),
@@ -2,6 +2,7 @@ import { createCommandHandlerShared } from "../commandHandlers/shared.js";
2
2
  import { createListCommands } from "../commandHandlers/list.js";
3
3
  import { createShowCommand } from "../commandHandlers/show.js";
4
4
  import { createPackageCommands } from "../commandHandlers/package.js";
5
+ import { createAppCommands } from "../commandHandlers/app.js";
5
6
  import { createHealthCommands } from "../commandHandlers/health.js";
6
7
  import { createCompletionCommands } from "../commandHandlers/completion.js";
7
8
 
@@ -23,6 +24,7 @@ function createCommandHandlers(deps = {}) {
23
24
  commandPosition,
24
25
  commandRemove
25
26
  } = createPackageCommands(commandContext);
27
+ const { commandApp } = createAppCommands(commandContext);
26
28
  const { commandDoctor, commandLintDescriptors } = createHealthCommands(commandContext);
27
29
  const { commandCompletion } = createCompletionCommands(commandContext);
28
30
 
@@ -32,6 +34,7 @@ function createCommandHandlers(deps = {}) {
32
34
  commandListLinkItems,
33
35
  commandCompletion,
34
36
  commandShow,
37
+ commandApp,
35
38
  commandCreate,
36
39
  commandAdd,
37
40
  commandGenerate,