@guiho/runx 0.2.5 → 0.2.7

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.
@@ -0,0 +1,85 @@
1
+ ---
2
+ name: RunX Windows Self-Upgrade Validation
3
+ purpose: Record verification evidence for the Windows self-upgrade fix and issue closure.
4
+ description: Captures Windows replacement and rollback tests, complete Bun build checks, native CLI checks, CI syntax, XDocs health, and residual release risk.
5
+ created: 2026-07-14
6
+ flags:
7
+ - validated
8
+ tags:
9
+ - validation
10
+ - windows
11
+ - cli
12
+ keywords:
13
+ - runx upgrade
14
+ - windows executable
15
+ - rollback
16
+ - issue 9
17
+ - issue 1
18
+ owner: runx-validation
19
+ ---
20
+
21
+ # RunX Windows Self-Upgrade Validation
22
+
23
+ ## Summary
24
+
25
+ The implementation is ready for commit, issue closure, and the requested
26
+ Mirror-managed patch. All local checks passed on Windows.
27
+
28
+ ## Scope
29
+
30
+ - Synchronous Windows replacement of a running native executable.
31
+ - Target-version verification before successful completion.
32
+ - Rollback after invalid replacement verification.
33
+ - Deferred cleanup of the locked old executable only.
34
+ - Existing manifest-free `-h` behavior from issue #1.
35
+ - Windows CI coverage and structured documentation health.
36
+
37
+ ## Commands and Results
38
+
39
+ | Check | Result | Evidence |
40
+ | --- | --- | --- |
41
+ | `bun run typecheck` | passed | TypeScript completed without diagnostics. |
42
+ | `bun test` | passed | 15 tests, 0 failures, 134 assertions. |
43
+ | `bun run build` | passed | Library output compiled. |
44
+ | `bun run binary` | passed | Local Windows executable compiled. |
45
+ | `bun run binaries` | passed | All twelve release assets built and verified. |
46
+ | Native `runx-windows-x64.exe -h` outside a manifest | passed | Citty usage rendered with exit code 0. |
47
+ | Native `runx-windows-x64.exe --version` | passed | Reported `0.2.5` before the patch bump. |
48
+ | `Bun.YAML.parse(.github/workflows/ci.yml)` | passed | Workflow YAML parsed successfully. |
49
+ | `xdocs meta . --documents --strict --owner runx --format json` | passed | Root metadata and companions valid. |
50
+ | `xdocs doctor . --warnings-as-errors --format json` | passed | 0 errors and 0 warnings. |
51
+ | `git diff --check` | passed | No whitespace errors. |
52
+
53
+ ## Behavioral Evidence
54
+
55
+ `source/self-management.spec.ts` runs only on Windows for the platform-specific
56
+ cases. Its success case launches a copied PowerShell executable from the
57
+ configured self path, keeps that executable running, upgrades the same path to
58
+ the current Bun executable, and requires the replacement to report the expected
59
+ version before `upgradeSelf()` resolves. It also observes removal of `.new` and
60
+ eventual removal of `.old` after the running process exits.
61
+
62
+ The rollback case downloads invalid executable bytes, requires verification to
63
+ fail, and confirms that the original bytes are restored with neither `.new` nor
64
+ `.old` left behind.
65
+
66
+ The existing `source/cli.spec.ts` root-help test proves both `-h` and `--help`
67
+ work without a manifest, satisfying issue #1.
68
+
69
+ ## Skipped or Blocked Checks
70
+
71
+ - The new GitHub-hosted `windows-latest` job is not locally runnable; it will
72
+ execute after the commit reaches GitHub.
73
+ - An actual 0.2.0-to-new-patch public upgrade is blocked until the new release
74
+ assets are published.
75
+
76
+ ## Readiness
77
+
78
+ Ready for commit, closing GitHub issues #9 and #1, changelog preparation, and
79
+ the requested Mirror patch plan/apply sequence.
80
+
81
+ ## References
82
+
83
+ - [Implementation Review](../reviews/implementation/windows-self-upgrade-review.md)
84
+ - [Plan](../plans/windows-self-upgrade.md)
85
+ - [Decision](../decisions/windows-self-upgrade.md)
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../source/cli.ts"],"names":[],"mappings":"AAAA;;GAEG;AAYH,OAAO,KAAK,EAAkB,UAAU,EAAkB,MAAM,OAAO,CAAA;AAGvE,OAAO,EACL,MAAM,EACN,uBAAuB,EACvB,WAAW,GACZ,CAAA;AAqPD,QAAA,MAAiB,WAAW,iBAAwB,CAAA;AAEpD,iBAAe,MAAM,CAAC,OAAO,GAAE,MAAM,EAA0B,GAAG,OAAO,CAAC,IAAI,CAAC,CAY9E;AAED,iBAAe,uBAAuB,CAAC,OAAO,CAAC,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBxE"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../source/cli.ts"],"names":[],"mappings":"AAAA;;GAEG;AAaH,OAAO,KAAK,EAAkB,UAAU,EAAkB,MAAM,OAAO,CAAA;AAGvE,OAAO,EACL,MAAM,EACN,uBAAuB,EACvB,WAAW,GACZ,CAAA;AA8PD,QAAA,MAAiB,WAAW,iBAAwB,CAAA;AAEpD,iBAAe,MAAM,CAAC,OAAO,GAAE,MAAM,EAA0B,GAAG,OAAO,CAAC,IAAI,CAAC,CAY9E;AAED,iBAAe,uBAAuB,CAAC,OAAO,CAAC,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBxE"}
package/library/cli.js CHANGED
@@ -7,6 +7,7 @@ import { installAgentInstructions, installAgentSkill } from './agents.js';
7
7
  import { RunXError } from './errors.js';
8
8
  import { runCommand } from './executor.js';
9
9
  import { readVersion, showHelpDocs, showHelpTree, showHome } from './help.js';
10
+ import { initializeRunXManifest } from './init.js';
10
11
  import { readManifest, resolveCommand } from './manifest.js';
11
12
  import { renderDescription, renderExecutionPlan, renderJson, renderList } from './render.js';
12
13
  import { checkForLatestVersion, listAvailableVersions, uninstallSelf, upgradeSelf } from './self-management.js';
@@ -95,6 +96,12 @@ const createCommandTree = () => {
95
96
  setup: withCommandHelp(state),
96
97
  run: async () => checkManifest(resolveOptions(state.globalArgs)),
97
98
  });
99
+ const initCommand = defineCommand({
100
+ meta: { name: 'runx init', description: 'Interactively create an empty RunX manifest.' },
101
+ args: commonArgs,
102
+ setup: withCommandHelp(state),
103
+ run: async () => initializeProject(state.globalArgs),
104
+ });
98
105
  const agentsInstallCommand = defineCommand({
99
106
  meta: { name: 'runx agents install', description: 'Install the bundled RunX skill.' },
100
107
  args: {
@@ -166,6 +173,7 @@ const createCommandTree = () => {
166
173
  describe: describeCommandDefinition,
167
174
  run: runCommandDefinition,
168
175
  check: checkCommand,
176
+ init: initCommand,
169
177
  agents: agentsCommand,
170
178
  upgrade: upgradeCommand,
171
179
  uninstall: uninstallCommand,
@@ -176,6 +184,7 @@ const createCommandTree = () => {
176
184
  ['run', runCommandDefinition],
177
185
  ['r', runCommandDefinition],
178
186
  ['check', checkCommand],
187
+ ['init', initCommand],
179
188
  ['agents', agentsCommand],
180
189
  ['agents install', agentsInstallCommand],
181
190
  ['agents instructions', agentsInstructionsCommand],
@@ -334,6 +343,15 @@ async function checkManifest(options) {
334
343
  const result = { valid: true, manifestPath: path, commandCount: manifest.commands.length, groups: Object.keys(manifest.groups) };
335
344
  write(options.format === 'json' ? renderJson(result) : `valid: true\nmanifest: ${path}\ncommands: ${result.commandCount}\n`);
336
345
  }
346
+ async function initializeProject(args) {
347
+ if (args.file) {
348
+ throw new RunXError('runx init does not support --file. It always creates runx.yaml in --cwd or the current directory.');
349
+ }
350
+ if (args.format === 'json') {
351
+ throw new RunXError('runx init does not support --format json because initialization is an interactive terminal workflow.');
352
+ }
353
+ await initializeRunXManifest({ cwd: resolve(args.cwd ?? process.cwd()) });
354
+ }
337
355
  async function runSelectedCommand(selector, options, args) {
338
356
  const { manifest, path } = await readManifest(options.cwd, options.file);
339
357
  const command = resolveCommand(manifest, path, selector);
@@ -1 +1 @@
1
- {"version":3,"file":"help.d.ts","sourceRoot":"","sources":["../source/help.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,WAAW,QAAO,MAAiF,CAAA;AAEhH,eAAO,MAAM,QAAQ,QAAO,MAe3B,CAAA;AAED,eAAO,MAAM,YAAY,QAAO,MAapB,CAAA;AAEZ,eAAO,MAAM,YAAY,QAAO,MAU/B,CAAA"}
1
+ {"version":3,"file":"help.d.ts","sourceRoot":"","sources":["../source/help.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,WAAW,QAAO,MAAiF,CAAA;AAEhH,eAAO,MAAM,QAAQ,QAAO,MAiB3B,CAAA;AAED,eAAO,MAAM,YAAY,QAAO,MAcpB,CAAA;AAEZ,eAAO,MAAM,YAAY,QAAO,MAW/B,CAAA"}
package/library/help.js CHANGED
@@ -5,6 +5,7 @@ export const showHome = () => `RunX ${readVersion()}
5
5
  A documented, local command catalog for runx.yaml manifests.
6
6
 
7
7
  Usage:
8
+ runx init [--cwd <path>]
8
9
  runx list [--file <path>] [--format <text|json>]
9
10
  runx describe <selector>
10
11
  runx run <selector> [--dry-run] [--yes]
@@ -12,6 +13,7 @@ Usage:
12
13
  runx <selector>
13
14
 
14
15
  Start here:
16
+ runx init Interactively create an empty runx.yaml catalog.
15
17
  runx list List every command in the nearest manifest.
16
18
  runx --help-tree Show the complete command tree.
17
19
  runx --help-docs Show manifest and agent documentation guidance.
@@ -23,6 +25,7 @@ export const showHelpTree = () => [
23
25
  '|- run <selector>',
24
26
  '| `- alias: r',
25
27
  '|- check',
28
+ '|- init',
26
29
  '|- agents',
27
30
  '| |- install <local|global>',
28
31
  '| `- instructions',
@@ -33,6 +36,7 @@ export const showHelpTree = () => [
33
36
  export const showHelpDocs = () => `RunX documentation
34
37
 
35
38
  Manifest: runx.yaml, discovered from the current directory upward or selected with --file.
39
+ Create an empty catalog with runx init. Its manifest uses SemVer 1.x, configures a scripts directory, and always includes the public group.
36
40
  Required command fields: uid, id, group, summary, description, command.
37
41
  Optional command fields: cwd, shell, tags, confirm.
38
42
 
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @copyright Copyright © 2026 GUIHO Technologies as represented by Cristóvão GUIHO. All Rights Reserved.
3
+ */
4
+ import type { RunXManifest } from './types.js';
5
+ export type RunXInitPrompter = {
6
+ intro(title: string): void;
7
+ text(options: {
8
+ message: string;
9
+ initialValue: string;
10
+ validate: (value: string | undefined) => string | undefined;
11
+ }): Promise<string | undefined>;
12
+ confirm(options: {
13
+ message: string;
14
+ initialValue: boolean;
15
+ }): Promise<boolean | undefined>;
16
+ preview(manifest: string): void;
17
+ cancel(message: string): void;
18
+ outro(message: string): void;
19
+ close?(): void;
20
+ };
21
+ export type RunXInitResult = {
22
+ status: 'cancelled';
23
+ } | {
24
+ status: 'created';
25
+ path: string;
26
+ manifest: RunXManifest;
27
+ };
28
+ type RunXInitOptions = {
29
+ cwd: string;
30
+ isInteractive?: boolean;
31
+ prompter?: RunXInitPrompter;
32
+ };
33
+ export declare const initializeRunXManifest: ({ cwd, isInteractive, prompter }: RunXInitOptions) => Promise<RunXInitResult>;
34
+ export declare const createInitialManifest: (projectName: string, scriptsDirectory: string) => RunXManifest;
35
+ export declare const renderInitialManifest: (manifest: RunXManifest) => string;
36
+ export {};
37
+ //# sourceMappingURL=init.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../source/init.ts"],"names":[],"mappings":"AAAA;;GAEG;AASH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAE9C,MAAM,MAAM,gBAAgB,GAAG;IAC7B,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,IAAI,CAAC,OAAO,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,KAAK,MAAM,GAAG,SAAS,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAA;IAClJ,OAAO,CAAC,OAAO,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC,CAAA;IAC1F,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IAC/B,MAAM,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,KAAK,CAAC,IAAI,IAAI,CAAA;CACf,CAAA;AAED,MAAM,MAAM,cAAc,GACtB;IAAE,MAAM,EAAE,WAAW,CAAA;CAAE,GACvB;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,YAAY,CAAA;CAAE,CAAA;AAE/D,KAAK,eAAe,GAAG;IACrB,GAAG,EAAE,MAAM,CAAA;IACX,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,QAAQ,CAAC,EAAE,gBAAgB,CAAA;CAC5B,CAAA;AAID,eAAO,MAAM,sBAAsB,qCAA4G,eAAe,KAAG,OAAO,CAAC,cAAc,CAmDtL,CAAA;AAED,eAAO,MAAM,qBAAqB,gBAAiB,MAAM,oBAAoB,MAAM,KAAG,YAQpF,CAAA;AAEF,eAAO,MAAM,qBAAqB,aAAc,YAAY,KAAG,MAenD,CAAA"}
@@ -0,0 +1,165 @@
1
+ /**
2
+ * @copyright Copyright © 2026 GUIHO Technologies as represented by Cristóvão GUIHO. All Rights Reserved.
3
+ */
4
+ import { randomUUID } from 'node:crypto';
5
+ import { rename, rm, writeFile } from 'node:fs/promises';
6
+ import { basename, isAbsolute, relative, resolve } from 'node:path';
7
+ import { createInterface } from 'node:readline/promises';
8
+ import { RunXError } from './errors.js';
9
+ import { readManifest } from './manifest.js';
10
+ const manifestVersion = '1.0.0';
11
+ export const initializeRunXManifest = async ({ cwd, isInteractive = process.stdin.isTTY === true && process.stdout.isTTY === true, prompter }) => {
12
+ if (!isInteractive) {
13
+ throw new RunXError('runx init requires an interactive terminal. Run it directly in a terminal with stdin and stdout attached.');
14
+ }
15
+ const activePrompter = prompter ?? createTerminalPrompter();
16
+ try {
17
+ const root = resolve(cwd);
18
+ const path = resolve(root, 'runx.yaml');
19
+ const defaultProjectName = basename(root) || 'my-project';
20
+ activePrompter.intro('RunX project setup');
21
+ if (await Bun.file(path).exists()) {
22
+ const overwrite = await activePrompter.confirm({
23
+ message: 'runx.yaml already exists. Replace it?',
24
+ initialValue: false,
25
+ });
26
+ if (overwrite !== true)
27
+ return cancelInitialization(activePrompter);
28
+ }
29
+ const projectName = await activePrompter.text({
30
+ message: 'Project name',
31
+ initialValue: defaultProjectName,
32
+ validate: (value) => value?.trim() ? undefined : 'Enter a project name.',
33
+ });
34
+ if (projectName === undefined)
35
+ return cancelInitialization(activePrompter);
36
+ const scriptsDirectory = await activePrompter.text({
37
+ message: 'Scripts directory',
38
+ initialValue: 'scripts',
39
+ validate: (value) => validateScriptsDirectory(value, root),
40
+ });
41
+ if (scriptsDirectory === undefined)
42
+ return cancelInitialization(activePrompter);
43
+ const manifest = createInitialManifest(projectName.trim(), scriptsDirectory.trim());
44
+ const yaml = renderInitialManifest(manifest);
45
+ activePrompter.preview(yaml);
46
+ const confirmed = await activePrompter.confirm({
47
+ message: 'Create this runx.yaml?',
48
+ initialValue: true,
49
+ });
50
+ if (confirmed !== true)
51
+ return cancelInitialization(activePrompter);
52
+ await writeValidatedManifest(root, path, yaml);
53
+ activePrompter.outro(`Created ${path}`);
54
+ return { status: 'created', path, manifest };
55
+ }
56
+ finally {
57
+ activePrompter.close?.();
58
+ }
59
+ };
60
+ export const createInitialManifest = (projectName, scriptsDirectory) => ({
61
+ version: manifestVersion,
62
+ project: { name: projectName },
63
+ scripts: { directory: scriptsDirectory },
64
+ groups: {
65
+ public: { summary: 'Default public project commands.' },
66
+ },
67
+ commands: [],
68
+ });
69
+ export const renderInitialManifest = (manifest) => [
70
+ `version: ${JSON.stringify(manifest.version)}`,
71
+ '',
72
+ 'project:',
73
+ ` name: ${JSON.stringify(manifest.project?.name ?? '')}`,
74
+ '',
75
+ 'scripts:',
76
+ ` directory: ${JSON.stringify(manifest.scripts.directory)}`,
77
+ '',
78
+ 'groups:',
79
+ ' public:',
80
+ ` summary: ${JSON.stringify(manifest.groups.public.summary)}`,
81
+ '',
82
+ 'commands: []',
83
+ '',
84
+ ].join('\n');
85
+ const createTerminalPrompter = () => {
86
+ const terminal = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
87
+ const accent = (value) => process.env['NO_COLOR'] ? value : `\u001b[36m${value}\u001b[0m`;
88
+ const success = (value) => process.env['NO_COLOR'] ? value : `\u001b[32m${value}\u001b[0m`;
89
+ const muted = (value) => process.env['NO_COLOR'] ? value : `\u001b[2m${value}\u001b[0m`;
90
+ const error = (value) => process.env['NO_COLOR'] ? value : `\u001b[31m${value}\u001b[0m`;
91
+ const write = (value) => {
92
+ process.stdout.write(value);
93
+ };
94
+ const ask = async (label) => {
95
+ try {
96
+ return await terminal.question(label);
97
+ }
98
+ catch {
99
+ return undefined;
100
+ }
101
+ };
102
+ return {
103
+ intro: (title) => write(`\n${accent('◆')} ${title}\n${muted(' A documented command catalog for this project.')}\n\n`),
104
+ text: async (options) => {
105
+ while (true) {
106
+ const value = await ask(`${accent('◆')} ${options.message} ${muted(`[${options.initialValue}]`)}\n `);
107
+ if (value === undefined)
108
+ return undefined;
109
+ const selected = value.trim() || options.initialValue;
110
+ const validation = options.validate(selected);
111
+ if (!validation)
112
+ return selected;
113
+ write(`${error('▲')} ${validation}\n`);
114
+ }
115
+ },
116
+ confirm: async (options) => {
117
+ while (true) {
118
+ const defaultLabel = options.initialValue ? 'Y/n' : 'y/N';
119
+ const value = await ask(`${accent('◆')} ${options.message} ${muted(`[${defaultLabel}]`)}\n `);
120
+ if (value === undefined)
121
+ return undefined;
122
+ const answer = value.trim().toLowerCase();
123
+ if (!answer)
124
+ return options.initialValue;
125
+ if (answer === 'y' || answer === 'yes')
126
+ return true;
127
+ if (answer === 'n' || answer === 'no')
128
+ return false;
129
+ write(`${error('▲')} Enter yes or no.\n`);
130
+ }
131
+ },
132
+ preview: (manifest) => write(`\n${accent('┌─ runx.yaml preview ─────────────────────────────────')}\n${manifest.trimEnd().split('\n').map((line) => `${accent('│')} ${line}`).join('\n')}\n${accent('└────────────────────────────────────────────────────')}\n\n`),
133
+ cancel: (message) => write(`${error('■')} ${message}\n`),
134
+ outro: (message) => {
135
+ write(`${success('◆')} ${message}\n`);
136
+ },
137
+ close: () => terminal.close(),
138
+ };
139
+ };
140
+ const cancelInitialization = (prompter) => {
141
+ prompter.cancel('Initialization cancelled. No files were changed.');
142
+ return { status: 'cancelled' };
143
+ };
144
+ const validateScriptsDirectory = (value, root) => {
145
+ const directory = value?.trim();
146
+ if (!directory)
147
+ return 'Enter a scripts directory.';
148
+ const resolvedDirectory = resolve(root, directory);
149
+ const relativeDirectory = relative(root, resolvedDirectory);
150
+ if (relativeDirectory === '' || relativeDirectory === '.' || isAbsolute(relativeDirectory) || relativeDirectory.startsWith('..')) {
151
+ return 'Use a relative subdirectory inside this project.';
152
+ }
153
+ return undefined;
154
+ };
155
+ const writeValidatedManifest = async (root, path, yaml) => {
156
+ const temporaryPath = resolve(root, `.runx-${randomUUID()}.yaml.tmp`);
157
+ try {
158
+ await writeFile(temporaryPath, yaml, { encoding: 'utf8', flag: 'wx' });
159
+ await readManifest(root, temporaryPath);
160
+ await rename(temporaryPath, path);
161
+ }
162
+ finally {
163
+ await rm(temporaryPath, { force: true });
164
+ }
165
+ };
@@ -12,10 +12,13 @@ export declare const CommandSchema: import("@sinclair/typebox").TObject<{
12
12
  confirm: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"never">, import("@sinclair/typebox").TLiteral<"always">]>>;
13
13
  }>;
14
14
  export declare const ManifestSchema: import("@sinclair/typebox").TObject<{
15
- version: import("@sinclair/typebox").TLiteral<1>;
15
+ version: import("@sinclair/typebox").TString;
16
16
  project: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
17
17
  name: import("@sinclair/typebox").TString;
18
18
  }>>;
19
+ scripts: import("@sinclair/typebox").TObject<{
20
+ directory: import("@sinclair/typebox").TString;
21
+ }>;
19
22
  groups: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
20
23
  summary: import("@sinclair/typebox").TString;
21
24
  }>>;
@@ -1 +1 @@
1
- {"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../source/manifest.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,eAAe,EAAe,YAAY,EAAE,MAAM,YAAY,CAAA;AAI5E,eAAO,MAAM,aAAa;;;;;;;;;;;EAiBS,CAAA;AAEnC,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;EAKQ,CAAA;AAInC,eAAO,MAAM,YAAY,QAAe,MAAM,iBAAiB,MAAM,KAAG,OAAO,CAAC,MAAM,CAiBrF,CAAA;AAED,eAAO,MAAM,YAAY,QAAe,MAAM,iBAAiB,MAAM,KAAG,OAAO,CAAC;IAAE,QAAQ,EAAE,YAAY,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAmBvH,CAAA;AAED,eAAO,MAAM,cAAc,aAAc,YAAY,gBAAgB,MAAM,YAAY,MAAM,KAAG,eAkC/F,CAAA"}
1
+ {"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../source/manifest.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,eAAe,EAAe,YAAY,EAAE,MAAM,YAAY,CAAA;AAK5E,eAAO,MAAM,aAAa;;;;;;;;;;;EAiBS,CAAA;AAEnC,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;EAMQ,CAAA;AAInC,eAAO,MAAM,YAAY,QAAe,MAAM,iBAAiB,MAAM,KAAG,OAAO,CAAC,MAAM,CAiBrF,CAAA;AAED,eAAO,MAAM,YAAY,QAAe,MAAM,iBAAiB,MAAM,KAAG,OAAO,CAAC;IAAE,QAAQ,EAAE,YAAY,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAmBvH,CAAA;AAED,eAAO,MAAM,cAAc,aAAc,YAAY,gBAAgB,MAAM,YAAY,MAAM,KAAG,eAkC/F,CAAA"}
@@ -3,6 +3,7 @@ import { Type } from '@sinclair/typebox';
3
3
  import { TypeCompiler } from '@sinclair/typebox/compiler';
4
4
  import { RunXError, invariant } from './errors.js';
5
5
  const identifier = '^[a-z][a-z0-9-]*$';
6
+ const semver = '^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$';
6
7
  export const CommandSchema = Type.Object({
7
8
  uid: Type.String({ pattern: identifier }),
8
9
  id: Type.String({ pattern: identifier }),
@@ -22,10 +23,11 @@ export const CommandSchema = Type.Object({
22
23
  confirm: Type.Optional(Type.Union([Type.Literal('never'), Type.Literal('always')])),
23
24
  }, { additionalProperties: false });
24
25
  export const ManifestSchema = Type.Object({
25
- version: Type.Literal(1),
26
+ version: Type.String({ pattern: semver }),
26
27
  project: Type.Optional(Type.Object({ name: Type.String({ minLength: 1 }) }, { additionalProperties: false })),
28
+ scripts: Type.Object({ directory: Type.String({ minLength: 1 }) }, { additionalProperties: false }),
27
29
  groups: Type.Record(Type.String({ pattern: identifier }), Type.Object({ summary: Type.String({ minLength: 1 }) }, { additionalProperties: false })),
28
- commands: Type.Array(CommandSchema, { minItems: 1 }),
30
+ commands: Type.Array(CommandSchema),
29
31
  }, { additionalProperties: false });
30
32
  const manifestCheck = TypeCompiler.Compile(ManifestSchema);
31
33
  export const findManifest = async (cwd, explicitFile) => {
@@ -96,6 +98,17 @@ export const resolveCommand = (manifest, manifestPath, selector) => {
96
98
  };
97
99
  };
98
100
  const validateManifestSemantics = (manifest, path) => {
101
+ const versionMajor = manifest.version.split('.', 1)[0];
102
+ if (versionMajor !== '1') {
103
+ throw new RunXError(`Unsupported RunX manifest version "${manifest.version}" in ${path}. Supported major version: 1.`);
104
+ }
105
+ const manifestDirectory = dirname(path);
106
+ const scriptsDirectory = resolve(manifestDirectory, manifest.scripts.directory);
107
+ const relativeScriptsDirectory = relative(manifestDirectory, scriptsDirectory);
108
+ invariant(relativeScriptsDirectory !== '' && relativeScriptsDirectory !== '.' && !isAbsolute(relativeScriptsDirectory) && !relativeScriptsDirectory.startsWith('..'), `Scripts directory "${manifest.scripts.directory}" must be a relative subdirectory inside the manifest directory in ${path}.`);
109
+ if (!manifest.groups.public) {
110
+ throw new RunXError(`RunX manifest ${path} must define the default "public" group.`);
111
+ }
99
112
  const uids = new Set();
100
113
  const selectors = new Set();
101
114
  for (const command of manifest.commands) {
@@ -1,8 +1,9 @@
1
1
  import type { RunXUpgradeResult, UpdateResult } from './types.js';
2
- export declare const checkForLatestVersion: () => Promise<UpdateResult>;
3
- export declare const listAvailableVersions: () => Promise<string[]>;
4
- export declare const upgradeSelf: (dryRun: boolean) => Promise<RunXUpgradeResult>;
5
- export declare const uninstallSelf: (dryRun: boolean) => Promise<{
2
+ export { checkForLatestVersion, listAvailableVersions, uninstallSelf, upgradeSelf, };
3
+ declare const checkForLatestVersion: () => Promise<UpdateResult>;
4
+ declare const listAvailableVersions: () => Promise<string[]>;
5
+ declare const upgradeSelf: (dryRun: boolean) => Promise<RunXUpgradeResult>;
6
+ declare const uninstallSelf: (dryRun: boolean) => Promise<{
6
7
  executablePath: string;
7
8
  scheduled: boolean;
8
9
  dryRun: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"self-management.d.ts","sourceRoot":"","sources":["../source/self-management.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAMjE,eAAO,MAAM,qBAAqB,QAAa,OAAO,CAAC,YAAY,CAYlE,CAAA;AAED,eAAO,MAAM,qBAAqB,QAAa,OAAO,CAAC,MAAM,EAAE,CAK9D,CAAA;AAED,eAAO,MAAM,WAAW,WAAkB,OAAO,KAAG,OAAO,CAAC,iBAAiB,CAmB5E,CAAA;AAED,eAAO,MAAM,aAAa,WAAkB,OAAO,KAAG,OAAO,CAAC;IAAE,cAAc,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,CAW5H,CAAA"}
1
+ {"version":3,"file":"self-management.d.ts","sourceRoot":"","sources":["../source/self-management.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAEjE,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,aAAa,EACb,WAAW,GACZ,CAAA;AAMD,QAAA,MAAM,qBAAqB,QAAa,OAAO,CAAC,YAAY,CAY3D,CAAA;AAED,QAAA,MAAM,qBAAqB,QAAa,OAAO,CAAC,MAAM,EAAE,CAKvD,CAAA;AAED,QAAA,MAAM,WAAW,WAAkB,OAAO,KAAG,OAAO,CAAC,iBAAiB,CAmBrE,CAAA;AAED,QAAA,MAAM,aAAa,WAAkB,OAAO,KAAG,OAAO,CAAC;IAAE,cAAc,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,CAWrH,CAAA"}
@@ -1,9 +1,11 @@
1
+ import { Buffer } from 'node:buffer';
1
2
  import { chmod, rename, rm } from 'node:fs/promises';
2
3
  import { basename } from 'node:path';
3
4
  import { RunXError } from './errors.js';
4
5
  import { readVersion } from './help.js';
6
+ export { checkForLatestVersion, listAvailableVersions, uninstallSelf, upgradeSelf, };
5
7
  const repositoryApi = 'https://api.github.com/repos/CGuiho/runx/releases';
6
- export const checkForLatestVersion = async () => {
8
+ const checkForLatestVersion = async () => {
7
9
  const currentVersion = readVersion();
8
10
  try {
9
11
  const response = await fetch(`${repositoryApi}/latest`, { headers: { accept: 'application/vnd.github+json' } });
@@ -18,14 +20,14 @@ export const checkForLatestVersion = async () => {
18
20
  return { currentVersion, latestVersion: currentVersion, updateAvailable: false };
19
21
  }
20
22
  };
21
- export const listAvailableVersions = async () => {
23
+ const listAvailableVersions = async () => {
22
24
  const response = await fetch(`${repositoryApi}?per_page=20`, { headers: { accept: 'application/vnd.github+json' } });
23
25
  if (!response.ok)
24
26
  throw new RunXError(`Could not retrieve RunX releases: HTTP ${response.status}`);
25
27
  const releases = await response.json();
26
28
  return releases.map((release) => release.tag_name.replace(/^@guiho\/runx@/, '').replace(/^v/, ''));
27
29
  };
28
- export const upgradeSelf = async (dryRun) => {
30
+ const upgradeSelf = async (dryRun) => {
29
31
  const executablePath = requireNativeExecutable();
30
32
  const update = await checkForLatestVersion();
31
33
  const upToDate = !update.updateAvailable && update.latestVersion === update.currentVersion && Boolean(update.url);
@@ -43,10 +45,10 @@ export const upgradeSelf = async (dryRun) => {
43
45
  await rename(temporaryPath, executablePath);
44
46
  return { ...update, executablePath, scheduled: false, upToDate: false };
45
47
  }
46
- scheduleWindowsReplacement(temporaryPath, executablePath);
47
- return { ...update, executablePath, scheduled: true, upToDate: false };
48
+ await replaceWindowsExecutable(temporaryPath, executablePath, update.latestVersion);
49
+ return { ...update, executablePath, scheduled: false, upToDate: false };
48
50
  };
49
- export const uninstallSelf = async (dryRun) => {
51
+ const uninstallSelf = async (dryRun) => {
50
52
  const executablePath = requireNativeExecutable();
51
53
  if (dryRun)
52
54
  return { executablePath, scheduled: false, dryRun: true };
@@ -66,10 +68,65 @@ const requireNativeExecutable = () => {
66
68
  throw new RunXError('Self-management requires a native RunX executable installed from a GitHub release.');
67
69
  return executablePath;
68
70
  };
69
- const scheduleWindowsReplacement = (temporaryPath, executablePath) => {
70
- const command = `ping 127.0.0.1 -n 2 > nul & move /y "${temporaryPath}" "${executablePath}" > nul`;
71
- Bun.spawn(['cmd.exe', '/d', '/s', '/c', command], { detached: true, stdout: 'ignore', stderr: 'ignore', stdin: 'ignore' });
71
+ const replaceWindowsExecutable = async (temporaryPath, executablePath, expectedVersion) => {
72
+ const backupPath = `${executablePath}.old`;
73
+ let originalMoved = false;
74
+ try {
75
+ await rm(backupPath, { force: true });
76
+ await rename(executablePath, backupPath);
77
+ originalMoved = true;
78
+ await rename(temporaryPath, executablePath);
79
+ await verifyExecutableVersion(executablePath, expectedVersion);
80
+ await cleanupWindowsBackup(backupPath);
81
+ }
82
+ catch (error) {
83
+ const replacementFailure = errorMessage(error);
84
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
85
+ if (!originalMoved) {
86
+ throw new RunXError(`Could not replace the Windows RunX executable at ${executablePath}: ${replacementFailure}`);
87
+ }
88
+ try {
89
+ await rm(executablePath, { force: true });
90
+ await rename(backupPath, executablePath);
91
+ }
92
+ catch (rollbackError) {
93
+ throw new RunXError(`Windows RunX upgrade failed at ${executablePath}: ${replacementFailure}. Automatic rollback also failed: ${errorMessage(rollbackError)}`);
94
+ }
95
+ throw new RunXError(`Windows RunX upgrade failed; the previous executable was restored at ${executablePath}: ${replacementFailure}`);
96
+ }
97
+ };
98
+ const verifyExecutableVersion = async (executablePath, expectedVersion) => {
99
+ const verification = Bun.spawn([executablePath, '--version'], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
100
+ const [stdout, stderr, exitCode] = await Promise.all([
101
+ new Response(verification.stdout).text(),
102
+ new Response(verification.stderr).text(),
103
+ verification.exited,
104
+ ]);
105
+ const actualVersion = stdout.trim();
106
+ if (exitCode !== 0)
107
+ throw new Error(`replacement exited with code ${exitCode}${stderr.trim() ? `: ${stderr.trim()}` : ''}`);
108
+ if (actualVersion !== expectedVersion)
109
+ throw new Error(`replacement reported version ${actualVersion || '<empty>'}; expected ${expectedVersion}`);
110
+ };
111
+ const cleanupWindowsBackup = async (backupPath) => {
112
+ try {
113
+ await rm(backupPath, { force: true });
114
+ }
115
+ catch { }
116
+ scheduleWindowsBackupCleanup(backupPath);
117
+ };
118
+ const scheduleWindowsBackupCleanup = (backupPath) => {
119
+ const command = 'for ($attempt = 0; $attempt -lt 300; $attempt += 1) { if (-not (Test-Path -LiteralPath $env:RUNX_BACKUP_PATH)) { exit 0 }; try { Remove-Item -LiteralPath $env:RUNX_BACKUP_PATH -Force -ErrorAction Stop; exit 0 } catch { Start-Sleep -Milliseconds 100 } }; exit 1';
120
+ const encodedCommand = Buffer.from(command, 'utf16le').toString('base64');
121
+ Bun.spawn(['cmd.exe', '/d', '/s', '/c', `powershell.exe -NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ${encodedCommand}`], {
122
+ detached: true,
123
+ env: { ...process.env, RUNX_BACKUP_PATH: backupPath },
124
+ stdout: 'ignore',
125
+ stderr: 'ignore',
126
+ stdin: 'ignore',
127
+ });
72
128
  };
129
+ const errorMessage = (error) => error instanceof Error ? error.message : String(error);
73
130
  const compareVersions = (left, right) => {
74
131
  const parse = (value) => value.split(/[.+-]/).map((part) => Number.parseInt(part, 10) || 0);
75
132
  const a = parse(left);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@guiho/runx",
3
3
  "description": "A language-agnostic, documented command catalog and local CLI executor.",
4
- "version": "0.2.5",
4
+ "version": "0.2.7",
5
5
  "type": "module",
6
6
  "main": "./library/guiho-runx.js",
7
7
  "types": "./library/guiho-runx.d.ts",