@ishlabs/cli 0.8.3 → 0.8.4

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.
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * ish study — Manage studies.
3
3
  */
4
+ import { readFileSync } from "node:fs";
4
5
  import { withClient, getWebUrl, terminalLink, resolveWorkspace } from "../lib/command-helpers.js";
5
6
  import { resolveId, tagAlias, ALIAS_PREFIX } from "../lib/alias-store.js";
6
7
  import { loadConfig, saveConfig } from "../config.js";
@@ -76,7 +77,7 @@ Concept pages: ish docs get-page concepts/study
76
77
  });
77
78
  study
78
79
  .command("create")
79
- .description("Create a new study (the persistent shape: modality, tasks, questionnaire)")
80
+ .description("Create a new study (the persistent shape: modality, tasks, questionnaire). Optionally creates iteration A inline when --content-text or --url is passed.")
80
81
  .option("--workspace <id>", "Workspace ID")
81
82
  .requiredOption("--name <name>", "Study name")
82
83
  .option("--description <description>", "Study description")
@@ -87,13 +88,15 @@ Concept pages: ish docs get-page concepts/study
87
88
  .option("--assignments <json>", "Inline JSON array of assignments (escape hatch)")
88
89
  .option("--question <text>", "Add a text question to the questionnaire (repeatable; type=text, timing=after)", collectRepeatable, [])
89
90
  .option("--questionnaire <path>", "JSON file defining the questionnaire (supports text, slider, likert, single-choice, multiple-choice, number; timing=before|after)")
91
+ .option("--content-text <text>", "Text content to evaluate, or @filepath to read from file. Creates iteration A inline (text modality only)")
92
+ .option("--url <url>", "URL to test. Creates iteration A inline (interactive modality only)")
90
93
  .addHelpText("after", `
91
94
  Note: --workspace is optional if set via \`ish workspace use <alias>\`.
92
95
 
93
96
  The questionnaire is the set of questions testers answer. Use \`--question\` to
94
97
  quickly add simple text questions, or \`--questionnaire <file.json>\` for richer
95
- types (slider, likert, choice) and custom timing. The two forms are mutually
96
- exclusive — pick one.
98
+ types (slider, likert, single-choice, multiple-choice, number) and custom
99
+ timing. The two forms are mutually exclusive — pick one.
97
100
 
98
101
  Examples:
99
102
  # Interactive study with one assignment and a single-question questionnaire:
@@ -145,6 +148,28 @@ Next: configure a run with \`ish iteration create --study <id>\`,
145
148
  throw new ValidationError(`Invalid content type "${opts.contentType}" for modality "${opts.modality}".`, validTypes);
146
149
  }
147
150
  }
151
+ // Pattern E (cli half): build an inline iteration A when --content-text
152
+ // or --url is provided, so a single `study create` produces a study
153
+ // that's immediately runnable. Without these flags the backend
154
+ // creates zero iterations and the first `iteration create` becomes A.
155
+ let inlineIteration;
156
+ if (opts.contentText !== undefined) {
157
+ if (opts.modality && opts.modality !== "text") {
158
+ throw new Error(`--content-text is only valid with --modality text (got "${opts.modality}").`);
159
+ }
160
+ const text = opts.contentText.startsWith("@")
161
+ ? readFileSync(opts.contentText.slice(1), "utf8")
162
+ : opts.contentText;
163
+ inlineIteration = { details: { type: "text", content_text: text } };
164
+ }
165
+ else if (opts.url !== undefined) {
166
+ if (opts.modality && opts.modality !== "interactive") {
167
+ throw new Error(`--url is only valid with --modality interactive (got "${opts.modality}").`);
168
+ }
169
+ inlineIteration = {
170
+ details: { type: "interactive", url: opts.url, platform: "browser" },
171
+ };
172
+ }
148
173
  const resolvedWs = resolveWorkspace(opts.workspace);
149
174
  const body = {
150
175
  product_id: resolvedWs,
@@ -154,6 +179,7 @@ Next: configure a run with \`ish iteration create --study <id>\`,
154
179
  ...(opts.contentType !== undefined && { content_type: opts.contentType }),
155
180
  ...(assignments && { assignments }),
156
181
  ...(interviewQuestions && { interview_questions: interviewQuestions }),
182
+ ...(inlineIteration && { iteration: inlineIteration }),
157
183
  };
158
184
  const data = await client.post(`/products/${resolvedWs}/studies`, body);
159
185
  if (data.id) {
@@ -198,20 +224,47 @@ Next: configure a run with \`ish iteration create --study <id>\`,
198
224
  });
199
225
  study
200
226
  .command("get")
201
- .description("Get study overview (assignments, questions, testers)")
202
- .argument("<id>", "Study ID")
203
- .addHelpText("after", "\nExamples:\n $ ish study get <id>\n $ ish study get <id> --json")
204
- .action(async (id, _opts, cmd) => {
227
+ .description("Get study overview (accepts multiple IDs for batched lookup)")
228
+ .argument("<ids...>", "Study ID(s) — one or more aliases/UUIDs (space- or comma-separated)")
229
+ .addHelpText("after", `
230
+ Examples:
231
+ $ ish study get s-b2c
232
+ $ ish study get s-b2c --json
233
+ $ ish study get s-b2c s-d4e s-f0a
234
+ $ ish study get s-b2c,s-d4e --fields alias,name,modality,status
235
+
236
+ With multiple IDs, returns a {items:[...], total:N} envelope and uses the
237
+ list table layout in human mode.`)
238
+ .action(async (ids, _opts, cmd) => {
205
239
  await withClient(cmd, async (client, globals) => {
206
- const rid = resolveId(id);
207
- const data = await client.get(`/studies/${rid}`);
208
- const result = data;
209
- if (result.id)
210
- result.alias = tagAlias(ALIAS_PREFIX.study, String(result.id));
211
- formatStudyDetail(result, globals.json);
212
- if (!globals.json && data.product_id) {
213
- const url = getWebUrl(globals, `/${data.product_id}/${rid}/overview`);
214
- console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
240
+ const flat = ids.flatMap((s) => s.split(",").map((x) => x.trim()).filter(Boolean));
241
+ if (flat.length === 0)
242
+ throw new Error("Provide at least one study id.");
243
+ if (flat.length === 1) {
244
+ const rid = resolveId(flat[0]);
245
+ const data = await client.get(`/studies/${rid}`);
246
+ const result = data;
247
+ if (result.id)
248
+ result.alias = tagAlias(ALIAS_PREFIX.study, String(result.id));
249
+ formatStudyDetail(result, globals.json);
250
+ if (!globals.json && data.product_id) {
251
+ const url = getWebUrl(globals, `/${data.product_id}/${rid}/overview`);
252
+ console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
253
+ }
254
+ return;
255
+ }
256
+ const results = await Promise.all(flat.map(async (raw) => {
257
+ const data = await client.get(`/studies/${resolveId(raw)}`);
258
+ const r = data;
259
+ if (r.id)
260
+ r.alias = tagAlias(ALIAS_PREFIX.study, String(r.id));
261
+ return r;
262
+ }));
263
+ if (globals.json) {
264
+ output({ items: results, total: results.length }, true);
265
+ }
266
+ else {
267
+ formatStudyList(results, false);
215
268
  }
216
269
  });
217
270
  });
@@ -219,6 +272,7 @@ Next: configure a run with \`ish iteration create --study <id>\`,
219
272
  .command("results")
220
273
  .description("View aggregated results: tester counts, sentiment, interview answers. Returns a stable envelope with empty fields when no runs have completed.")
221
274
  .argument("<id>", "Study ID")
275
+ .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the study)")
222
276
  .addHelpText("after", `
223
277
  Examples:
224
278
  $ ish study results <id>
@@ -319,6 +373,7 @@ Examples:
319
373
  .command("delete")
320
374
  .description("Delete a study")
321
375
  .argument("<id>", "Study ID")
376
+ .option("--workspace <id>", "Workspace ID; accepted for consistency (workspace is inferred from the study)")
322
377
  .addHelpText("after", "\nExamples:\n $ ish study delete <id>")
323
378
  .action(async (id, _opts, cmd) => {
324
379
  await withClient(cmd, async (client, globals) => {
package/dist/connect.js CHANGED
@@ -348,7 +348,7 @@ function manualInstallInstructions() {
348
348
  function startCloudflared(port, binPath, json) {
349
349
  return new Promise((resolve, reject) => {
350
350
  if (!json)
351
- console.log(`Connecting to localhost:${port}...`);
351
+ console.error(`Connecting to localhost:${port}...`);
352
352
  const proc = spawn(binPath, ["tunnel", "--url", `http://localhost:${port}`], {
353
353
  stdio: ["ignore", "pipe", "pipe"],
354
354
  });
@@ -368,7 +368,7 @@ function startCloudflared(port, binPath, json) {
368
368
  resolve({ process: proc, tunnelUrl });
369
369
  }
370
370
  });
371
- proc.on("exit", (code) => {
371
+ proc.on("exit", () => {
372
372
  clearTimeout(timeout);
373
373
  if (!tunnelUrl) {
374
374
  reject(new Error("cloudflared exited unexpectedly."));
@@ -415,7 +415,7 @@ async function deregisterTunnel(apiUrl, token, json) {
415
415
  console.log(JSON.stringify({ status: "disconnected" }));
416
416
  }
417
417
  else {
418
- console.log("Disconnected");
418
+ console.error("Disconnected");
419
419
  }
420
420
  }
421
421
  catch (e) {
@@ -455,7 +455,7 @@ function startHeartbeat(apiUrl, getToken, doRefresh, onTokenRefreshed, onFatal,
455
455
  const newToken = await doRefresh();
456
456
  onTokenRefreshed(newToken);
457
457
  if (!json)
458
- console.log("Token refreshed.");
458
+ console.error("Token refreshed.");
459
459
  // Retry heartbeat with new token
460
460
  const retry = await fetch(`${apiUrl}${API_BASE}/connect/heartbeat`, {
461
461
  method: "POST",
@@ -514,7 +514,7 @@ function scheduleProactiveRefresh(token, doRefresh, onTokenRefreshed, json) {
514
514
  const newToken = await doRefresh();
515
515
  onTokenRefreshed(newToken);
516
516
  if (!json)
517
- console.log("Token proactively refreshed.");
517
+ console.error("Token proactively refreshed.");
518
518
  // Schedule next refresh for the new token
519
519
  scheduleProactiveRefresh(newToken, doRefresh, onTokenRefreshed, json);
520
520
  }
@@ -583,7 +583,7 @@ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputO
583
583
  process.exit(1);
584
584
  shuttingDown = true;
585
585
  if (!json)
586
- console.log("\nShutting down...");
586
+ console.error("\nShutting down...");
587
587
  heartbeat.stop();
588
588
  proactiveRefresh.stop();
589
589
  cfProcess.kill();
@@ -593,7 +593,7 @@ export async function runTunnel(port, tokenArg, apiUrlArg, tokenFileArg, outputO
593
593
  process.on("SIGINT", shutdown);
594
594
  process.on("SIGTERM", shutdown);
595
595
  if (!json && !quiet) {
596
- console.log("Press Ctrl+C to disconnect.\n");
596
+ console.error("Press Ctrl+C to disconnect.\n");
597
597
  }
598
598
  cfProcess.on("exit", async () => {
599
599
  if (!shuttingDown) {
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { program, Option } from "commander";
3
3
  import { runTunnel } from "./connect.js";
4
- import { login, getAppUrl } from "./auth.js";
4
+ import { login, getAppUrl, decodeJwtClaims } from "./auth.js";
5
5
  import { loadConfig, saveConfig } from "./config.js";
6
6
  import { upgrade } from "./upgrade.js";
7
7
  import { registerWorkspaceCommands } from "./commands/workspace.js";
@@ -14,8 +14,12 @@ import { registerAskCommands } from "./commands/ask.js";
14
14
  import { registerDocsCommands } from "./commands/docs.js";
15
15
  import { registerInitCommands } from "./commands/init.js";
16
16
  import { AGENT_HELP_FOOTER } from "./lib/docs.js";
17
- import { runInline, EXIT_USAGE } from "./lib/command-helpers.js";
17
+ import { runInline, EXIT_USAGE, injectGlobalWorkspaceOption } from "./lib/command-helpers.js";
18
+ import { resolveApiUrl, resolveToken } from "./lib/auth.js";
19
+ import { ApiClient } from "./lib/api-client.js";
20
+ import { tagAlias, ALIAS_PREFIX } from "./lib/alias-store.js";
18
21
  import { output } from "./lib/output.js";
22
+ import { ishDir } from "./lib/paths.js";
19
23
  import pkg from "../package.json" with { type: "json" };
20
24
  const { version } = pkg;
21
25
  program
@@ -98,6 +102,118 @@ program
98
102
  output({ message: "Logged out" }, globals.json);
99
103
  });
100
104
  });
105
+ program
106
+ .command("status")
107
+ .alias("whoami")
108
+ .description("Show active session — user, workspace, study, ask")
109
+ .addHelpText("after", "\nFirst command to run when starting cold. Outputs the active workspace,\nstudy, and ask handles plus token validity. Doesn't error on no-token —\nreturns user: null with a hint instead. JSON safe for piping.")
110
+ .action(async (_opts, cmd) => {
111
+ await runInline(cmd, async (globals) => {
112
+ const apiUrl = resolveApiUrl(globals.apiUrl, globals.dev);
113
+ const config = loadConfig();
114
+ // Try to resolve a token; on auth failure, return a no-token status
115
+ // instead of throwing so the command works pre-login.
116
+ let token;
117
+ let tokenError;
118
+ try {
119
+ token = await resolveToken(globals.token, apiUrl, globals.tokenFile);
120
+ }
121
+ catch (err) {
122
+ tokenError = err instanceof Error ? err.message : String(err);
123
+ }
124
+ let user = null;
125
+ if (token) {
126
+ const claims = decodeJwtClaims(token);
127
+ const exp = typeof claims?.exp === "number" ? claims.exp : 0;
128
+ const expiresIn = exp ? Math.max(0, exp - Math.floor(Date.now() / 1000)) : 0;
129
+ user = {
130
+ email: typeof claims?.email === "string" ? claims.email : null,
131
+ token_valid: expiresIn > 0,
132
+ expires_in_seconds: expiresIn,
133
+ };
134
+ }
135
+ // Resolve names of active resources. Failures are non-fatal — we still
136
+ // return the saved IDs so the user knows what's configured.
137
+ const client = token ? new ApiClient({ apiUrl, token }) : null;
138
+ let workspace = null;
139
+ if (config.workspace) {
140
+ workspace = {
141
+ id: config.workspace,
142
+ alias: tagAlias(ALIAS_PREFIX.workspace, config.workspace),
143
+ };
144
+ if (client) {
145
+ try {
146
+ const ws = await client.get(`/products/${config.workspace}`);
147
+ if (ws?.name)
148
+ workspace.name = ws.name;
149
+ }
150
+ catch { /* keep id+alias only */ }
151
+ }
152
+ }
153
+ let study = null;
154
+ if (config.study) {
155
+ study = {
156
+ id: config.study,
157
+ alias: tagAlias(ALIAS_PREFIX.study, config.study),
158
+ };
159
+ if (client) {
160
+ try {
161
+ const s = await client.get(`/studies/${config.study}`);
162
+ if (s?.name)
163
+ study.name = s.name;
164
+ }
165
+ catch { /* keep id+alias only */ }
166
+ }
167
+ }
168
+ let ask = null;
169
+ if (config.ask) {
170
+ ask = {
171
+ id: config.ask,
172
+ alias: tagAlias(ALIAS_PREFIX.ask, config.ask),
173
+ };
174
+ if (client) {
175
+ try {
176
+ const a = await client.get(`/asks/${config.ask}`);
177
+ if (a?.name)
178
+ ask.name = a.name;
179
+ }
180
+ catch { /* keep id+alias only */ }
181
+ }
182
+ }
183
+ const payload = {
184
+ user,
185
+ workspace,
186
+ study,
187
+ ask,
188
+ api_url: apiUrl,
189
+ home: ishDir(),
190
+ };
191
+ if (tokenError && !token)
192
+ payload.hint = `Run \`ish login\`. (${tokenError})`;
193
+ if (globals.json) {
194
+ output(payload, true);
195
+ return;
196
+ }
197
+ // Human output
198
+ const fmtSeconds = (s) => {
199
+ if (s <= 0)
200
+ return "expired";
201
+ const m = Math.floor(s / 60);
202
+ const h = Math.floor(m / 60);
203
+ if (h > 0)
204
+ return `${h}h${m % 60}m`;
205
+ return `${m}m`;
206
+ };
207
+ console.log(`User: ${user ? `${user.email ?? "(no email)"} (token ${user.token_valid ? "valid" : "expired"}, expires in ${fmtSeconds(user.expires_in_seconds)})` : "(not logged in — run `ish login`)"}`);
208
+ console.log(`Workspace: ${workspace ? `${workspace.name ?? "(name unavailable)"} (${workspace.alias})` : "—"}`);
209
+ console.log(`Study: ${study ? `${study.name ?? "(name unavailable)"} (${study.alias})` : "—"}`);
210
+ console.log(`Ask: ${ask ? `${ask.name ?? "(name unavailable)"} (${ask.alias})` : "—"}`);
211
+ console.log(`Home: ${ishDir()}`);
212
+ console.log(`API: ${apiUrl}`);
213
+ if (tokenError && !token)
214
+ console.error(`\nHint: ${payload.hint}`);
215
+ });
216
+ });
101
217
  program
102
218
  .command("connect")
103
219
  .description("Expose your localhost to Ish via a Cloudflare tunnel")
@@ -134,4 +250,5 @@ program
134
250
  .action(async (options) => {
135
251
  await upgrade(version, options.release);
136
252
  });
253
+ injectGlobalWorkspaceOption(program);
137
254
  program.parse();
@@ -31,15 +31,30 @@ export class ApiError extends Error {
31
31
  error_code;
32
32
  retryable;
33
33
  constructor(status, statusText, body) {
34
- const msg = typeof body === "object" && body !== null && "detail" in body
35
- ? String(body.detail)
36
- : `HTTP ${status} ${statusText}`;
34
+ // FastAPI HTTPException(detail=...) wraps detail under a top-level "detail" key.
35
+ // When detail is a structured object (our convention for typed errors),
36
+ // pull the message off detail.detail and prefer detail.error_code.
37
+ let detail;
38
+ let bodyErrorCode;
39
+ if (typeof body === "object" && body !== null && "detail" in body) {
40
+ detail = body.detail;
41
+ if (typeof detail === "object" && detail !== null && "error_code" in detail
42
+ && typeof detail.error_code === "string") {
43
+ bodyErrorCode = detail.error_code;
44
+ }
45
+ }
46
+ const detailMsg = typeof detail === "string"
47
+ ? detail
48
+ : (typeof detail === "object" && detail !== null && "detail" in detail
49
+ ? String(detail.detail)
50
+ : undefined);
51
+ const msg = detailMsg ?? `HTTP ${status} ${statusText}`;
37
52
  super(msg);
38
53
  this.status = status;
39
54
  this.statusText = statusText;
40
55
  this.body = body;
41
56
  this.name = "ApiError";
42
- this.error_code = mapErrorCode(status);
57
+ this.error_code = bodyErrorCode ?? mapErrorCode(status);
43
58
  this.retryable = isRetryable(status);
44
59
  }
45
60
  }
@@ -176,7 +191,9 @@ export class ApiClient {
176
191
  return this.handleResponse(res);
177
192
  }
178
193
  async del(path, opts) {
179
- const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
194
+ // Deletes typically cascade (rounds → responses → testers → audience),
195
+ // so default to a longer timeout than the standard 15s.
196
+ const timeout = opts?.timeout ?? 60_000;
180
197
  const url = `${this.baseUrl}${path}`;
181
198
  let res;
182
199
  try {
@@ -187,8 +204,13 @@ export class ApiClient {
187
204
  });
188
205
  }
189
206
  catch (err) {
190
- if (isAbortTimeout(err))
191
- throw timeoutError("DELETE", timeout);
207
+ if (isAbortTimeout(err)) {
208
+ const seconds = Math.round(timeout / 1000);
209
+ throw new ApiError(408, "Request Timeout", {
210
+ detail: `DELETE request timed out after ${seconds}s. The deletion may have completed server-side — ` +
211
+ `re-fetch the resource (e.g. \`ish ask get <id>\`) before retrying to avoid a 404.`,
212
+ });
213
+ }
192
214
  if (err instanceof TypeError)
193
215
  throw networkError(url);
194
216
  throw err;
@@ -100,3 +100,17 @@ export declare function collectRepeatable(value: string, prev?: string[]): strin
100
100
  export declare function collectIds(value: string, prev?: string[]): string[];
101
101
  /** Parse a `--timeout <seconds>` flag into milliseconds, with validation. */
102
102
  export declare function parseWaitTimeout(raw: string | undefined, defaultMs?: number): number;
103
+ /**
104
+ * Inject `--workspace <id>` on every leaf subcommand under the workspace-scoped
105
+ * groups that doesn't already declare it. Run once after all `registerXxxCommands`
106
+ * calls have populated the program tree. Agents reflexively pass `--workspace`
107
+ * on any command — without this, Commander rejects it with a generic "unknown
108
+ * option" on read-side commands like `ask delete`, `ask get`, `study get`,
109
+ * `profile get`, etc.
110
+ *
111
+ * The injected option is documentation-only on commands where the workspace is
112
+ * inferred from an ID alias; it's accepted and then discarded by the action
113
+ * body. Resolvers (`resolveWorkspace`, `resolveAudienceProfileIds`) ignore
114
+ * unused values.
115
+ */
116
+ export declare function injectGlobalWorkspaceOption(program: Command): void;
@@ -86,6 +86,9 @@ export async function resolveAudienceProfileIds(client, workspace, flags, opts =
86
86
  }
87
87
  return explicit;
88
88
  }
89
+ if (sampleN !== undefined && flags.all) {
90
+ throw new Error(`Use either --sample <N> or ${allFlagName}, not both. --sample picks a random subset; ${allFlagName} returns every match.`);
91
+ }
89
92
  if (sampleN === undefined && !flags.all && !filtersUsed) {
90
93
  throw new Error(`Pick an audience: pass --profile <id> (repeatable), --sample <N>, ${allFlagName}, or filter flags (--country, --gender, --min-age, --max-age, --search, --visibility).`);
91
94
  }
@@ -355,3 +358,40 @@ export function parseWaitTimeout(raw, defaultMs = 5 * 60 * 1000) {
355
358
  }
356
359
  return n * 1000;
357
360
  }
361
+ /** Top-level command groups whose subcommands should accept `--workspace`. */
362
+ const WORKSPACE_SCOPED_GROUPS = new Set([
363
+ "ask",
364
+ "study",
365
+ "iteration",
366
+ "profile",
367
+ "source",
368
+ ]);
369
+ /**
370
+ * Inject `--workspace <id>` on every leaf subcommand under the workspace-scoped
371
+ * groups that doesn't already declare it. Run once after all `registerXxxCommands`
372
+ * calls have populated the program tree. Agents reflexively pass `--workspace`
373
+ * on any command — without this, Commander rejects it with a generic "unknown
374
+ * option" on read-side commands like `ask delete`, `ask get`, `study get`,
375
+ * `profile get`, etc.
376
+ *
377
+ * The injected option is documentation-only on commands where the workspace is
378
+ * inferred from an ID alias; it's accepted and then discarded by the action
379
+ * body. Resolvers (`resolveWorkspace`, `resolveAudienceProfileIds`) ignore
380
+ * unused values.
381
+ */
382
+ export function injectGlobalWorkspaceOption(program) {
383
+ const walk = (cmd) => {
384
+ if (cmd.commands.length === 0) {
385
+ const hasWorkspace = cmd.options.some((o) => o.long === "--workspace");
386
+ if (!hasWorkspace) {
387
+ cmd.option("--workspace <id>", "Workspace ID; accepted for consistency (inferred from alias / active context)");
388
+ }
389
+ }
390
+ for (const sub of cmd.commands)
391
+ walk(sub);
392
+ };
393
+ for (const top of program.commands) {
394
+ if (WORKSPACE_SCOPED_GROUPS.has(top.name()))
395
+ walk(top);
396
+ }
397
+ }