@cardstack/boxel-cli 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@cardstack/boxel-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "license": "MIT",
5
5
  "description": "CLI tools for Boxel workspace management",
6
6
  "main": "./dist/index.js",
7
7
  "bin": {
8
- "boxel": "./bin/boxel.js"
8
+ "boxel": "./dist/index.js"
9
9
  },
10
10
  "files": [
11
11
  "dist/**/*",
@@ -28,35 +28,41 @@
28
28
  },
29
29
  "author": "Cardstack",
30
30
  "dependencies": {
31
- "@aws-crypto/sha256-js": "catalog:",
31
+ "@aws-crypto/sha256-js": "^5.2.0",
32
32
  "commander": "^13.1.0",
33
33
  "dotenv": "^16.4.7",
34
- "ignore": "catalog:",
35
- "jsonwebtoken": "catalog:",
34
+ "ignore": "^5.2.0",
35
+ "jsonwebtoken": "9.0.3",
36
36
  "p-limit": "^7.3.0"
37
37
  },
38
38
  "devDependencies": {
39
- "@cardstack/local-types": "workspace:*",
40
- "@cardstack/postgres": "workspace:*",
41
- "@cardstack/runtime-common": "workspace:*",
42
- "content-tag": "catalog:",
43
- "@types/jsonwebtoken": "catalog:",
44
- "@types/node": "catalog:",
45
- "@typescript-eslint/eslint-plugin": "catalog:",
46
- "@typescript-eslint/parser": "catalog:",
47
- "concurrently": "catalog:",
39
+ "content-tag": "^4.0.0",
40
+ "@types/jsonwebtoken": "9.0.10",
41
+ "@types/node": "^24.3.0",
42
+ "@typescript-eslint/eslint-plugin": "^7.18.0",
43
+ "@typescript-eslint/parser": "^7.18.0",
44
+ "concurrently": "^8.2.2",
48
45
  "esbuild": "^0.19.0",
49
- "eslint": "catalog:",
50
- "eslint-plugin-n": "catalog:",
51
- "eslint-plugin-prettier": "catalog:",
52
- "prettier": "catalog:",
46
+ "eslint": "^8.57.1",
47
+ "eslint-plugin-n": "^17.17.0",
48
+ "eslint-plugin-prettier": "^5.5.4",
49
+ "prettier": "^3.6.2",
53
50
  "ts-node": "^10.9.1",
54
- "typescript": "catalog:",
55
- "vite": "catalog:",
56
- "vitest": "catalog:"
51
+ "typescript": "~5.9.3",
52
+ "vite": "^6.3.2",
53
+ "vitest": "^2.1.9",
54
+ "@cardstack/local-types": "0.0.0",
55
+ "@cardstack/runtime-common": "1.0.0",
56
+ "@cardstack/postgres": "0.0.0"
57
+ },
58
+ "publishConfig": {
59
+ "access": "public",
60
+ "provenance": true
57
61
  },
58
62
  "scripts": {
59
63
  "build": "pnpm clean && NODE_NO_WARNINGS=1 ts-node --transpileOnly scripts/build.ts",
64
+ "build:plugin": "NODE_NO_WARNINGS=1 ts-node --transpileOnly scripts/build-plugin.ts",
65
+ "build:skills": "NODE_NO_WARNINGS=1 ts-node --transpileOnly scripts/build-skills.ts",
60
66
  "clean": "rm -rf dist/*",
61
67
  "start": "NODE_NO_WARNINGS=1 ts-node --transpileOnly src/index.ts",
62
68
  "lint": "concurrently \"pnpm:lint:*(!fix)\" --names \"lint:\"",
@@ -72,14 +78,7 @@
72
78
  "version:patch": "npm version patch",
73
79
  "version:minor": "npm version minor",
74
80
  "version:major": "npm version major",
75
- "publish:npm": "npm publish",
76
- "publish:dry": "npm publish --dry-run"
77
- },
78
- "publishConfig": {
79
- "access": "public",
80
- "provenance": true,
81
- "bin": {
82
- "boxel": "./dist/index.js"
83
- }
81
+ "publish:npm": "pnpm publish --no-git-checks",
82
+ "publish:dry": "pnpm publish --dry-run --no-git-checks"
84
83
  }
85
- }
84
+ }
@@ -0,0 +1,91 @@
1
+ import { Command } from 'commander';
2
+ import { profileCommand } from './commands/profile';
3
+ import { registerReadTranspiledCommand } from './commands/read-transpiled';
4
+ import { registerRealmCommand } from './commands/realm/index';
5
+ import { registerFileCommand } from './commands/file/index';
6
+ import { registerRunCommand } from './commands/run-command';
7
+ import { registerSearchCommand } from './commands/search';
8
+ import { setQuiet } from './lib/cli-log';
9
+
10
+ /**
11
+ * Construct the boxel CLI program with every command registered. Pure builder
12
+ * — does not call `program.parse()` and has no side effects on argv. Both the
13
+ * runtime entry point (`src/index.ts`) and the plugin generator
14
+ * (`scripts/build-plugin.ts`) call this so the Commander tree is one source of
15
+ * truth.
16
+ */
17
+ export function buildBoxelProgram(version: string): Command {
18
+ const program = new Command();
19
+
20
+ program
21
+ .name('boxel')
22
+ .description('CLI tools for Boxel workspace management')
23
+ .version(version)
24
+ .option(
25
+ '-q, --quiet',
26
+ 'Suppress informational progress logs (info/log/debug). Errors and warnings, plus command result payloads (JSON, file contents), are still emitted. Use this when invoking the CLI from automation (e.g. the software factory test harness) to keep stdout focused on the result.',
27
+ )
28
+ .hook('preAction', (thisCommand) => {
29
+ let opts = thisCommand.optsWithGlobals?.() ?? thisCommand.opts();
30
+ if (opts.quiet) {
31
+ setQuiet(true);
32
+ }
33
+ });
34
+
35
+ program
36
+ .command('profile')
37
+ .description('Manage saved profiles for different users/environments')
38
+ .argument('[subcommand]', 'list | add | switch | remove | migrate')
39
+ .argument('[arg]', 'Profile ID (for switch/remove)')
40
+ .option('-u, --user <matrixId>', 'Matrix user ID (e.g., @user:boxel.ai)')
41
+ .option('-p, --password <password>', 'Password (for add command)')
42
+ .option('-n, --name <displayName>', 'Display name (for add command)')
43
+ .option(
44
+ '-m, --matrix-url <url>',
45
+ 'Matrix server URL (for add command with non-standard domains)',
46
+ )
47
+ .option(
48
+ '-r, --realm-server-url <url>',
49
+ 'Realm server URL (for add command with non-standard domains)',
50
+ )
51
+ .addHelpText(
52
+ 'after',
53
+ `
54
+ Environment variables (for 'add'):
55
+ BOXEL_PASSWORD Password; preferred over -p to avoid shell history.
56
+ BOXEL_ENVIRONMENT An env-mode slug (e.g. a branch name), interpreted
57
+ like scripts/env-slug.sh: URLs are derived as
58
+ http://matrix.<slug>.localhost and
59
+ http://realm-server.<slug>.localhost/. Overridden
60
+ by --matrix-url / --realm-server-url if provided.`,
61
+ )
62
+ .action(
63
+ async (
64
+ subcommand?: string,
65
+ arg?: string,
66
+ options?: {
67
+ user?: string;
68
+ password?: string;
69
+ name?: string;
70
+ matrixUrl?: string;
71
+ realmServerUrl?: string;
72
+ },
73
+ ) => {
74
+ if (options?.password) {
75
+ console.warn(
76
+ 'Warning: Supplying a password via -p/--password may expose it in shell history and process listings. ' +
77
+ 'For non-interactive usage, prefer the BOXEL_PASSWORD environment variable or use "boxel profile add" interactively.',
78
+ );
79
+ }
80
+ await profileCommand(subcommand, arg, options);
81
+ },
82
+ );
83
+
84
+ registerFileCommand(program);
85
+ registerRealmCommand(program);
86
+ registerRunCommand(program);
87
+ registerSearchCommand(program);
88
+ registerReadTranspiledCommand(program);
89
+
90
+ return program;
91
+ }
@@ -25,6 +25,11 @@ interface PushOptions extends SyncOptions {
25
25
  force?: boolean;
26
26
  }
27
27
 
28
+ // Fresh realms always include these server-managed cards even when the local
29
+ // workspace has never pulled them. Treat them as realm artifacts, not user
30
+ // drift, so `push --delete` only removes genuine remote-only user files.
31
+ const REMOTE_DELETE_EXCLUSIONS = new Set(['index.json', 'realm.json']);
32
+
28
33
  class RealmPusher extends RealmSyncBase {
29
34
  hasError = false;
30
35
 
@@ -225,10 +230,16 @@ class RealmPusher extends RealmSyncBase {
225
230
 
226
231
  if (this.pushOptions.deleteRemote) {
227
232
  const filesToDelete = new Set(initialRemoteFiles.keys());
233
+ const skippedDeleteArtifacts: string[] = [];
228
234
 
229
235
  for (const relativePath of filesToDelete) {
230
236
  if (isProtectedFile(relativePath)) {
231
237
  filesToDelete.delete(relativePath);
238
+ continue;
239
+ }
240
+ if (REMOTE_DELETE_EXCLUSIONS.has(relativePath)) {
241
+ filesToDelete.delete(relativePath);
242
+ skippedDeleteArtifacts.push(relativePath);
232
243
  }
233
244
  }
234
245
 
@@ -236,21 +247,26 @@ class RealmPusher extends RealmSyncBase {
236
247
  filesToDelete.delete(relativePath);
237
248
  }
238
249
 
239
- if (filesToDelete.size > 0) {
250
+ if (skippedDeleteArtifacts.length > 0) {
240
251
  console.log(
241
- `Deleting ${filesToDelete.size} remote files that don't exist locally`,
252
+ `Skipping ${skippedDeleteArtifacts.length} realm-managed remote artifact(s): ${skippedDeleteArtifacts.join(', ')}`,
242
253
  );
254
+ }
243
255
 
244
- await Promise.all(
245
- Array.from(filesToDelete).map(async (relativePath) => {
246
- try {
247
- await this.deleteFile(relativePath);
248
- } catch (error) {
249
- this.hasError = true;
250
- console.error(`Error deleting ${relativePath}:`, error);
251
- }
252
- }),
256
+ if (filesToDelete.size > 0) {
257
+ const deletePlan = Array.from(filesToDelete).sort();
258
+ console.log(
259
+ `Deleting ${deletePlan.length} remote files that don't exist locally: ${deletePlan.join(', ')}`,
253
260
  );
261
+
262
+ for (const relativePath of deletePlan) {
263
+ try {
264
+ await this.deleteFile(relativePath);
265
+ } catch (error) {
266
+ this.hasError = true;
267
+ console.error(`Error deleting ${relativePath}:`, error);
268
+ }
269
+ }
254
270
  }
255
271
  }
256
272
 
@@ -489,6 +489,12 @@ export interface SyncCommandOptions {
489
489
  preferNewest?: boolean;
490
490
  delete?: boolean;
491
491
  dryRun?: boolean;
492
+ /**
493
+ * Append `?waitForIndex=true` to the `_atomic` upload so the
494
+ * realm-server returns only after the indexer has processed the
495
+ * batch. See `SyncOptions.waitForIndex` for the rationale.
496
+ */
497
+ waitForIndex?: boolean;
492
498
  profileManager?: ProfileManager;
493
499
  /**
494
500
  * Pre-resolved realm secret seed for administrative access. When set, the
@@ -629,6 +635,7 @@ export async function sync(
629
635
  preferNewest: options.preferNewest,
630
636
  deleteSync: options.delete,
631
637
  dryRun: options.dryRun,
638
+ waitForIndex: options.waitForIndex,
632
639
  },
633
640
  authenticator,
634
641
  );
package/src/index.ts CHANGED
@@ -1,44 +1,19 @@
1
1
  import 'dotenv/config';
2
- import { Command } from 'commander';
3
2
  import { readFileSync } from 'fs';
4
3
  import { resolve } from 'path';
5
- import { profileCommand } from './commands/profile';
6
- import { registerReadTranspiledCommand } from './commands/read-transpiled';
7
- import { registerRealmCommand } from './commands/realm/index';
8
- import { registerFileCommand } from './commands/file/index';
9
- import { registerRunCommand } from './commands/run-command';
10
- import { registerSearchCommand } from './commands/search';
4
+ import { buildBoxelProgram } from './build-program';
11
5
  import { setQuiet } from './lib/cli-log';
12
6
 
13
7
  const pkg = JSON.parse(
14
8
  readFileSync(resolve(__dirname, '../package.json'), 'utf-8'),
15
9
  );
16
10
 
17
- const program = new Command();
18
-
19
11
  // `--quiet` is implemented by intercepting `console.log/info/debug`.
20
12
  // New commands: write decorative output (status, confirmations, colored
21
13
  // lines) with `console.log` — it's silenced for free under `--quiet`.
22
14
  // For programmatic output (`--json` payloads, raw file bytes), use
23
15
  // `cliLog.output(...)`. Full guidance: see `lib/cli-log.ts`.
24
- program
25
- .name('boxel')
26
- .description('CLI tools for Boxel workspace management')
27
- .version(pkg.version)
28
- .option(
29
- '-q, --quiet',
30
- 'Suppress informational progress logs (info/log/debug). Errors and warnings, plus command result payloads (JSON, file contents), are still emitted. Use this when invoking the CLI from automation (e.g. the software factory test harness) to keep stdout focused on the result.',
31
- )
32
- // Toggle quiet mode as soon as the global option is parsed, so that any
33
- // module-level setup happening inside command actions sees the right
34
- // state. Commander invokes this hook before any subcommand action.
35
- .hook('preAction', (thisCommand) => {
36
- let opts = thisCommand.optsWithGlobals?.() ?? thisCommand.opts();
37
- if (opts.quiet) {
38
- setQuiet(true);
39
- }
40
- });
41
-
16
+ //
42
17
  // Belt-and-suspenders: also flip quiet mode based on a raw scan of argv,
43
18
  // so any code that runs between Commander's option parsing and the
44
19
  // `preAction` hook sees the right state. We scan for the long form only;
@@ -47,59 +22,4 @@ if (process.argv.includes('--quiet')) {
47
22
  setQuiet(true);
48
23
  }
49
24
 
50
- program
51
- .command('profile')
52
- .description('Manage saved profiles for different users/environments')
53
- .argument('[subcommand]', 'list | add | switch | remove | migrate')
54
- .argument('[arg]', 'Profile ID (for switch/remove)')
55
- .option('-u, --user <matrixId>', 'Matrix user ID (e.g., @user:boxel.ai)')
56
- .option('-p, --password <password>', 'Password (for add command)')
57
- .option('-n, --name <displayName>', 'Display name (for add command)')
58
- .option(
59
- '-m, --matrix-url <url>',
60
- 'Matrix server URL (for add command with non-standard domains)',
61
- )
62
- .option(
63
- '-r, --realm-server-url <url>',
64
- 'Realm server URL (for add command with non-standard domains)',
65
- )
66
- .addHelpText(
67
- 'after',
68
- `
69
- Environment variables (for 'add'):
70
- BOXEL_PASSWORD Password; preferred over -p to avoid shell history.
71
- BOXEL_ENVIRONMENT An env-mode slug (e.g. a branch name), interpreted
72
- like scripts/env-slug.sh: URLs are derived as
73
- http://matrix.<slug>.localhost and
74
- http://realm-server.<slug>.localhost/. Overridden
75
- by --matrix-url / --realm-server-url if provided.`,
76
- )
77
- .action(
78
- async (
79
- subcommand?: string,
80
- arg?: string,
81
- options?: {
82
- user?: string;
83
- password?: string;
84
- name?: string;
85
- matrixUrl?: string;
86
- realmServerUrl?: string;
87
- },
88
- ) => {
89
- if (options?.password) {
90
- console.warn(
91
- 'Warning: Supplying a password via -p/--password may expose it in shell history and process listings. ' +
92
- 'For non-interactive usage, prefer the BOXEL_PASSWORD environment variable or use "boxel profile add" interactively.',
93
- );
94
- }
95
- await profileCommand(subcommand, arg, options);
96
- },
97
- );
98
-
99
- registerFileCommand(program);
100
- registerRealmCommand(program);
101
- registerRunCommand(program);
102
- registerSearchCommand(program);
103
- registerReadTranspiledCommand(program);
104
-
105
- program.parse();
25
+ buildBoxelProgram(pkg.version).parse();
@@ -90,6 +90,16 @@ export interface SyncOptions {
90
90
  delete?: boolean;
91
91
  /** Preview without making changes. */
92
92
  dryRun?: boolean;
93
+ /**
94
+ * Block on the realm-server until uploaded cards have been indexed,
95
+ * not just durably written. Appends `?waitForIndex=true` to the
96
+ * `_atomic` POST. Trades upload latency for read-after-write
97
+ * consistency — useful when the next step queries the realm's index
98
+ * (search / list) and can't tolerate the indexer lag introduced by
99
+ * CS-11003 PR 2's deferred `+source` POST. Off by default; flip on
100
+ * for hand-off boundaries like the factory's post-seed sync.
101
+ */
102
+ waitForIndex?: boolean;
93
103
  }
94
104
 
95
105
  export type { DeleteResult };
@@ -467,6 +477,7 @@ export class BoxelCLIClient {
467
477
  preferNewest: options?.preferNewest,
468
478
  delete: options?.delete,
469
479
  dryRun: options?.dryRun,
480
+ waitForIndex: options?.waitForIndex,
470
481
  profileManager: this.pm,
471
482
  });
472
483
  }
@@ -9,6 +9,8 @@ type Ignore = ReturnType<typeof ignoreModule>;
9
9
 
10
10
  // Files that must never be pushed, deleted, or overwritten on the server via CLI.
11
11
  export const PROTECTED_FILES = new Set(['.realm.json']);
12
+ const DELETE_TIMEOUT_MS = 10_000;
13
+ const DELETE_TIMEOUT_PROBE_MS = 3_000;
12
14
 
13
15
  export function isProtectedFile(relativePath: string): boolean {
14
16
  const normalizedPath = relativePath.replace(/\\/g, '/').replace(/^\/+/, '');
@@ -50,6 +52,15 @@ export interface SyncOptions {
50
52
  realmUrl: string;
51
53
  localDir: string;
52
54
  dryRun?: boolean;
55
+ /**
56
+ * Append `?waitForIndex=true` to the `_atomic` POST so the realm-server
57
+ * returns only after the indexer has processed the batch. The
58
+ * `_atomic` handler hardcoded `waitForIndex: false` after CS-11003
59
+ * PR 2 (deferred `+source` POST), so callers that read indexed state
60
+ * (search / list) immediately after a sync race the indexer. Off by
61
+ * default.
62
+ */
63
+ waitForIndex?: boolean;
53
64
  }
54
65
 
55
66
  const REMOTE_CONCURRENCY = 10;
@@ -453,7 +464,9 @@ export abstract class RealmSyncBase {
453
464
  }),
454
465
  );
455
466
 
456
- const url = `${this.normalizedRealmUrl}_atomic`;
467
+ const url = this.options.waitForIndex
468
+ ? `${this.normalizedRealmUrl}_atomic?waitForIndex=true`
469
+ : `${this.normalizedRealmUrl}_atomic`;
457
470
  const response = await this.authenticator.authedRealmFetch(url, {
458
471
  method: 'POST',
459
472
  headers: {
@@ -567,13 +580,45 @@ export abstract class RealmSyncBase {
567
580
  }
568
581
 
569
582
  const url = this.buildFileUrl(relativePath);
583
+ const startedAt = Date.now();
570
584
 
571
- const response = await this.authenticator.authedRealmFetch(url, {
572
- method: 'DELETE',
573
- headers: {
574
- Accept: SupportedMimeType.CardSource,
575
- },
576
- });
585
+ let response: Response;
586
+ try {
587
+ response = await this.authenticator.authedRealmFetch(url, {
588
+ method: 'DELETE',
589
+ headers: {
590
+ Accept: SupportedMimeType.CardSource,
591
+ },
592
+ signal: AbortSignal.timeout(DELETE_TIMEOUT_MS),
593
+ });
594
+ } catch (error) {
595
+ let elapsedMs = Date.now() - startedAt;
596
+ console.error(
597
+ ` Delete request failed after ${elapsedMs}ms: ${relativePath}`,
598
+ );
599
+ if (
600
+ error instanceof Error &&
601
+ (error.name === 'TimeoutError' || error.name === 'AbortError')
602
+ ) {
603
+ let deleteApplied = await this.verifyDeleteApplied(relativePath);
604
+ if (deleteApplied === true) {
605
+ console.warn(
606
+ ` Delete response timed out after ${DELETE_TIMEOUT_MS}ms, but ${relativePath} is already gone on the realm; continuing`,
607
+ );
608
+ return;
609
+ }
610
+ throw new Error(
611
+ `Timed out deleting ${relativePath} after ${DELETE_TIMEOUT_MS}ms`,
612
+ { cause: error },
613
+ );
614
+ }
615
+ throw error;
616
+ }
617
+
618
+ let elapsedMs = Date.now() - startedAt;
619
+ console.log(
620
+ ` Delete response for ${relativePath}: ${response.status} ${response.statusText} (${elapsedMs}ms)`,
621
+ );
577
622
 
578
623
  if (!response.ok && response.status !== 404) {
579
624
  throw new Error(
@@ -584,6 +629,30 @@ export abstract class RealmSyncBase {
584
629
  console.log(` Deleted: ${relativePath}`);
585
630
  }
586
631
 
632
+ private async verifyDeleteApplied(
633
+ relativePath: string,
634
+ ): Promise<boolean | 'unknown'> {
635
+ const url = this.buildFileUrl(relativePath);
636
+ try {
637
+ const response = await this.authenticator.authedRealmFetch(url, {
638
+ headers: {
639
+ Accept: SupportedMimeType.CardSource,
640
+ },
641
+ signal: AbortSignal.timeout(DELETE_TIMEOUT_PROBE_MS),
642
+ });
643
+ console.warn(
644
+ ` Delete-timeout probe for ${relativePath}: ${response.status} ${response.statusText}`,
645
+ );
646
+ return response.status === 404 ? true : false;
647
+ } catch (probeError) {
648
+ console.warn(
649
+ ` Delete-timeout probe failed for ${relativePath}:`,
650
+ probeError,
651
+ );
652
+ return 'unknown';
653
+ }
654
+ }
655
+
587
656
  protected async deleteLocalFile(localPath: string): Promise<void> {
588
657
  console.log(`Deleting local: ${localPath}`);
589
658
 
package/bin/boxel.js DELETED
@@ -1,15 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- const path = require('path');
4
- const fs = require('fs');
5
-
6
- // Use the built dist version if available, otherwise fall back to ts-node
7
- const distEntry = path.resolve(__dirname, '..', 'dist', 'index.js');
8
-
9
- if (fs.existsSync(distEntry)) {
10
- require(distEntry);
11
- } else {
12
- // Development fallback: run from TypeScript source via ts-node
13
- require('ts-node').register({ transpileOnly: true });
14
- require('../src/index.ts');
15
- }