antigravity-sidecar-kit 1.0.1 → 1.0.3

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/README.md CHANGED
@@ -13,13 +13,14 @@ Google Antigravity's built-in `sidecar_sdk` provides a minimal `SidecarApp` loop
13
13
  Generate a complete, Hangar-compatible Antigravity Auxiliary Pane sidecar in one command:
14
14
 
15
15
  ```bash
16
- # Using npx create-* shorthand
17
- npx create-antigravity-sidecar my-sidecar
18
-
19
- # Or via the CLI binary directly
20
- npx agy-sidecar-kit init my-sidecar
16
+ npx antigravity-sidecar-kit init my-sidecar
17
+ cd antigravity-my-sidecar
18
+ npm install
19
+ npm run setup
21
20
  ```
22
21
 
22
+ Options: `--dir <target-dir>` to choose the output folder, `--force` to overwrite an existing scaffold.
23
+
23
24
  What gets generated:
24
25
  - `package.json` — ESM configuration with `setup`, `start`, `uninstall`, and `test` scripts.
25
26
  - `sidecar.json` — Antigravity `SidecarManager` manifest (`SIDECAR_UI_ENTRYPOINT_AUX_PANE`).
@@ -87,8 +88,8 @@ await manageSidecar('my-sidecar', 'SIDECAR_ACTION_RESTART');
87
88
  const customRes = await callLsRpc('GetMcpServerStates', {});
88
89
  ```
89
90
 
90
- - **Automatic Credential Persistence**: When imported inside a running sidecar daemon (`ANTIGRAVITY_SIDECAR_WEB_PORT` + `ANTIGRAVITY_LS_ADDRESS`), `persistSidecarRuntime` caches `{ pid, webPort, lsAddress, csrfToken }` to `~/.gemini/antigravity/sidecar_data/<id>/data/runtime.json` so CLI tools (`setup.mjs`) invoked from external terminals can talk to the Language Server even when env vars aren't exported in the user's shell.
91
- - **Fallback Process Restarter (`killSidecarProcessByPortOrPid`)**: If the Language Server RPC is unreachable from an external terminal, inspects `runtime.json` PID first and falls back to parsing `listening on 127.0.0.1:<port>` from `logs/sidecar.log`, sending `SIGTERM` so Antigravity's `"restart_policy": "always"` respawns the updated sidecar immediately.
91
+ - **Automatic Credential Persistence**: When imported inside a running sidecar daemon (`ANTIGRAVITY_SIDECAR_WEB_PORT` + `ANTIGRAVITY_LS_ADDRESS`), `persistSidecarRuntime` caches `{ pid, webPort, lsAddress, csrfToken }` to `~/.gemini/antigravity/sidecar_data/<id>/data/runtime.json` (owner-only, mode `0600` on macOS/Linux) so CLI tools (`setup.mjs`) invoked from external terminals can talk to the Language Server even when env vars aren't exported in the user's shell.
92
+ - **Fallback Process Restarter (`killSidecarProcessByPortOrPid`)**: If the Language Server RPC is unreachable from an external terminal, sends `SIGTERM` to the sidecar so Antigravity's `"restart_policy": "always"` respawns the updated version. It only kills a process it can verify: the `runtime.json` PID must still own the recorded `webPort`, and when the port only comes from `logs/sidecar.log`, only `node` processes listening on it are stopped. A stale `runtime.json` is ignored.
92
93
 
93
94
  ---
94
95
 
@@ -154,13 +155,18 @@ await runSidecarSetup({
154
155
  });
155
156
  ```
156
157
 
158
+ Command-line flags read from `args` (default `process.argv`): `--skip-tests`, `--uninstall`, and `--force`.
159
+
157
160
  What `runSidecarSetup` handles automatically:
158
161
  1. Verifies Node.js `>= 20.0.0` and all `requiredFiles`.
159
- 2. Runs `testFiles` via `node --test` unless `--skip-tests` is passed.
160
- 3. **Ephemeral `_npx` Copy & SDK Bundling (`resolvePermanentLinkSource`)**: When executed via `npx`, copies the package out of the temporary `_npx` cache into `~/.gemini/hangar/packages/<id>@<version>` and guarantees `node_modules/antigravity-sidecar-kit` is present inside the permanent copy whether npm nested or hoisted the dependency.
161
- 4. Links `~/.gemini/config/sidecars/<id>` using an NTFS directory junction on Windows or symlink on macOS/Linux.
162
- 5. Enables `sidecars[id].enabled = true` in `~/.gemini/config/config.json` (preserving `projectId`).
163
- 6. Hot-reloads the sidecar via `ManageSidecar` RPC (`SIDECAR_ACTION_RESTART` $\rightarrow$ `SIDECAR_ACTION_ENABLE`) or `killSidecarProcessByPortOrPid` fallback.
162
+ 2. Refuses to continue if `~/.gemini/config/sidecars/<id>` is a real directory (an in-place checkout) rather than a link, unless setup is running from inside that directory or `--force` is passed. Nothing is run, copied, or deleted before this check.
163
+ 3. Runs `testFiles` via `node --test` unless `--skip-tests` is passed.
164
+ 4. **Ephemeral `_npx` Copy & SDK Bundling (`resolvePermanentLinkSource`)**: When executed via `npx`, copies the package out of the temporary `_npx` cache into `~/.gemini/hangar/packages/<id>@<version>` and guarantees `node_modules/antigravity-sidecar-kit` is present inside the permanent copy whether npm nested or hoisted the dependency. **Only `antigravity-sidecar-kit` is carried over**: sidecars installed this way must have no other runtime dependencies, and setup warns if `package.json` lists any.
165
+ 5. Links `~/.gemini/config/sidecars/<id>` using an NTFS directory junction on Windows or symlink on macOS/Linux.
166
+ 6. Enables `sidecars[id].enabled = true` in `~/.gemini/config/config.json` (preserving `projectId`, written atomically via temp file + rename).
167
+ 7. Hot-reloads the sidecar via `ManageSidecar` RPC (`SIDECAR_ACTION_RESTART` $\rightarrow$ `SIDECAR_ACTION_ENABLE`) or `killSidecarProcessByPortOrPid` fallback.
168
+
169
+ `--uninstall` removes the link (never a real directory unless `--force` is passed), disables the sidecar in `config.json`, and prunes `~/.gemini/hangar/packages/<id>@*` copies left by earlier `npx` installs.
164
170
 
165
171
  ---
166
172
 
@@ -177,6 +183,7 @@ import {
177
183
  } from 'antigravity-sidecar-kit/workspace';
178
184
 
179
185
  normalizeWorkspacePath('file:///c%3A/Projects/App/'); // -> 'c:/Projects/App'
186
+ normalizeWorkspacePath('file:///home/dev/app'); // -> '/home/dev/app'
180
187
  matchesWorkspaceRule('c:/Projects/App/packages/api', ['c:/Projects/App']); // -> true
181
188
  ```
182
189
 
@@ -187,6 +194,7 @@ matchesWorkspaceRule('c:/Projects/App/packages/api', ['c:/Projects/App']); // ->
187
194
  ```text
188
195
  antigravity-sidecar-kit/
189
196
  ├── package.json
197
+ ├── .github/workflows/test.yml # CI: Linux, macOS, Windows × Node 20/22/24
190
198
  ├── bin/
191
199
  │ └── cli.mjs # CLI scaffolder (agy-sidecar-kit / create-antigravity-sidecar)
192
200
  ├── lib/
@@ -198,7 +206,10 @@ antigravity-sidecar-kit/
198
206
  │ ├── server.mjs # SidecarApp asset & stylesheet route factories
199
207
  │ ├── workspace.mjs # Workspace URI normalizer, glob matcher & trajectory resolver
200
208
  │ └── scaffolder.mjs # Project template generator
209
+ ├── packages/
210
+ │ └── create-antigravity-sidecar/ # `npm create antigravity-sidecar` launcher (published separately)
201
211
  └── test/
212
+ ├── cli.test.mjs
202
213
  ├── config.test.mjs
203
214
  ├── rpc.test.mjs
204
215
  ├── theme.test.mjs
@@ -208,6 +219,23 @@ antigravity-sidecar-kit/
208
219
 
209
220
  ---
210
221
 
222
+ ## Changes in 1.0.3
223
+
224
+ - The CLI is exported as `antigravity-sidecar-kit/cli`, so launcher packages such as `create-antigravity-sidecar` can run it.
225
+
226
+ ## Changes in 1.0.2
227
+
228
+ - `npx antigravity-sidecar-kit init <name>` now works: the package exposes a bin with its own name. The previous README commands pointed at unpublished package names.
229
+ - Setup no longer deletes an in-place checkout at `~/.gemini/config/sidecars/<id>`; it refuses unless `--force` is passed.
230
+ - The restart fallback no longer kills a process based on a stale `runtime.json` PID.
231
+ - The UI token is no longer appended to protocol-relative (`//host`) URLs, and is inserted before `#fragment`s.
232
+ - `file:///home/...` URIs normalize to absolute POSIX paths.
233
+ - `runtime.json` is written owner-only; `config.json` writes are atomic.
234
+ - Uninstall prunes old `npx` copies; setup warns about dependencies that the `npx` copy can't carry.
235
+ - Scaffolded projects pass `ResponseClass: Response` and depend on the current kit version.
236
+
237
+ ---
238
+
211
239
  ## License
212
240
 
213
241
  [MIT](./LICENSE)
package/bin/cli.mjs CHANGED
@@ -1,10 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * @fileoverview CLI entrypoint for create-antigravity-sidecar / agy-sidecar-kit.
3
+ * @fileoverview CLI entrypoint for antigravity-sidecar-kit / create-antigravity-sidecar / agy-sidecar-kit.
4
4
  */
5
5
 
6
+ import { isAbsolute, relative } from 'node:path';
6
7
  import { scaffoldSidecarProject } from '../lib/scaffolder.mjs';
7
8
 
9
+ const USAGE = 'Usage: npx antigravity-sidecar-kit init <sidecar-name> [--dir <target-dir>] [--force]';
10
+
8
11
  const rawArgs = process.argv.slice(2);
9
12
  let name = '';
10
13
  let targetDir = '';
@@ -26,14 +29,24 @@ for (let i = 0; i < rawArgs.length; i++) {
26
29
  }
27
30
  }
28
31
 
29
- if (!name || rawArgs.includes('--help') || rawArgs.includes('-h')) {
30
- console.log('Usage: agy-sidecar-kit init <sidecar-name> [--dir <target-dir>] [--force]');
31
- process.exit(name ? 0 : 1);
32
+ if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
33
+ console.log(USAGE);
34
+ process.exit(0);
35
+ }
36
+ if (!name) {
37
+ console.error(USAGE);
38
+ process.exit(1);
32
39
  }
33
40
 
34
41
  try {
35
42
  const res = scaffoldSidecarProject({ name, targetDir, force });
43
+ const rel = relative(process.cwd(), res.outDir);
44
+ const cdPath = !rel ? '.' : rel.startsWith('..') || isAbsolute(rel) ? res.outDir : rel;
36
45
  console.log(`[antigravity-sidecar-kit] Scaffolded ${res.pkgName} at ${res.outDir}`);
46
+ console.log('\nNext steps:');
47
+ console.log(` cd ${cdPath.includes(' ') ? `"${cdPath}"` : cdPath}`);
48
+ console.log(' npm install');
49
+ console.log(' npm run setup');
37
50
  } catch (err) {
38
51
  console.error(`[antigravity-sidecar-kit] Error: ${err.message}`);
39
52
  process.exit(1);
package/lib/config.mjs CHANGED
@@ -2,7 +2,15 @@
2
2
  * @fileoverview OS-aware Antigravity paths and UTF-8 BOM-safe JSON config helpers.
3
3
  */
4
4
 
5
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import {
6
+ existsSync,
7
+ mkdirSync,
8
+ readFileSync,
9
+ realpathSync,
10
+ renameSync,
11
+ rmSync,
12
+ writeFileSync,
13
+ } from 'node:fs';
6
14
  import { homedir } from 'node:os';
7
15
  import { dirname, join } from 'node:path';
8
16
 
@@ -47,9 +55,39 @@ export function readJsonFile(filePath, { strict = false, fallback = {} } = {}) {
47
55
  return res && typeof res === 'object' ? res : structuredClone(fallback);
48
56
  }
49
57
 
58
+ const RENAME_RETRY_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']);
59
+
60
+ /**
61
+ * Writes JSON without a BOM via temp file + rename, so readers (including Antigravity
62
+ * itself for config.json) never observe a truncated or half-written file.
63
+ */
50
64
  export function writeJsonNoBom(filePath, data) {
51
- mkdirSync(dirname(filePath), { recursive: true });
52
- writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
65
+ let target = filePath;
66
+ try {
67
+ // Write through symlinked files instead of replacing the link
68
+ target = realpathSync(filePath);
69
+ } catch {
70
+ // does not exist yet
71
+ }
72
+ mkdirSync(dirname(target), { recursive: true });
73
+ const content = JSON.stringify(data, null, 2) + '\n';
74
+ const tmpPath = `${target}.${process.pid}.tmp`;
75
+ writeFileSync(tmpPath, content, 'utf8');
76
+ for (let attempt = 0; ; attempt++) {
77
+ try {
78
+ renameSync(tmpPath, target);
79
+ return;
80
+ } catch (err) {
81
+ if (!RENAME_RETRY_CODES.has(err?.code) || attempt >= 3) break;
82
+ // Windows: another process may briefly hold the file open
83
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
84
+ }
85
+ }
86
+ try {
87
+ writeFileSync(target, content, 'utf8');
88
+ } finally {
89
+ rmSync(tmpPath, { force: true });
90
+ }
53
91
  }
54
92
 
55
93
  export const writeJsonFile = writeJsonNoBom;
package/lib/rpc.mjs CHANGED
@@ -4,11 +4,12 @@
4
4
  */
5
5
 
6
6
  import { execFileSync } from 'node:child_process';
7
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
8
8
  import { basename, dirname, join } from 'node:path';
9
9
  import { SIDECAR_DATA_ROOT, readJsonSafe } from './config.mjs';
10
10
 
11
11
  const DEFAULT_TIMEOUT_MS = 4000;
12
+ const EXEC_OPTS = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] };
12
13
 
13
14
  function inferCurrentSidecarId() {
14
15
  if (process.env.ANTIGRAVITY_SIDECAR_ID) return String(process.env.ANTIGRAVITY_SIDECAR_ID).trim();
@@ -42,17 +43,25 @@ export function persistSidecarRuntime(
42
43
  }
43
44
  try {
44
45
  const dir = join(sidecarDataRoot, sidecarId, 'data');
45
- mkdirSync(dir, { recursive: true });
46
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
47
+ const runtimePath = join(dir, 'runtime.json');
48
+ // runtime.json holds the Language Server CSRF token, so keep it owner-only
46
49
  writeFileSync(
47
- join(dir, 'runtime.json'),
50
+ runtimePath,
48
51
  JSON.stringify({
49
52
  pid: process.pid,
50
53
  webPort: Number(env.ANTIGRAVITY_SIDECAR_WEB_PORT) || 0,
51
54
  lsAddress: env.ANTIGRAVITY_LS_ADDRESS,
52
55
  csrfToken: env.ANTIGRAVITY_CSRF_TOKEN || '',
53
56
  }) + '\n',
54
- 'utf8'
57
+ { encoding: 'utf8', mode: 0o600 }
55
58
  );
59
+ try {
60
+ // mode only applies on creation; tighten files written by older versions
61
+ chmodSync(runtimePath, 0o600);
62
+ } catch {
63
+ // ignore (e.g. Windows)
64
+ }
56
65
  return true;
57
66
  } catch {
58
67
  return false;
@@ -61,7 +70,11 @@ export function persistSidecarRuntime(
61
70
 
62
71
  persistSidecarRuntime();
63
72
 
64
- export function readCachedLsCredentials(sidecarDataRoot = SIDECAR_DATA_ROOT, preferredId = '') {
73
+ export function readCachedLsCredentials(
74
+ sidecarDataRoot = SIDECAR_DATA_ROOT,
75
+ preferredId = '',
76
+ { lsAddress: matchAddress = '' } = {}
77
+ ) {
65
78
  const candidates = [preferredId, 'hangar', 'context-monitor'].filter(Boolean);
66
79
  try {
67
80
  if (existsSync(sidecarDataRoot)) {
@@ -78,6 +91,7 @@ export function readCachedLsCredentials(sidecarDataRoot = SIDECAR_DATA_ROOT, pre
78
91
  if (!existsSync(p)) continue;
79
92
  const data = JSON.parse(readFileSync(p, 'utf8'));
80
93
  if (data?.lsAddress && data?.csrfToken) {
94
+ if (matchAddress && String(data.lsAddress) !== matchAddress) continue;
81
95
  return { lsAddress: String(data.lsAddress), csrfToken: String(data.csrfToken) };
82
96
  }
83
97
  } catch {
@@ -92,35 +106,71 @@ export function getLsConfig(overrides = {}) {
92
106
  let lsAddress = overrides.lsAddress ?? env.ANTIGRAVITY_LS_ADDRESS ?? '';
93
107
  let csrfToken = overrides.csrfToken ?? env.ANTIGRAVITY_CSRF_TOKEN ?? '';
94
108
  if ((!lsAddress || !csrfToken) && overrides.lsAddress === undefined) {
95
- const cached = readCachedLsCredentials(overrides.sidecarDataRoot, overrides.sidecarId);
109
+ // With a known address, only accept a token cached for that same Language Server
110
+ const cached = readCachedLsCredentials(overrides.sidecarDataRoot, overrides.sidecarId, {
111
+ lsAddress,
112
+ });
96
113
  if (!lsAddress) lsAddress = cached.lsAddress;
97
114
  if (!csrfToken) csrfToken = cached.csrfToken;
98
115
  }
99
116
  return { lsAddress, csrfToken };
100
117
  }
101
118
 
119
+ export function findListeningPids(port, { execFn = execFileSync } = {}) {
120
+ const p = Number(port);
121
+ if (!Number.isInteger(p) || p <= 0) return [];
122
+ try {
123
+ const rawOut =
124
+ process.platform === 'win32'
125
+ ? execFn(
126
+ 'powershell.exe',
127
+ [
128
+ '-NoProfile',
129
+ '-Command',
130
+ `(Get-NetTCPConnection -LocalPort ${p} -State Listen -ErrorAction SilentlyContinue).OwningProcess`,
131
+ ],
132
+ EXEC_OPTS
133
+ )
134
+ : execFn('lsof', ['-ti', `tcp:${p}`, '-sTCP:LISTEN'], EXEC_OPTS);
135
+ const pids = String(rawOut || '')
136
+ .split(/\r?\n/)
137
+ .map((s) => Number.parseInt(s.trim(), 10))
138
+ .filter((n) => Number.isFinite(n) && n > 0 && n !== process.pid);
139
+ return [...new Set(pids)];
140
+ } catch {
141
+ return [];
142
+ }
143
+ }
144
+
145
+ export function isNodeProcess(pid, { execFn = execFileSync } = {}) {
146
+ const n = Number(pid);
147
+ if (!Number.isInteger(n) || n <= 0) return false;
148
+ try {
149
+ const rawOut =
150
+ process.platform === 'win32'
151
+ ? execFn(
152
+ 'powershell.exe',
153
+ ['-NoProfile', '-Command', `(Get-Process -Id ${n} -ErrorAction SilentlyContinue).ProcessName`],
154
+ EXEC_OPTS
155
+ )
156
+ : execFn('ps', ['-p', String(n), '-o', 'comm='], EXEC_OPTS);
157
+ return /(^|[\\/])node(\.exe)?$/i.test(String(rawOut || '').trim());
158
+ } catch {
159
+ return false;
160
+ }
161
+ }
162
+
102
163
  export function killSidecarProcessByPortOrPid(
103
164
  sidecarId,
104
165
  { sidecarDataRoot = SIDECAR_DATA_ROOT, execFn = execFileSync, killFn = process.kill } = {}
105
166
  ) {
106
167
  if (!sidecarId) return { restarted: false };
168
+ let runtimePid = 0;
107
169
  let port = 0;
108
- try {
109
- const rtPath = join(sidecarDataRoot, sidecarId, 'data', 'runtime.json');
110
- if (existsSync(rtPath)) {
111
- const rt = JSON.parse(readFileSync(rtPath, 'utf8'));
112
- if (rt?.pid && Number(rt.pid) > 0 && Number(rt.pid) !== process.pid) {
113
- try {
114
- killFn(Number(rt.pid), 'SIGTERM');
115
- return { restarted: true, method: `pid:${rt.pid}` };
116
- } catch {
117
- // Process already exited; fall through to log port check
118
- }
119
- }
120
- if (rt?.webPort) port = Number(rt.webPort);
121
- }
122
- } catch {
123
- // ignore
170
+ const rt = readJsonSafe(join(sidecarDataRoot, sidecarId, 'data', 'runtime.json'), null);
171
+ if (rt && typeof rt === 'object') {
172
+ if (Number(rt.pid) > 0) runtimePid = Number(rt.pid);
173
+ if (Number(rt.webPort) > 0) port = Number(rt.webPort);
124
174
  }
125
175
 
126
176
  if (!port) {
@@ -138,40 +188,34 @@ export function killSidecarProcessByPortOrPid(
138
188
  }
139
189
  }
140
190
 
191
+ // Never kill on a PID alone: runtime.json outlives the sidecar, and the OS may have
192
+ // reused that PID for an unrelated process (Windows recycles PIDs aggressively).
141
193
  if (!port) return { restarted: false };
194
+ const owners = findListeningPids(port, { execFn });
142
195
 
143
- try {
144
- const rawOut =
145
- process.platform === 'win32'
146
- ? execFn(
147
- 'powershell.exe',
148
- [
149
- '-NoProfile',
150
- '-Command',
151
- `(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue).OwningProcess`,
152
- ],
153
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
154
- )
155
- : execFn('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN'], {
156
- encoding: 'utf8',
157
- stdio: ['ignore', 'pipe', 'ignore'],
158
- });
159
- const pids = String(rawOut || '')
160
- .split(/\r?\n/)
161
- .map((s) => Number.parseInt(s.trim(), 10))
162
- .filter((n) => Number.isFinite(n) && n > 0 && n !== process.pid);
163
- if (pids.length > 0) {
164
- for (const pid of pids) {
165
- try {
166
- killFn(pid, 'SIGTERM');
167
- } catch {
168
- // ignore
169
- }
170
- }
171
- return { restarted: true, method: `port:${port}->pid:${pids.join(',')}` };
196
+ if (runtimePid) {
197
+ if (!owners.includes(runtimePid)) return { restarted: false, reason: 'stale runtime.json' };
198
+ try {
199
+ killFn(runtimePid, 'SIGTERM');
200
+ return { restarted: true, method: `pid:${runtimePid}` };
201
+ } catch {
202
+ return { restarted: false };
172
203
  }
173
- } catch {
174
- // ignore
204
+ }
205
+
206
+ // Port recovered from the log only: kill just the node processes listening on it
207
+ const killed = [];
208
+ for (const pid of owners) {
209
+ if (!isNodeProcess(pid, { execFn })) continue;
210
+ try {
211
+ killFn(pid, 'SIGTERM');
212
+ killed.push(pid);
213
+ } catch {
214
+ // ignore
215
+ }
216
+ }
217
+ if (killed.length > 0) {
218
+ return { restarted: true, method: `port:${port}->pid:${killed.join(',')}` };
175
219
  }
176
220
  return { restarted: false };
177
221
  }
@@ -2,10 +2,12 @@
2
2
  * @fileoverview Scaffolds a minimal, Hangar-compatible Antigravity sidecar project.
3
3
  */
4
4
 
5
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
6
  import { join, resolve } from 'node:path';
7
7
  import { writeJsonNoBom } from './config.mjs';
8
8
 
9
+ const KIT_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
10
+
9
11
  export function sanitizeSidecarName(raw = '') {
10
12
  return String(raw || '')
11
13
  .trim()
@@ -51,7 +53,7 @@ export function scaffoldSidecarProject({ name, targetDir, force = false } = {})
51
53
  },
52
54
  engines: { node: '>=20.0.0' },
53
55
  dependencies: {
54
- 'antigravity-sidecar-kit': '^1.0.1',
56
+ 'antigravity-sidecar-kit': `^${KIT_VERSION}`,
55
57
  },
56
58
  scripts: {
57
59
  start: 'node server.mjs',
@@ -122,7 +124,7 @@ runSidecarSetup({
122
124
 
123
125
  writeFileSync(
124
126
  join(outDir, 'server.mjs'),
125
- `import { SidecarApp } from 'sidecar_sdk';
127
+ `import { SidecarApp, Response } from 'sidecar_sdk';
126
128
  import { realpathSync } from 'node:fs';
127
129
  import { dirname, join } from 'node:path';
128
130
  import { fileURLToPath } from 'node:url';
@@ -132,12 +134,16 @@ import { resolveHostTheme } from 'antigravity-sidecar-kit/theme';
132
134
  const __dirname = realpathSync(dirname(fileURLToPath(import.meta.url)));
133
135
  const UI_DIR = join(__dirname, 'ui');
134
136
 
137
+ // Pass the SDK's Response class so SidecarApp's instanceof checks recognise handler results
138
+ const serveAsset = (relPath, contentType) =>
139
+ createAssetHandler(UI_DIR, relPath, contentType, { ResponseClass: Response });
140
+
135
141
  const app = new SidecarApp();
136
- app.page('/', createAssetHandler(UI_DIR, 'index.html', 'text/html; charset=utf-8'));
137
- app.page('/index.html', createAssetHandler(UI_DIR, 'index.html', 'text/html; charset=utf-8'));
138
- app.page('/compiled_tailwind.css', createAgyCssHandler());
139
- app.page('/styles.css', createAssetHandler(UI_DIR, 'styles.css', 'text/css; charset=utf-8'));
140
- app.page('/app.js', createAssetHandler(UI_DIR, 'app.js', 'text/javascript; charset=utf-8'));
142
+ app.page('/', serveAsset('index.html', 'text/html; charset=utf-8'));
143
+ app.page('/index.html', serveAsset('index.html', 'text/html; charset=utf-8'));
144
+ app.page('/compiled_tailwind.css', createAgyCssHandler({ ResponseClass: Response }));
145
+ app.page('/styles.css', serveAsset('styles.css', 'text/css; charset=utf-8'));
146
+ app.page('/app.js', serveAsset('app.js', 'text/javascript; charset=utf-8'));
141
147
 
142
148
  app.api('/api/status', async () => ({
143
149
  ok: true,
package/lib/server.mjs CHANGED
@@ -19,23 +19,35 @@ let SdkResponse = FallbackResponse;
19
19
  try {
20
20
  const sdk = await import('sidecar_sdk');
21
21
  if (sdk?.Response) SdkResponse = sdk.Response;
22
- } catch {
23
- // Running outside Antigravity SidecarManager (e.g. standalone unit tests)
22
+ } catch (err) {
23
+ // Expected outside Antigravity SidecarManager (e.g. standalone unit tests); inside a
24
+ // sidecar it means handlers will return FallbackResponse, which SidecarApp won't recognise.
25
+ if (process.env.ANTIGRAVITY_SIDECAR_WEB_PORT) {
26
+ console.warn(
27
+ `[antigravity-sidecar-kit] Could not import sidecar_sdk (${err?.message || err}); ` +
28
+ 'pass { ResponseClass: Response } to createAssetHandler/createAgyCssHandler.'
29
+ );
30
+ }
24
31
  }
25
32
 
26
33
  const DEFAULT_BOOT_TS = Date.now();
27
34
 
35
+ // Same-origin absolute paths only: skips protocol-relative //host URLs and paths that
36
+ // already carry a query string, and keeps any #fragment after the injected query.
37
+ const LOCAL_ASSET_ATTR = /(\s(?:src|href))="(\/(?!\/)[^"?#]*)(#[^"]*)?"/g;
38
+
28
39
  export function createAssetHandler(uiDir, relPath, contentType, options = {}) {
29
40
  const ResponseClass = options.ResponseClass || SdkResponse;
30
41
  const bootTs = options.bootTs ?? DEFAULT_BOOT_TS;
31
42
  return () => {
32
43
  const uiToken = options.uiToken ?? process.env.ANTIGRAVITY_SIDECAR_UI_TOKEN ?? '';
33
44
  const raw = readFileSync(join(uiDir, relPath), 'utf-8');
45
+ const query = `?token=${encodeURIComponent(uiToken)}&v=${bootTs}`;
34
46
  const body =
35
47
  relPath.endsWith('.html') && uiToken
36
48
  ? raw.replace(
37
- /(src|href)="(\/[^"?]+)"/g,
38
- `$1="$2?token=${encodeURIComponent(uiToken)}&v=${bootTs}"`
49
+ LOCAL_ASSET_ATTR,
50
+ (_, attr, path, fragment = '') => `${attr}="${path}${query}${fragment}"`
39
51
  )
40
52
  : raw;
41
53
  return new ResponseClass(body, { contentType });
package/lib/setup.mjs CHANGED
@@ -9,12 +9,13 @@ import {
9
9
  existsSync,
10
10
  lstatSync,
11
11
  mkdirSync,
12
+ readdirSync,
12
13
  realpathSync,
13
14
  rmSync,
14
15
  symlinkSync,
15
16
  unlinkSync,
16
17
  } from 'node:fs';
17
- import { dirname, isAbsolute, join, resolve } from 'node:path';
18
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
18
19
  import { fileURLToPath } from 'node:url';
19
20
  import {
20
21
  CONFIG_JSON_PATH,
@@ -47,7 +48,31 @@ export function removeLinkOrDir(targetPath) {
47
48
  return true;
48
49
  }
49
50
 
50
- export function createSidecarLink(sourceDir, linkPath) {
51
+ /**
52
+ * True when targetPath is a real directory rather than a symlink or junction
53
+ * (Windows junctions report isSymbolicLink() === true).
54
+ */
55
+ export function isRealDirectory(targetPath) {
56
+ try {
57
+ const stat = lstatSync(targetPath);
58
+ return stat.isDirectory() && !stat.isSymbolicLink();
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ function assertNotInPlaceCheckout(linkPath, sourceRealPath, force) {
65
+ if (force || !isRealDirectory(linkPath)) return;
66
+ if (realpathSync(linkPath) === sourceRealPath) return;
67
+ const err = new Error(
68
+ `Refusing to replace ${linkPath}: it is a real directory (an in-place checkout?), not a link. ` +
69
+ 'Run setup from inside that directory, move it away, or re-run with --force to replace it.'
70
+ );
71
+ err.code = 'EINPLACECHECKOUT';
72
+ throw err;
73
+ }
74
+
75
+ export function createSidecarLink(sourceDir, linkPath, { force = false } = {}) {
51
76
  mkdirSync(resolve(linkPath, '..'), { recursive: true });
52
77
  const resolvedSource = realpathSync(resolve(sourceDir));
53
78
  let alreadyLinked = false;
@@ -59,19 +84,38 @@ export function createSidecarLink(sourceDir, linkPath) {
59
84
  }
60
85
  }
61
86
  if (!alreadyLinked) {
87
+ assertNotInPlaceCheckout(linkPath, resolvedSource, force);
62
88
  removeLinkOrDir(linkPath);
63
89
  symlinkSync(resolvedSource, linkPath, process.platform === 'win32' ? 'junction' : 'dir');
64
90
  }
65
91
  return resolvedSource;
66
92
  }
67
93
 
94
+ /**
95
+ * Path of `src` relative to `root` as '' (the root itself) or '/a/b', for cpSync filters.
96
+ * Slicing by string length breaks when Windows hands the filter a differently-cased or
97
+ * short-name form of the root, so compare as paths and fall back to canonical real paths.
98
+ */
99
+ function pathFromRoot(root, src) {
100
+ let rel = relative(root, src);
101
+ if (rel.startsWith('..') || isAbsolute(rel)) {
102
+ try {
103
+ rel = relative(realpathSync.native(root), realpathSync.native(src));
104
+ } catch {
105
+ // keep the first answer
106
+ }
107
+ }
108
+ rel = rel.replace(/\\/g, '/');
109
+ return rel ? `/${rel}` : '';
110
+ }
111
+
68
112
  export function bundleSidecarKit(targetDir, kitRoot = KIT_ROOT) {
69
113
  if (!targetDir || !kitRoot || !existsSync(join(kitRoot, 'package.json'))) return false;
70
114
  const targetKitDir = join(targetDir, 'node_modules', 'antigravity-sidecar-kit');
71
115
  mkdirSync(join(targetDir, 'node_modules'), { recursive: true });
72
116
  cpSync(kitRoot, targetKitDir, {
73
117
  recursive: true,
74
- filter: (src) => !/^\/(\.git|node_modules)(\/|$)/.test(src.slice(kitRoot.length).replace(/\\/g, '/')),
118
+ filter: (src) => !/^\/(\.git|node_modules|test)(\/|$)/.test(pathFromRoot(kitRoot, src)),
75
119
  });
76
120
  return true;
77
121
  }
@@ -89,6 +133,15 @@ export function resolvePermanentLinkSource(
89
133
  return resolvedRoot;
90
134
  }
91
135
  const pkgJson = readJsonSafe(join(resolvedRoot, 'package.json'), {});
136
+ const droppedDeps = Object.keys(pkgJson.dependencies || {}).filter(
137
+ (dep) => dep !== 'antigravity-sidecar-kit'
138
+ );
139
+ if (droppedDeps.length > 0) {
140
+ console.warn(
141
+ `[antigravity-sidecar-kit] Warning: the permanent copy only bundles antigravity-sidecar-kit; ` +
142
+ `these dependencies will be missing at runtime: ${droppedDeps.join(', ')}`
143
+ );
144
+ }
92
145
  const cleanVer = String(version || pkgJson.version || '1.0.0').replace(/[^a-z0-9._-]+/gi, '') || '1.0.0';
93
146
  const id =
94
147
  sidecarId ||
@@ -102,7 +155,7 @@ export function resolvePermanentLinkSource(
102
155
  cpSync(resolvedRoot, destDir, {
103
156
  recursive: true,
104
157
  filter: (src) => {
105
- const rel = src.slice(resolvedRoot.length).replace(/\\/g, '/');
158
+ const rel = pathFromRoot(resolvedRoot, src);
106
159
  if (!rel) return true;
107
160
  if (/^\/\.git(\/|$)/.test(rel)) return false;
108
161
  if (/^\/node_modules(\/|$)/.test(rel)) {
@@ -170,20 +223,34 @@ export async function runSidecarSetup({
170
223
  const cliArgs = new Set(args);
171
224
  const isUninstall = cliArgs.has('--uninstall');
172
225
  const skipTests = cliArgs.has('--skip-tests');
226
+ const force = cliArgs.has('--force');
173
227
 
174
228
  console.log(`[${sidecarId}] Repository root: ${resolvedRepoRoot}`);
175
229
 
176
230
  if (isUninstall) {
177
- const isInPlaceCheckout =
178
- existsSync(junctionPath) &&
179
- realpathSync(junctionPath) === resolvedRepoRoot &&
180
- !lstatSync(junctionPath).isSymbolicLink();
181
- if (isInPlaceCheckout) {
231
+ if (isRealDirectory(junctionPath) && realpathSync(junctionPath) === resolvedRepoRoot) {
182
232
  console.log(`[${sidecarId}] Preserving in-place repository directory at ${junctionPath}.`);
233
+ } else if (isRealDirectory(junctionPath) && !force) {
234
+ console.log(
235
+ `[${sidecarId}] Not removing ${junctionPath}: it is a real directory, not a link (re-run with --force to delete it).`
236
+ );
183
237
  } else {
184
238
  removeLinkOrDir(junctionPath);
185
239
  }
186
240
 
241
+ // Prune permanent copies made by earlier npx installs, but never the directory we are running from
242
+ const entries = existsSync(packagesDir) ? readdirSync(packagesDir) : [];
243
+ for (const entry of entries) {
244
+ if (!entry.startsWith(`${sidecarId}@`)) continue;
245
+ const entryPath = join(packagesDir, entry);
246
+ try {
247
+ if (realpathSync(entryPath) === resolvedRepoRoot) continue;
248
+ } catch {
249
+ // dangling entry; safe to remove
250
+ }
251
+ removeLinkOrDir(entryPath);
252
+ }
253
+
187
254
  if (typeof onUninstall === 'function') {
188
255
  await onUninstall({
189
256
  sidecarId,
@@ -207,6 +274,9 @@ export async function runSidecarSetup({
207
274
  }
208
275
  }
209
276
 
277
+ // Fail before running hooks, tests, or the npx copy if we would have to delete a real checkout
278
+ assertNotInPlaceCheckout(junctionPath, resolvedRepoRoot, force);
279
+
210
280
  if (typeof onBeforeSetup === 'function') {
211
281
  await onBeforeSetup({
212
282
  sidecarId,
@@ -231,7 +301,7 @@ export async function runSidecarSetup({
231
301
  version,
232
302
  packagesDir,
233
303
  });
234
- createSidecarLink(targetSourceDir, junctionPath);
304
+ createSidecarLink(targetSourceDir, junctionPath, { force });
235
305
  console.log(`[${sidecarId}] Linked ${junctionPath} -> ${realpathSync(junctionPath)}`);
236
306
 
237
307
  updateAgySidecarConfig(sidecarId, true, configJsonPath, { createIfMissing: true });
@@ -246,7 +316,7 @@ export async function runSidecarSetup({
246
316
  );
247
317
  }
248
318
  if (!rpcRes.notified) {
249
- const killed = killSidecarProcessByPortOrPid(sidecarId, rpcOptions);
319
+ const killed = killSidecarProcessByPortOrPid(sidecarId, { execFn, ...rpcOptions });
250
320
  if (killed.restarted) {
251
321
  rpcRes = { notified: true, viaProcessRestart: killed.method };
252
322
  }
package/lib/workspace.mjs CHANGED
@@ -10,10 +10,19 @@ export function normalizeWorkspacePath(uriOrPath = '', { lowercase = false } = {
10
10
  } catch {
11
11
  // Keep original string if percent-decoding fails
12
12
  }
13
- clean = clean
14
- .replace(/^file:\/\/\/?/i, '')
15
- .replace(/\\/g, '/')
16
- .replace(/\/+$/, '');
13
+ clean = clean.replace(/\\/g, '/');
14
+ if (/^file:\/\//i.test(clean)) {
15
+ clean = clean.slice('file://'.length);
16
+ if (/^\/[A-Za-z]:/.test(clean)) {
17
+ // file:///c:/x -> c:/x
18
+ clean = clean.slice(1);
19
+ } else if (clean && !clean.startsWith('/')) {
20
+ // file://server/share -> //server/share (UNC)
21
+ clean = `//${clean}`;
22
+ }
23
+ // otherwise file:///home/x -> /home/x
24
+ }
25
+ clean = clean.replace(/(.)\/+$/, '$1');
17
26
 
18
27
  // Normalize Windows drive letter casing (e.g. C:/Projects -> c:/Projects)
19
28
  if (/^[A-Za-z]:/.test(clean)) {
@@ -33,7 +42,10 @@ function globToRegExp(pattern = '') {
33
42
  if (!normalizedPattern || normalizedPattern === '*') {
34
43
  return /^.*$/;
35
44
  }
36
- const escaped = normalizedPattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
45
+ const escaped = normalizedPattern
46
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
47
+ .replace(/\*/g, '.*')
48
+ .replace(/\?/g, '.');
37
49
  return new RegExp(`^${escaped}$`, 'i');
38
50
  }
39
51
 
@@ -55,7 +67,7 @@ export function matchesWorkspaceRule(activeWorkspacePath = '', patterns = []) {
55
67
  if (activeNorm === normPat || activeNorm.startsWith(`${normPat}/`)) {
56
68
  return true;
57
69
  }
58
- if (trimmed.includes('*') && globToRegExp(trimmed).test(activeNorm)) {
70
+ if (/[*?]/.test(trimmed) && globToRegExp(trimmed).test(activeNorm)) {
59
71
  return true;
60
72
  }
61
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antigravity-sidecar-kit",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "type": "module",
5
5
  "description": "Zero-dependency SDK and CLI scaffolder for Google Antigravity Auxiliary Pane sidecars (Connect RPC client, host theme & Tailwind proxy, BOM-safe config store, workspace URI matcher, and npx-safe installer).",
6
6
  "license": "MIT",
@@ -19,7 +19,6 @@
19
19
  "files": [
20
20
  "bin",
21
21
  "lib",
22
- "test",
23
22
  "README.md",
24
23
  "LICENSE"
25
24
  ],
@@ -30,11 +29,14 @@
30
29
  "./theme": "./lib/theme.mjs",
31
30
  "./setup": "./lib/setup.mjs",
32
31
  "./server": "./lib/server.mjs",
33
- "./workspace": "./lib/workspace.mjs"
32
+ "./workspace": "./lib/workspace.mjs",
33
+ "./scaffolder": "./lib/scaffolder.mjs",
34
+ "./cli": "./bin/cli.mjs"
34
35
  },
35
36
  "bin": {
36
- "create-antigravity-sidecar": "./bin/cli.mjs",
37
- "agy-sidecar-kit": "./bin/cli.mjs"
37
+ "antigravity-sidecar-kit": "bin/cli.mjs",
38
+ "create-antigravity-sidecar": "bin/cli.mjs",
39
+ "agy-sidecar-kit": "bin/cli.mjs"
38
40
  },
39
41
  "keywords": [
40
42
  "antigravity",
@@ -50,6 +52,7 @@
50
52
  "node": ">=20.0.0"
51
53
  },
52
54
  "scripts": {
53
- "test": "node --test test/*.test.mjs"
55
+ "test": "node --test",
56
+ "test:pack": "node scripts/pack-smoke.mjs && node scripts/pack-smoke-create.mjs"
54
57
  }
55
58
  }
@@ -1,66 +0,0 @@
1
- import { describe, it } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
6
- import {
7
- readJsonSafe,
8
- readJsonFile,
9
- writeJsonNoBom,
10
- updateAgySidecarConfig,
11
- } from '../lib/config.mjs';
12
-
13
- describe('antigravity-sidecar-kit/config BOM-safe JSON helpers and config.json updater', () => {
14
- it('strips UTF-8 BOM on read, writes without BOM, and supports strict mode', () => {
15
- const dir = mkdtempSync(join(tmpdir(), 'kit-config-test-'));
16
- try {
17
- const p = join(dir, 'bom.json');
18
- writeFileSync(p, '\uFEFF{"hello":"world"}\n', 'utf8');
19
- assert.deepEqual(readJsonSafe(p, {}), { hello: 'world' });
20
-
21
- const outPath = join(dir, 'nested', 'out.json');
22
- writeJsonNoBom(outPath, { a: 1 });
23
- const rawBytes = readFileSync(outPath);
24
- assert.notEqual(rawBytes[0], 0xef);
25
- assert.deepEqual(readJsonFile(outPath), { a: 1 });
26
-
27
- const badPath = join(dir, 'bad.json');
28
- writeFileSync(badPath, '{ truncated', 'utf8');
29
- assert.deepEqual(readJsonSafe(badPath, { fallback: true }), { fallback: true });
30
- assert.throws(() => readJsonFile(badPath, { strict: true }));
31
- } finally {
32
- rmSync(dir, { recursive: true, force: true });
33
- }
34
- });
35
-
36
- it('updateAgySidecarConfig preserves projectId and never clobbers malformed config.json', () => {
37
- const dir = mkdtempSync(join(tmpdir(), 'kit-agy-cfg-test-'));
38
- try {
39
- const cfgPath = join(dir, 'config.json');
40
- writeJsonNoBom(cfgPath, {
41
- userSettings: { themeMode: 'THEME_MODE_DARK' },
42
- sidecars: {
43
- hangar: {
44
- enabled: false,
45
- projectId: 'preserve-uuid-999',
46
- },
47
- },
48
- });
49
-
50
- const ok = updateAgySidecarConfig('hangar', true, cfgPath);
51
- assert.equal(ok, true);
52
- const updated = readJsonSafe(cfgPath, {});
53
- assert.equal(updated.sidecars.hangar.enabled, true);
54
- assert.equal(updated.sidecars.hangar.projectId, 'preserve-uuid-999');
55
-
56
- // Malformed config.json must never be overwritten
57
- const corruptContent = '{ "sidecars": { "hangar": ';
58
- writeFileSync(cfgPath, corruptContent, 'utf8');
59
- const okCorrupt = updateAgySidecarConfig('hangar', false, cfgPath);
60
- assert.equal(okCorrupt, false);
61
- assert.equal(readFileSync(cfgPath, 'utf8'), corruptContent);
62
- } finally {
63
- rmSync(dir, { recursive: true, force: true });
64
- }
65
- });
66
- });
package/test/rpc.test.mjs DELETED
@@ -1,124 +0,0 @@
1
- import { describe, it } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
6
- import {
7
- callLsRpc,
8
- getLsConfig,
9
- persistSidecarRuntime,
10
- killSidecarProcessByPortOrPid,
11
- getSidecars,
12
- manageSidecar,
13
- getAllTrajectories,
14
- getUserStatus,
15
- } from '../lib/rpc.mjs';
16
-
17
- describe('antigravity-sidecar-kit/rpc client, credential cache, and process restarter', () => {
18
- it('persists and reads runtime.json credentials when env vars are absent', () => {
19
- const dir = mkdtempSync(join(tmpdir(), 'kit-rpc-cache-'));
20
- try {
21
- const saved = persistSidecarRuntime('hangar', {
22
- sidecarDataRoot: dir,
23
- env: {
24
- ANTIGRAVITY_SIDECAR_WEB_PORT: '51234',
25
- ANTIGRAVITY_LS_ADDRESS: '127.0.0.1:59999',
26
- ANTIGRAVITY_CSRF_TOKEN: 'cached-token-abc',
27
- },
28
- });
29
- assert.equal(saved, true);
30
-
31
- const cfg = getLsConfig({ sidecarDataRoot: dir, env: {} });
32
- assert.equal(cfg.lsAddress, '127.0.0.1:59999');
33
- assert.equal(cfg.csrfToken, 'cached-token-abc');
34
- } finally {
35
- rmSync(dir, { recursive: true, force: true });
36
- }
37
- });
38
-
39
- it('killSidecarProcessByPortOrPid terminates by runtime.json PID first, then falls back to log port', () => {
40
- const dir = mkdtempSync(join(tmpdir(), 'kit-rpc-kill-'));
41
- try {
42
- const dataDir = join(dir, 'demo-sc', 'data');
43
- const logsDir = join(dir, 'demo-sc', 'logs');
44
- mkdirSync(dataDir, { recursive: true });
45
- mkdirSync(logsDir, { recursive: true });
46
- writeFileSync(join(dataDir, 'runtime.json'), JSON.stringify({ pid: 999999, webPort: 0 }), 'utf8');
47
-
48
- const killedPids = [];
49
- const res1 = killSidecarProcessByPortOrPid('demo-sc', {
50
- sidecarDataRoot: dir,
51
- killFn: (pid, sig) => killedPids.push({ pid, sig }),
52
- });
53
- assert.equal(res1.restarted, true);
54
- assert.equal(res1.method, 'pid:999999');
55
- assert.deepEqual(killedPids, [{ pid: 999999, sig: 'SIGTERM' }]);
56
-
57
- // Fallback when runtime.json PID is dead and port is parsed from sidecar.log
58
- writeFileSync(
59
- join(logsDir, 'sidecar.log'),
60
- '[sidecar] listening on 127.0.0.1:61111\n[sidecar] listening on 127.0.0.1:62222\n',
61
- 'utf8'
62
- );
63
- const portKilled = [];
64
- const res2 = killSidecarProcessByPortOrPid('demo-sc', {
65
- sidecarDataRoot: dir,
66
- killFn: (pid) => {
67
- if (pid === 999999) throw new Error('ESRCH');
68
- portKilled.push(pid);
69
- },
70
- execFn: () => '888888\n',
71
- });
72
- assert.equal(res2.restarted, true);
73
- assert.equal(res2.method, 'port:62222->pid:888888');
74
- assert.deepEqual(portKilled, [888888]);
75
- } finally {
76
- rmSync(dir, { recursive: true, force: true });
77
- }
78
- });
79
-
80
- it('callLsRpc and convenience wrappers send CSRF header and handle HTTP errors', async () => {
81
- const calls = [];
82
- const mockFetch = async (url, init) => {
83
- calls.push({ url, init });
84
- return {
85
- ok: true,
86
- status: 200,
87
- text: async () => JSON.stringify({ ok: true }),
88
- };
89
- };
90
- const opts = { lsAddress: '127.0.0.1:50000', csrfToken: 'tok-1', fetchFn: mockFetch };
91
-
92
- await getSidecars(opts);
93
- await manageSidecar('context-monitor', 'SIDECAR_ACTION_RESTART', opts);
94
- await getAllTrajectories(opts);
95
- await getUserStatus(opts);
96
-
97
- assert.equal(calls.length, 4);
98
- assert.ok(calls[0].url.endsWith('/GetSidecars'));
99
- assert.ok(calls[1].url.endsWith('/ManageSidecar'));
100
- assert.equal(calls[1].init.headers['x-codeium-csrf-token'], 'tok-1');
101
- assert.deepEqual(JSON.parse(calls[1].init.body), {
102
- action: 'SIDECAR_ACTION_RESTART',
103
- sidecarId: 'context-monitor',
104
- });
105
-
106
- await assert.rejects(
107
- () =>
108
- callLsRpc(
109
- 'GetSidecars',
110
- {},
111
- {
112
- lsAddress: '127.0.0.1:50000',
113
- csrfToken: 'tok-1',
114
- fetchFn: async () => ({
115
- ok: false,
116
- status: 500,
117
- text: async () => JSON.stringify({ message: 'internal failure' }),
118
- }),
119
- }
120
- ),
121
- /LanguageServerService\/GetSidecars failed: internal failure/
122
- );
123
- });
124
- });
@@ -1,143 +0,0 @@
1
- import { describe, it } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
6
- import { readJsonSafe, writeJsonNoBom } from '../lib/config.mjs';
7
- import { resolvePermanentLinkSource, runSidecarSetup } from '../lib/setup.mjs';
8
- import { createAssetHandler, createAgyCssHandler } from '../lib/server.mjs';
9
- import { scaffoldSidecarProject } from '../lib/scaffolder.mjs';
10
-
11
- describe('antigravity-sidecar-kit/setup, /server, and CLI scaffolder', () => {
12
- it('resolvePermanentLinkSource copies out of _npx and guarantees node_modules/antigravity-sidecar-kit in both nested and hoisted layouts', () => {
13
- const dir = mkdtempSync(join(tmpdir(), 'kit-npx-test-'));
14
- try {
15
- const packagesDir = join(dir, 'packages');
16
-
17
- // Case 1: Hoisted _npx layout (no nested node_modules inside repoRoot)
18
- const hoistedRepo = join(dir, '_npx', 'hash123', 'node_modules', 'antigravity-demo');
19
- mkdirSync(join(hoistedRepo, '.git'), { recursive: true });
20
- writeJsonNoBom(join(hoistedRepo, 'package.json'), {
21
- name: 'antigravity-demo',
22
- version: '1.2.3',
23
- });
24
- writeFileSync(join(hoistedRepo, 'server.mjs'), '// server', 'utf8');
25
- writeFileSync(join(hoistedRepo, '.git', 'HEAD'), 'ref: refs/heads/main', 'utf8');
26
-
27
- const copiedHoisted = resolvePermanentLinkSource(hoistedRepo, {
28
- sidecarId: 'demo',
29
- packagesDir,
30
- });
31
- assert.ok(copiedHoisted.endsWith('demo@1.2.3'));
32
- assert.equal(existsSync(join(copiedHoisted, '.git')), false);
33
- assert.equal(existsSync(join(copiedHoisted, 'server.mjs')), true);
34
- assert.equal(
35
- existsSync(join(copiedHoisted, 'node_modules', 'antigravity-sidecar-kit', 'package.json')),
36
- true,
37
- 'Should inject KIT_ROOT into destDir/node_modules/antigravity-sidecar-kit when hoisted'
38
- );
39
-
40
- // Case 2: Nested node_modules with both antigravity-sidecar-kit and an extraneous package
41
- const nestedRepo = join(dir, '_npx', 'hash456', 'node_modules', 'antigravity-nested');
42
- mkdirSync(join(nestedRepo, 'node_modules', 'antigravity-sidecar-kit', 'lib'), { recursive: true });
43
- mkdirSync(join(nestedRepo, 'node_modules', 'other-bloat-pkg'), { recursive: true });
44
- writeJsonNoBom(join(nestedRepo, 'package.json'), { name: 'antigravity-nested', version: '2.0.0' });
45
- writeJsonNoBom(join(nestedRepo, 'node_modules', 'antigravity-sidecar-kit', 'package.json'), {
46
- name: 'antigravity-sidecar-kit',
47
- version: '1.0.0',
48
- });
49
- writeFileSync(join(nestedRepo, 'node_modules', 'other-bloat-pkg', 'index.js'), '', 'utf8');
50
-
51
- const copiedNested = resolvePermanentLinkSource(nestedRepo, {
52
- sidecarId: 'nested',
53
- packagesDir,
54
- });
55
- assert.equal(
56
- existsSync(join(copiedNested, 'node_modules', 'antigravity-sidecar-kit', 'package.json')),
57
- true
58
- );
59
- assert.equal(existsSync(join(copiedNested, 'node_modules', 'other-bloat-pkg')), false);
60
- } finally {
61
- rmSync(dir, { recursive: true, force: true });
62
- }
63
- });
64
-
65
- it('runSidecarSetup links sidecar, preserves projectId on enable/uninstall, and scaffolds projects cleanly', async () => {
66
- const dir = mkdtempSync(join(tmpdir(), 'kit-setup-flow-'));
67
- try {
68
- const scaffoldDir = join(dir, 'my-custom-tool');
69
- const sc = scaffoldSidecarProject({ name: 'my-custom-tool', targetDir: scaffoldDir });
70
- assert.equal(sc.id, 'my-custom-tool');
71
- assert.equal(existsSync(join(scaffoldDir, 'sidecar.json')), true);
72
- assert.equal(existsSync(join(scaffoldDir, 'hub-manifest.json')), true);
73
-
74
- const globalSidecarsDir = join(dir, 'sidecars');
75
- const configJsonPath = join(dir, 'config.json');
76
- writeJsonNoBom(configJsonPath, {
77
- sidecars: {
78
- 'my-custom-tool': {
79
- enabled: false,
80
- projectId: 'keep-this-project-id',
81
- },
82
- },
83
- });
84
-
85
- const setupRes = await runSidecarSetup({
86
- sidecarId: 'my-custom-tool',
87
- repoRoot: scaffoldDir,
88
- args: ['--skip-tests'],
89
- globalSidecarsDir,
90
- configJsonPath,
91
- rpcOptions: { lsAddress: '', csrfToken: '' },
92
- restartDelayMs: 0,
93
- });
94
- assert.equal(setupRes.uninstalled, false);
95
- assert.equal(existsSync(join(globalSidecarsDir, 'my-custom-tool', 'sidecar.json')), true);
96
-
97
- const cfgAfterSetup = readJsonSafe(configJsonPath, {});
98
- assert.equal(cfgAfterSetup.sidecars['my-custom-tool'].enabled, true);
99
- assert.equal(cfgAfterSetup.sidecars['my-custom-tool'].projectId, 'keep-this-project-id');
100
-
101
- const uninstRes = await runSidecarSetup({
102
- sidecarId: 'my-custom-tool',
103
- repoRoot: scaffoldDir,
104
- args: ['--uninstall'],
105
- globalSidecarsDir,
106
- configJsonPath,
107
- rpcOptions: { lsAddress: '', csrfToken: '' },
108
- });
109
- assert.equal(uninstRes.uninstalled, true);
110
- assert.equal(existsSync(join(globalSidecarsDir, 'my-custom-tool')), false);
111
- const cfgAfterUninst = readJsonSafe(configJsonPath, {});
112
- assert.equal(cfgAfterUninst.sidecars['my-custom-tool'].enabled, false);
113
- assert.equal(cfgAfterUninst.sidecars['my-custom-tool'].projectId, 'keep-this-project-id');
114
- } finally {
115
- rmSync(dir, { recursive: true, force: true });
116
- }
117
- });
118
-
119
- it('createAssetHandler injects UI token into local HTML asset links and createAgyCssHandler serves CSS', async () => {
120
- const dir = mkdtempSync(join(tmpdir(), 'kit-server-test-'));
121
- try {
122
- writeFileSync(
123
- join(dir, 'index.html'),
124
- '<link rel="stylesheet" href="/styles.css"><script src="/app.js"></script>',
125
- 'utf8'
126
- );
127
- const handler = createAssetHandler(dir, 'index.html', 'text/html; charset=utf-8', {
128
- uiToken: 'sec-123',
129
- bootTs: 999,
130
- });
131
- const res = handler();
132
- assert.equal(res.contentType, 'text/html; charset=utf-8');
133
- assert.match(res.body, /href="\/styles\.css\?token=sec-123&v=999"/);
134
- assert.match(res.body, /src="\/app\.js\?token=sec-123&v=999"/);
135
-
136
- const cssHandler = createAgyCssHandler({ lsAddress: '' });
137
- const cssRes = await cssHandler();
138
- assert.equal(cssRes.contentType, 'text/css; charset=utf-8');
139
- } finally {
140
- rmSync(dir, { recursive: true, force: true });
141
- }
142
- });
143
- });
@@ -1,66 +0,0 @@
1
- import { describe, it } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { mkdtempSync, rmSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
6
- import { writeJsonNoBom } from '../lib/config.mjs';
7
- import { fetchHostStylesheet, resolveHostTheme } from '../lib/theme.mjs';
8
-
9
- describe('antigravity-sidecar-kit/theme unified resolver and stylesheet proxy', () => {
10
- it('resolves both Hangar cssVariables and Context Monitor vars across dark, light, and high-contrast modes', () => {
11
- const darkDefault = resolveHostTheme({ themeMode: 2 });
12
- assert.equal(darkDefault.mode, 'dark');
13
- assert.equal(darkDefault.themeType, 'dark');
14
- assert.equal(darkDefault.contrast, 'normal');
15
- assert.equal(darkDefault.vars['--cm-bg'], '#181818');
16
- assert.equal(darkDefault.vars['--cm-text'], '#ffffff');
17
- assert.equal(darkDefault.vars['--cm-card'], 'color-mix(in srgb, #ffffff 5%, #181818)');
18
- assert.equal(darkDefault.vars['--cm-border'], 'color-mix(in srgb, #ffffff 5%, transparent)');
19
- assert.equal(darkDefault.vars['--cm-hover'], 'rgba(255, 255, 255, 0.05)');
20
- assert.equal(darkDefault.cssVariables['--background'], '#181818');
21
-
22
- const dir = mkdtempSync(join(tmpdir(), 'kit-theme-test-'));
23
- try {
24
- const agyCfgPath = join(dir, 'config.json');
25
- const appStoragePath = join(dir, 'app_storage.json');
26
- writeJsonNoBom(agyCfgPath, {
27
- userSettings: {
28
- themeMode: 'THEME_MODE_LIGHT',
29
- customThemeSeedsLight: { background: '#FAFAFA', primary: '#10b981' },
30
- },
31
- });
32
- writeJsonNoBom(appStoragePath, { 'jetski.contrast': '2' });
33
- const resolved = resolveHostTheme(agyCfgPath, appStoragePath);
34
- assert.equal(resolved.themeType, 'light');
35
- assert.equal(resolved.mode, 'light');
36
- assert.equal(resolved.contrast, 'high');
37
- assert.equal(resolved.cssVariables['--background'], '#FAFAFA');
38
- assert.equal(resolved.cssVariables['--primary'], '#10b981');
39
- assert.equal(resolved.cssVariables['--border'], 'rgba(0, 0, 0, 0.12)');
40
- assert.equal(resolved.vars['--cm-bg'], '#FAFAFA');
41
- assert.equal(resolved.vars['--cm-accent'], '#10b981');
42
- } finally {
43
- rmSync(dir, { recursive: true, force: true });
44
- }
45
- });
46
-
47
- it('fetchHostStylesheet discovers renamed CSS links, rejects SPA HTML catch-all, and wraps in @layer agy-host', async () => {
48
- const css = await fetchHostStylesheet({
49
- lsAddress: '127.0.0.1:59125',
50
- fetchFn: async (u) =>
51
- u.endsWith('/')
52
- ? {
53
- ok: true,
54
- headers: new Map([['content-type', 'text/html']]),
55
- text: async () => '<link rel="stylesheet" href="/app-hash.css">',
56
- }
57
- : {
58
- ok: true,
59
- headers: new Map([['content-type', 'text/css']]),
60
- text: async () => ':root{--radius-sm:4px}@property --tw-x { syntax: "*"; }',
61
- },
62
- forceRefresh: true,
63
- });
64
- assert.match(css, /^@layer agy-host \{[\s\S]*--radius-sm:4px[\s\S]*\}\n@property --tw-x/);
65
- });
66
- });
@@ -1,44 +0,0 @@
1
- import { describe, it } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import {
4
- normalizeWorkspacePath,
5
- extractWorkspaceName,
6
- matchesWorkspaceRule,
7
- resolveActiveWorkspace,
8
- } from '../lib/workspace.mjs';
9
-
10
- describe('antigravity-sidecar-kit/workspace normalization and glob matching', () => {
11
- it('normalizes percent-encoded Windows file:// URIs, supports lowercase flag, and matches rules', () => {
12
- assert.equal(normalizeWorkspacePath('file:///c%3A/Projects/App/'), 'c:/Projects/App');
13
- assert.equal(
14
- normalizeWorkspacePath('file:///C:/Users/dev/Repo', { lowercase: true }),
15
- 'c:/users/dev/repo'
16
- );
17
- assert.equal(extractWorkspaceName('c:/Projects/services'), 'services');
18
-
19
- assert.equal(matchesWorkspaceRule('c:/Projects/App', ['*']), true);
20
- assert.equal(matchesWorkspaceRule('c:/Projects/App/packages/api', ['c:/Projects/App']), true);
21
- assert.equal(matchesWorkspaceRule('c:/Users/dev/backend-api', ['*backend*']), true);
22
- assert.equal(matchesWorkspaceRule('c:/Users/dev/frontend', ['c:/Projects/App']), false);
23
- });
24
-
25
- it('resolves active workspace from trajectories sorted by lastUserViewTime', () => {
26
- const resolved = resolveActiveWorkspace({
27
- trajectoriesResponse: {
28
- trajectorySummaries: {
29
- 'conv-1': {
30
- annotations: { lastUserViewTime: '2026-10-17T05:00:00Z' },
31
- workspaces: [{ workspaceFolderAbsoluteUri: 'file:///c:/Old' }],
32
- },
33
- 'conv-2': {
34
- annotations: { lastUserViewTime: '2026-10-17T08:00:00Z' },
35
- workspaces: [{ workspaceFolderAbsoluteUri: 'file:///c%3A/Projects/Current' }],
36
- },
37
- },
38
- },
39
- });
40
- assert.equal(resolved.workspacePath, 'c:/Projects/Current');
41
- assert.equal(resolved.workspaceName, 'Current');
42
- assert.equal(resolved.conversationId, 'conv-2');
43
- });
44
- });