dsh-plugin-jules 0.1.0

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/lib/watch.js ADDED
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Background watching for Jules sessions.
3
+ *
4
+ * A Jules session runs for minutes in Google's cloud, so a turn that blocks on
5
+ * one wastes the model's time. `jules_watch` instead registers the wait with
6
+ * `ctx.jobs`: the call returns a job id immediately, the harness keeps polling
7
+ * out of band, and `dsh-tool-jobs` delivers a completion notice to the owning
8
+ * agent when the session finishes, fails, or stops to ask something. The model
9
+ * reads the report with `job_output` on a later step.
10
+ *
11
+ * The session state alone is not a completion signal. Jules can post its final
12
+ * answer as an `agentMessaged` activity and then leave `state` sitting at
13
+ * `IN_PROGRESS` indefinitely, so the watcher treats the append-only activity
14
+ * log as authoritative and settles on what the agent did, not only on what the
15
+ * state field says.
16
+ *
17
+ * @module dsh-plugin-jules/watch
18
+ */
19
+ import { defineTool } from '@deepseek-ai/dsh-tools';
20
+ import { sleep } from "./async.js";
21
+ import { JulesError, JulesNotFoundError } from "./client.js";
22
+ import { DEFAULT_WAIT_STATES, needsAttentionState, sessionIdOf, settledState } from "./types.js";
23
+ import { renderWait, sessionDetail } from "./views.js";
24
+ /** Attempts allowed for a read that may race session creation. */
25
+ const VISIBILITY_ATTEMPTS = 4;
26
+ /**
27
+ * First delay between visibility attempts, in milliseconds. The wait is short
28
+ * on purpose: the race resolves in about a second in practice, and this same
29
+ * bound is what a genuinely bad session id costs before it reports.
30
+ */
31
+ const VISIBILITY_DELAY_MS = 500;
32
+ /**
33
+ * Read something that may not be visible yet.
34
+ *
35
+ * A session created moments ago answers `Requested entity was not found` for its
36
+ * first read: Jules mints the id before the resource is queryable, which is why
37
+ * the official SDK retries the first request on a 404. Only a not-found is
38
+ * retried, and only while it keeps saying so — a genuinely bad id still reports
39
+ * once the attempts are spent.
40
+ * @param operation - the read to attempt.
41
+ * @param signal - task-owned cancellation signal.
42
+ * @returns the operation's result.
43
+ * @throws the last failure once the attempts are exhausted, or any other error at once.
44
+ */
45
+ async function readWhenVisible(operation, signal) {
46
+ let lastError;
47
+ for (let attempt = 1; attempt <= VISIBILITY_ATTEMPTS; attempt += 1) {
48
+ try {
49
+ return await operation();
50
+ }
51
+ catch (error) {
52
+ if (!(error instanceof JulesNotFoundError))
53
+ throw error;
54
+ lastError = error;
55
+ if (attempt === VISIBILITY_ATTEMPTS)
56
+ break;
57
+ await sleep(VISIBILITY_DELAY_MS * attempt, signal);
58
+ }
59
+ }
60
+ throw lastError;
61
+ }
62
+ /**
63
+ * Stable identity of one activity, for de-duplicating a re-read page.
64
+ * @param activity - one activity from the log.
65
+ * @returns a key that is stable across reads of the same activity.
66
+ */
67
+ function activityKey(activity) {
68
+ return activity.id ?? activity.name ?? `${activity.createTime ?? ''}:${activity.originator ?? ''}`;
69
+ }
70
+ /**
71
+ * Read one page without re-recording what an earlier page already produced.
72
+ * @param collected - the running log for this watch, appended in place.
73
+ * @param seen - identities already recorded.
74
+ * @param page - the newest page from the service.
75
+ * @returns only the activities this watch had not seen before.
76
+ */
77
+ function absorb(collected, seen, page) {
78
+ const fresh = [];
79
+ for (const activity of page) {
80
+ const key = activityKey(activity);
81
+ if (seen.has(key))
82
+ continue;
83
+ seen.add(key);
84
+ collected.push(activity);
85
+ fresh.push(activity);
86
+ }
87
+ return fresh;
88
+ }
89
+ /**
90
+ * The newest `createTime` in a set of activities, used as the next cursor.
91
+ * @param activities - activities already collected.
92
+ * @returns the newest timestamp, or undefined when none carries one.
93
+ */
94
+ function newestTime(activities) {
95
+ let newest;
96
+ for (const activity of activities) {
97
+ const time = activity.createTime;
98
+ if (time !== undefined && (newest === undefined || time > newest))
99
+ newest = time;
100
+ }
101
+ return newest;
102
+ }
103
+ /**
104
+ * Poll one session until the agent hands something back, then describe it.
105
+ *
106
+ * Separated from the job adapter so the settling rules — which states end a
107
+ * watch, which activities end it, when the budget expires, and what the report
108
+ * contains — are testable without a live agent owner or a real job registry.
109
+ * @param client - the configured Jules client.
110
+ * @param session - bare session id.
111
+ * @param targets - states that end the watch.
112
+ * @param budgetMs - total watch budget.
113
+ * @param config - polling rules.
114
+ * @param signal - task-owned cancellation signal.
115
+ * @returns why the watch stopped and the rendered report.
116
+ * @throws whatever the client throws, including cancellation.
117
+ */
118
+ export async function runWatch(client, session, targets, budgetMs, config, signal) {
119
+ const started = Date.now();
120
+ let polls = 0;
121
+ let trigger = 'state';
122
+ // One cursor over an append-only log: the first read establishes it and each
123
+ // later poll asks only for what is new. The cursor is a bandwidth
124
+ // optimisation, so identity de-duplication carries correctness on its own —
125
+ // a rejected or ignored cursor costs requests, never a wrong answer.
126
+ const collected = [];
127
+ const seen = new Set();
128
+ absorb(collected, seen, await readWhenVisible(() => client.listAllActivities(session, {
129
+ signal,
130
+ maxPages: config.maxActivityPages,
131
+ pageSize: 100,
132
+ }), signal));
133
+ let cursor = newestTime(collected);
134
+ let cursorUsable = true;
135
+ let current = await readWhenVisible(() => client.getSession(session, signal), signal);
136
+ polls += 1;
137
+ let previousState = current.state ?? 'STATE_UNSPECIFIED';
138
+ // Only what happens after the watch starts is news. The baseline is kept for
139
+ // the report but never settles the watch on its own: the caller who just
140
+ // answered the agent's question does not need it reported back.
141
+ let fresh = [];
142
+ for (;;) {
143
+ const state = current.state ?? 'STATE_UNSPECIFIED';
144
+ // Terminal means nothing more will ever arrive, so report it at once even
145
+ // when the watch started after the fact.
146
+ if (settledState(state)) {
147
+ trigger = 'state';
148
+ break;
149
+ }
150
+ // An attention state settles the watch only when the session moved into it.
151
+ // Settling on the state it started in would make the ordinary
152
+ // "answer the agent, then watch for its reply" flow return instantly on the
153
+ // stale pre-answer state, which is no news at all.
154
+ if (targets.has(state) && state !== previousState) {
155
+ trigger = 'state';
156
+ break;
157
+ }
158
+ // Terminal activities outrank everything: the log is the authority for what
159
+ // actually happened, and a failed session must never wait out its budget.
160
+ if (collected.some(activity => activity.sessionFailed !== undefined)) {
161
+ trigger = 'failed';
162
+ break;
163
+ }
164
+ if (collected.some(activity => activity.sessionCompleted !== undefined)) {
165
+ trigger = 'completed';
166
+ break;
167
+ }
168
+ // A message posted since the watch began: the agent has handed something
169
+ // back, whether a question or a finished report, and may leave `state`
170
+ // reading IN_PROGRESS while it waits.
171
+ if (config.settleOnMessage && fresh.some(activity => activity.agentMessaged !== undefined)) {
172
+ trigger = 'message';
173
+ break;
174
+ }
175
+ const elapsed = Date.now() - started;
176
+ if (elapsed >= budgetMs) {
177
+ trigger = 'timeout';
178
+ break;
179
+ }
180
+ // Never oversleep past the deadline: a long poll interval must not turn a
181
+ // short budget into a much longer watch.
182
+ previousState = state;
183
+ await sleep(Math.min(config.pollIntervalMs, Math.max(250, budgetMs - elapsed)), signal);
184
+ current = await client.getSession(session, signal);
185
+ polls += 1;
186
+ let page;
187
+ try {
188
+ page = await client.listActivities(session, {
189
+ signal,
190
+ pageSize: 100,
191
+ ...cursorUsable && cursor !== undefined ? { since: cursor } : {},
192
+ });
193
+ }
194
+ catch (error) {
195
+ // The cursor form is not in the API reference — the documented
196
+ // `?createTime=` parameter is rejected outright — so a 400 here means this
197
+ // deployment does not take the filter either. Drop the cursor rather than
198
+ // the watch: de-duplication already makes correctness independent of it.
199
+ if (!cursorUsable || !(error instanceof JulesError) || error.status !== 400)
200
+ throw error;
201
+ cursorUsable = false;
202
+ page = await client.listActivities(session, { signal, pageSize: 100 });
203
+ }
204
+ fresh = absorb(collected, seen, page.activities ?? []);
205
+ cursor = newestTime(collected) ?? cursor;
206
+ }
207
+ const detail = sessionDetail(current, collected);
208
+ const waitedMs = Date.now() - started;
209
+ const wait = {
210
+ id: detail.id,
211
+ title: detail.title,
212
+ state: detail.state,
213
+ url: detail.url,
214
+ settled: settledState(detail.state),
215
+ timedOut: trigger === 'timeout',
216
+ needsAttention: needsAttentionState(detail.state),
217
+ waitedMs,
218
+ polls,
219
+ planId: detail.planId,
220
+ planSteps: detail.planSteps,
221
+ pullRequests: detail.pullRequests,
222
+ lastMessage: detail.lastMessage,
223
+ lastProgress: detail.lastProgress,
224
+ };
225
+ return {
226
+ trigger,
227
+ detail: describeWatch(trigger, detail.state, waitedMs),
228
+ report: renderWait(wait),
229
+ wait,
230
+ };
231
+ }
232
+ /**
233
+ * One short phrase describing why a watch stopped, for the job status line.
234
+ * @param trigger - why the watch stopped.
235
+ * @param state - the session state at settle time.
236
+ * @param waitedMs - how long the watch ran.
237
+ * @returns the status detail.
238
+ */
239
+ function describeWatch(trigger, state, waitedMs) {
240
+ const seconds = Math.round(waitedMs / 1000);
241
+ if (trigger === 'timeout')
242
+ return `still ${state} after ${seconds}s`;
243
+ if (trigger === 'failed')
244
+ return `session failed (${state})`;
245
+ if (trigger === 'completed')
246
+ return `session completed (${state})`;
247
+ // "checkpoint" not "finished": an agent message means the floor is the
248
+ // caller's, and the session may well keep going once they answer.
249
+ if (trigger === 'message')
250
+ return `checkpoint: agent message (${state})`;
251
+ return `session ${state}`;
252
+ }
253
+ /**
254
+ * Register `jules_watch`.
255
+ *
256
+ * Registered through `ctx.inject(['jobs'])` rather than a plugin-level
257
+ * injection, so a composition without the job registry still gets the rest of
258
+ * the tool family instead of holding the whole plugin pending forever.
259
+ * @param ctx - plugin context supplying the job registry.
260
+ * @param client - the configured Jules client.
261
+ * @param config - watch budgets and polling bounds.
262
+ * @param journal - durable record of armed watches, when storage is mounted.
263
+ */
264
+ export function registerJulesWatch(ctx, client, config, journal, live) {
265
+ ctx.tools.register(defineTool({
266
+ name: 'jules_watch',
267
+ description: 'Watch a Jules session in the background — this is how you wait for one. Returns a job id immediately and notifies '
268
+ + 'you when the session completes, fails, needs a plan decision, or the agent posts a message; read the report with job_output. '
269
+ + 'Call it once, then END YOUR TURN and do other work. Do not poll jules_status alongside it: the notice is the signal, and nothing '
270
+ + 'you check in the meantime will have changed.',
271
+ parameters: {
272
+ session: { type: 'string', required: true, description: 'Session id, "sessions/<id>", or the session URL.' },
273
+ timeoutMs: {
274
+ type: 'number',
275
+ description: 'How long to keep watching before giving up, in milliseconds. '
276
+ + 'Defaults to the configured watchDefaultMs and is capped by watchMaxMs.',
277
+ },
278
+ until: {
279
+ type: 'array',
280
+ description: 'Session states that end the watch. Defaults to completion, failure, a pending plan, and a question.',
281
+ items: { type: 'string' },
282
+ },
283
+ },
284
+ output: {
285
+ schema: {
286
+ type: 'object', additionalProperties: false,
287
+ properties: {
288
+ kind: { type: 'string', required: true, const: 'background' },
289
+ jobId: { type: 'string', required: true },
290
+ },
291
+ },
292
+ render: (args, value) => [{
293
+ type: 'text',
294
+ text: `Watching Jules session ${args.session} in the background as job ${value.jobId}. `
295
+ + 'You will be notified when it finishes or needs you; read the report with job_output. '
296
+ + 'Do not poll it; continue with other work.',
297
+ }],
298
+ },
299
+ // Read-only against the service: it registers local work and asks Jules for
300
+ // nothing new, so watching several sessions in one turn may overlap.
301
+ isConcurrencySafe: () => true,
302
+ async execute(args, exec) {
303
+ const owner = exec.agent;
304
+ if (owner === undefined) {
305
+ throw new Error('jules_watch requires an agent Session, because the completion notice is delivered to the session that started the watch');
306
+ }
307
+ const session = sessionIdOf(args.session);
308
+ const requested = args.timeoutMs ?? config.defaultMs;
309
+ // Floored at 30s: a watch shorter than the poll interval cannot observe
310
+ // anything, so a caller asking for one would pay for a job that reports
311
+ // only the state it already had. Ceilinged by config so a caller cannot
312
+ // pin a poller on this machine indefinitely.
313
+ const budget = Math.max(30_000, Math.min(Math.trunc(requested), config.maxMs));
314
+ const targets = new Set(args.until === undefined || args.until.length === 0 ? DEFAULT_WAIT_STATES : args.until);
315
+ const jobId = startWatchJob(ctx, client, owner, session, targets, budget, config, journal, live);
316
+ return { kind: 'background', jobId: String(jobId) };
317
+ },
318
+ }));
319
+ }
320
+ /**
321
+ * Start the polling job and return its id.
322
+ *
323
+ * The controller is task-owned rather than the tool call's: once the job id is
324
+ * published the model is tracking that work, so a later cancellation of the
325
+ * originating call must not silently kill it. `job_kill`, owner disposal,
326
+ * and service teardown own this lifetime instead.
327
+ * @param ctx - plugin context supplying the job registry.
328
+ * @param client - the configured Jules client.
329
+ * @param owner - the agent that receives the completion notice.
330
+ * @param session - bare session id.
331
+ * @param targets - states that end the watch.
332
+ * @param budgetMs - total watch budget.
333
+ * @param config - polling bounds.
334
+ * @returns the registry-issued job id.
335
+ */
336
+ function startWatchJob(ctx, client, owner, session, targets, budgetMs, config, journal, live) {
337
+ const controller = new AbortController();
338
+ let cancelled = false;
339
+ let settle = () => { };
340
+ const done = new Promise((resolve) => { settle = resolve; });
341
+ const watch = async () => {
342
+ try {
343
+ const outcome = await runWatch(client, session, targets, budgetMs, config, controller.signal);
344
+ // The watch itself succeeded. A failed or waiting *session* is reported in
345
+ // the detail and the report, not as a broken job.
346
+ settle({ status: 'completed', detail: outcome.detail, output: outcome.report });
347
+ // It reached its own conclusion, so the journal has nothing left to
348
+ // report. A kill or a teardown deliberately does NOT release: a teardown
349
+ // is precisely the restart this journal exists to survive.
350
+ live.delete(session);
351
+ void journal()?.release(session);
352
+ }
353
+ catch (error) {
354
+ if (cancelled) {
355
+ settle({ status: 'killed', detail: 'watch cancelled' });
356
+ return;
357
+ }
358
+ settle({ status: 'failed', detail: error instanceof Error ? error.message : String(error) });
359
+ }
360
+ };
361
+ const jobId = ctx.jobs.start({
362
+ kind: 'jules',
363
+ label: `session ${session}`,
364
+ owner,
365
+ run: () => {
366
+ void watch();
367
+ return {
368
+ cancel: (reason) => {
369
+ if (cancelled)
370
+ return;
371
+ cancelled = true;
372
+ controller.abort(new Error(reason ?? 'watch cancelled'));
373
+ },
374
+ done,
375
+ };
376
+ },
377
+ });
378
+ // Recorded only once the registry has accepted the job, so a refusal at the
379
+ // concurrency cap leaves no phantom entry behind.
380
+ const opened = journal();
381
+ if (opened !== undefined) {
382
+ const armedAt = Date.now();
383
+ live.add(session);
384
+ void opened.arm({ session, startedAt: armedAt, expiresAt: armedAt + budgetMs });
385
+ }
386
+ return jobId;
387
+ }
package/package.json ADDED
@@ -0,0 +1,86 @@
1
+ {
2
+ "name": "dsh-plugin-jules",
3
+ "version": "0.1.0",
4
+ "description": "Google Jules remote coding agent integration for DeepSeek Harness",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "deepseek-harness",
9
+ "jules",
10
+ "google-jules",
11
+ "coding-agent",
12
+ "remote-agent"
13
+ ],
14
+ "license": "MIT",
15
+ "author": {
16
+ "name": "Aurimas Šeputis"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/rbviz/dsh-plugin-jules.git"
21
+ },
22
+ "homepage": "https://github.com/rbviz/dsh-plugin-jules#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/rbviz/dsh-plugin-jules/issues"
25
+ },
26
+ "engines": {
27
+ "node": ">=22"
28
+ },
29
+ "type": "module",
30
+ "main": "lib/index.js",
31
+ "types": "lib/types/index.d.ts",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./lib/types/index.d.ts",
35
+ "default": "./lib/index.js"
36
+ }
37
+ },
38
+ "files": [
39
+ "lib",
40
+ "cordis.patch.yml",
41
+ "scripts",
42
+ "README.md",
43
+ "LICENSE"
44
+ ],
45
+ "dsh": {
46
+ "bundle": {
47
+ "patch": "./cordis.patch.yml"
48
+ }
49
+ },
50
+ "peerDependencies": {
51
+ "@deepseek-ai/cordis": "^4.0.2",
52
+ "@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
53
+ "@deepseek-ai/dsh-credentials": "^0.1.5-rc.2",
54
+ "@deepseek-ai/dsh-jobs": "^0.1.5-rc.2",
55
+ "@deepseek-ai/dsh-launch-environment": "^0.1.5-rc.2",
56
+ "@deepseek-ai/dsh-storage-domain": "^0.1.5-rc.2",
57
+ "@deepseek-ai/dsh-system-prompt": "^0.1.5-rc.2",
58
+ "@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
59
+ "@deepseek-ai/schemastery": "^3.18.2",
60
+ "zod": "^4.4.3"
61
+ },
62
+ "devDependencies": {
63
+ "@deepseek-ai/cordis": "^4.0.2",
64
+ "@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
65
+ "@deepseek-ai/dsh-credentials": "^0.1.5-rc.2",
66
+ "@deepseek-ai/dsh-jobs": "^0.1.5-rc.2",
67
+ "@deepseek-ai/dsh-launch-environment": "^0.1.5-rc.2",
68
+ "@deepseek-ai/dsh-storage-domain": "^0.1.5-rc.2",
69
+ "@deepseek-ai/dsh-system-prompt": "^0.1.5-rc.2",
70
+ "@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
71
+ "@deepseek-ai/schemastery": "^3.18.2",
72
+ "zod": "^4.4.3",
73
+ "@deepseek-ai/cordis-plugin-include": "^1.0.7",
74
+ "@deepseek-ai/cordis-plugin-loader": "^1.0.3",
75
+ "@deepseek-ai/dsh-jobs-local": "^0.1.5-rc.2",
76
+ "@deepseek-ai/dsh-storage": "^0.1.5-rc.2",
77
+ "@deepseek-ai/dsh-storage-json": "^0.1.5-rc.2",
78
+ "typescript": "^6.0.3"
79
+ },
80
+ "scripts": {
81
+ "build": "node scripts/build.mjs",
82
+ "typecheck": "node scripts/build.mjs --noEmit",
83
+ "link-dsh-deps": "node scripts/link-dsh-deps.mjs",
84
+ "test": "node scripts/build.mjs && node --test test/*.test.mjs"
85
+ }
86
+ }
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Compile the plugin with the TypeScript that the running dsh installation
4
+ * already ships, so this package needs no toolchain of its own.
5
+ *
6
+ * Resolution order: `$DSH_TSC`, then a `typescript` found through this
7
+ * package's own `node_modules` (populated by `link-dsh-deps`), then the
8
+ * harness home. Run `npm run link-dsh-deps` first if this fails.
9
+ */
10
+
11
+ import { spawnSync } from 'node:child_process'
12
+ import { existsSync } from 'node:fs'
13
+ import { dirname, join, resolve } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+
16
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
17
+
18
+ function candidateTsc() {
19
+ if (process.env.DSH_TSC !== undefined && existsSync(process.env.DSH_TSC)) return process.env.DSH_TSC
20
+ const home = process.env.DSH_HOME ?? join(process.env.HOME ?? '', '.dsh')
21
+ return [
22
+ join(PACKAGE_ROOT, 'node_modules', 'typescript', 'bin', 'tsc'),
23
+ join(home, 'profiles', 'node_modules', 'typescript', 'bin', 'tsc'),
24
+ ].find(existsSync)
25
+ }
26
+
27
+ const tsc = candidateTsc()
28
+ if (tsc === undefined) {
29
+ process.stderr.write('build: no typescript found; run "npm run link-dsh-deps" or set DSH_TSC\n')
30
+ process.exit(1)
31
+ }
32
+
33
+ const result = spawnSync(process.execPath, [tsc, '-p', join(PACKAGE_ROOT, 'tsconfig.json'), ...process.argv.slice(2)], {
34
+ stdio: 'inherit',
35
+ cwd: PACKAGE_ROOT,
36
+ })
37
+ process.exit(result.status ?? 1)
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Make the in-box `@deepseek-ai/*` packages importable from this plugin's own
4
+ * directory.
5
+ *
6
+ * Why this exists: a profile installs an out-of-tree bundle with pnpm's
7
+ * `link:` protocol, which puts a symlink in the profile's `node_modules` but
8
+ * creates no `node_modules` beside the real package. Node resolves a symlinked
9
+ * package to its realpath, so this plugin's own `import '@deepseek-ai/dsh-tools'`
10
+ * would otherwise fail with ERR_MODULE_NOT_FOUND even though dsh itself loaded
11
+ * the file.
12
+ *
13
+ * The fix is to point each declared peer dependency at the copy the running
14
+ * installation already uses, so both sides share one module instance. dsh
15
+ * maintains exactly such a directory at `$DSH_HOME/profiles/node_modules`
16
+ * (its "module fallback"), whose entries are themselves links into the
17
+ * installation.
18
+ *
19
+ * Usage:
20
+ * node scripts/link-dsh-deps.mjs [--home <dsh home>] [--check]
21
+ *
22
+ * `--check` reports what is missing without writing anything.
23
+ */
24
+
25
+ import {
26
+ existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, unlinkSync,
27
+ } from 'node:fs'
28
+ import { dirname, join, resolve } from 'node:path'
29
+ import { fileURLToPath } from 'node:url'
30
+
31
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
32
+ const MANIFEST = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8'))
33
+
34
+ /**
35
+ * Every name to link: runtime peers plus the loader packages the composition
36
+ * test boots. Types alone still need resolution, so this is not filtered.
37
+ */
38
+ const REQUIRED = [...new Set([
39
+ ...Object.keys(MANIFEST.peerDependencies ?? {}),
40
+ ...Object.keys(MANIFEST.devDependencies ?? {}),
41
+ ])]
42
+
43
+ function parseArgs(argv) {
44
+ const options = { home: process.env.DSH_HOME, check: false, optional: false }
45
+ for (let index = 0; index < argv.length; index += 1) {
46
+ const arg = argv[index]
47
+ if (arg === '--check') options.check = true
48
+ else if (arg === '--optional') options.optional = true
49
+ else if (arg === '--home') options.home = argv[++index]
50
+ else if (arg.startsWith('--home=')) options.home = arg.slice('--home='.length)
51
+ else {
52
+ process.stderr.write(`link-dsh-deps: unknown argument ${arg}\n`)
53
+ process.exit(2)
54
+ }
55
+ }
56
+ if (options.home === undefined || options.home.length === 0) options.home = join(homedir(), '.dsh')
57
+ return { home: resolve(options.home), check: options.check, optional: options.optional }
58
+ }
59
+
60
+ function homedir() {
61
+ return process.env.HOME ?? process.env.USERPROFILE ?? ''
62
+ }
63
+
64
+ /** `node_modules` directories that may hold the in-box packages, best candidate first. */
65
+ function candidateRoots(home) {
66
+ const roots = [join(home, 'profiles', 'node_modules')]
67
+ const profiles = join(home, 'profiles')
68
+ if (existsSync(profiles)) {
69
+ for (const entry of readdirSync(profiles, { withFileTypes: true })) {
70
+ if (!entry.isDirectory() || entry.name === 'node_modules') continue
71
+ roots.push(join(profiles, entry.name, 'node_modules'))
72
+ }
73
+ }
74
+ return roots
75
+ }
76
+
77
+ /** First existing directory providing `packageName`, or undefined. */
78
+ function locate(packageName, roots) {
79
+ for (const root of roots) {
80
+ const candidate = join(root, packageName)
81
+ if (existsSync(join(candidate, 'package.json'))) return candidate
82
+ }
83
+ return undefined
84
+ }
85
+
86
+ const { home, check, optional } = parseArgs(process.argv.slice(2))
87
+ const roots = candidateRoots(home)
88
+ const target = join(PACKAGE_ROOT, 'node_modules')
89
+ const missing = []
90
+
91
+ for (const packageName of REQUIRED) {
92
+ const source = locate(packageName, roots)
93
+ if (source === undefined) {
94
+ missing.push(packageName)
95
+ continue
96
+ }
97
+ const link = join(target, packageName)
98
+ const current = existsSync(link)
99
+ ? (() => { try { return readlinkSync(link) } catch { return undefined } })()
100
+ : undefined
101
+ if (current === resolve(source)) continue
102
+ if (check) {
103
+ process.stdout.write(`would link ${packageName} -> ${source}\n`)
104
+ continue
105
+ }
106
+ mkdirSync(dirname(link), { recursive: true })
107
+ // rmSync, not unlinkSync: these peers are devDependencies too, so npm may have
108
+ // installed them as real directories. unlinkSync on one throws EISDIR, and
109
+ // swallowing that left the symlink to fail with EEXIST against a directory
110
+ // that was still there — which is exactly how this broke the first time a
111
+ // checkout had both an npm install and a local harness.
112
+ rmSync(link, { recursive: true, force: true })
113
+ symlinkSync(resolve(source), link, 'junction')
114
+ process.stdout.write(`linked ${packageName} -> ${source}\n`)
115
+ }
116
+
117
+ if (missing.length > 0) {
118
+ const detail = `link-dsh-deps: could not find ${missing.join(', ')}\n`
119
+ + ` searched:\n${roots.map(root => ` ${root}\n`).join('')}`
120
+ + ' pass --home <dsh home> or set DSH_HOME to the harness home that installed them.\n'
121
+ // --optional is for a machine with no harness installed: CI, or a consumer
122
+ // building from a tarball. The packages then come from node_modules like any
123
+ // other dependency, so the absence of a harness home is not a failure.
124
+ if (optional) {
125
+ process.stderr.write(detail + ' (--optional: continuing; node_modules will be used as-is)\n')
126
+ process.exit(0)
127
+ }
128
+ process.stderr.write(detail)
129
+ process.exit(1)
130
+ }
131
+
132
+ if (check) process.stdout.write('link-dsh-deps: all peer dependencies resolve\n')