clever-tools 4.10.0 → 4.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clever-tools",
3
- "version": "4.10.0",
3
+ "version": "4.11.0",
4
4
  "description": "Command Line Interface for Clever Cloud.",
5
5
  "keywords": [
6
6
  "cli",
@@ -108,3 +108,20 @@ export function enableDrain(params) {
108
108
  // no body
109
109
  });
110
110
  }
111
+
112
+ /**
113
+ * POST /v4/drains/organisations/{ownerId}/resources/{resourceId}/drains/{drainId}/check
114
+ * @param {Object} params
115
+ * @param {String} params.ownerId
116
+ * @param {String} params.resourceId
117
+ * @param {String} params.drainId
118
+ */
119
+ export function checkDrain(params) {
120
+ // no multipath for /self or /organisations/{id}
121
+ return Promise.resolve({
122
+ method: 'post',
123
+ url: `/v4/drains/organisations/${params.ownerId}/resources/${params.resourceId}/drains/${params.drainId}/check`,
124
+ headers: { Accept: 'application/json' },
125
+ // no body
126
+ });
127
+ }
@@ -1,5 +1,6 @@
1
1
  import { ApplicationAccessLogStream } from '@clevercloud/client/esm/streams/access-logs.js';
2
2
  import { formatTable } from '../../format-table.js';
3
+ import { formatClf } from '../../lib/access-logs-clf.js';
3
4
  import { defineCommand } from '../../lib/define-command.js';
4
5
  import { styleText } from '../../lib/style-text.js';
5
6
  import { Logger } from '../../logger.js';
@@ -8,12 +9,12 @@ import { JsonArray } from '../../models/json-array.js';
8
9
  import { getHostAndTokens } from '../../models/send-to-api.js';
9
10
  import { truncateWithEllipsis } from '../../models/utils.js';
10
11
  import {
12
+ accessLogsFormatOption,
11
13
  addonIdOrRealIdOption,
12
14
  afterOption,
13
15
  aliasOption,
14
16
  appIdOrNameOption,
15
17
  beforeOption,
16
- logsFormatOption,
17
18
  } from '../global.options.js';
18
19
 
19
20
  const THROTTLE_ELEMENTS = 2000;
@@ -76,7 +77,7 @@ export const accesslogsCommand = defineCommand({
76
77
  options: {
77
78
  alias: aliasOption,
78
79
  app: appIdOrNameOption,
79
- format: logsFormatOption,
80
+ format: accessLogsFormatOption,
80
81
  before: beforeOption,
81
82
  after: afterOption,
82
83
  addon: addonIdOrRealIdOption,
@@ -132,6 +133,14 @@ export const accesslogsCommand = defineCommand({
132
133
  case 'json-stream':
133
134
  Logger.printJson(log);
134
135
  break;
136
+ case 'clf':
137
+ // when the connection is cut too early, or for TCP redirections, we don't have HTTP section
138
+ if (log.http == null) {
139
+ break;
140
+ }
141
+
142
+ Logger.println(formatClf(log));
143
+ break;
135
144
  case 'human':
136
145
  default:
137
146
  // when the connection is cut too early, or for TCP redirections, we don't have HTTP section
@@ -17,4 +17,4 @@ clever accesslogs [options]
17
17
  |`-a`, `--alias` `<alias>`|Short name for the application|
18
18
  |`--app` `<app-id\|app-name>`|Application to manage by its ID (or name, if unambiguous)|
19
19
  |`--before`, `--until` `<before>`|Fetch logs before this date/time (ISO8601 date, positive number in seconds or duration, e.g.: 1h)|
20
- |`-F`, `--format` `<format>`|Output format (human, json, json-stream) (default: human)|
20
+ |`-F`, `--format` `<format>`|Output format (human, json, json-stream, clf) (default: human)|
@@ -9,6 +9,7 @@ import { Logger } from '../../logger.js';
9
9
  import * as AppConfig from '../../models/app_configuration.js';
10
10
  import * as Application from '../../models/application.js';
11
11
  import { AVAILABLE_ZONES, listAvailableTypes, listAvailableZones } from '../../models/application.js';
12
+ import { LinkedWorktreeNotSupportedError } from '../../models/git-isomorphic.js';
12
13
  import { Git } from '../../models/git.js';
13
14
  import { aliasCreationOption, humanJsonOutputFormatOption, orgaIdOrNameOption } from '../global.options.js';
14
15
 
@@ -54,7 +55,13 @@ async function displayAppCreation(app, alias, github, taskCommand) {
54
55
  Logger.println(` ${shellCommand('git commit -m "Initial commit"')}`);
55
56
  Logger.println();
56
57
  } else {
57
- const isClean = await git.isGitWorkingDirectoryClean();
58
+ // Best-effort hint: if we can't tell because of a linked worktree on the JS backend, don't nag
59
+ const isClean = await git.isGitWorkingDirectoryClean().catch((error) => {
60
+ if (error instanceof LinkedWorktreeNotSupportedError) {
61
+ return true;
62
+ }
63
+ throw error;
64
+ });
58
65
  if (!isClean) {
59
66
  Logger.println(` ${styleText('yellow', '!')} Commit your changes first:`);
60
67
  Logger.println(` ${shellCommand('git add .')}`);
@@ -28,6 +28,7 @@ Clever Tools uses a current JS implementation for git operations. This works wit
28
28
  * **HTTP-only**: cannot use SSH-based git protocols
29
29
  * **Slow performance** on repositories with rewritten history (rebases, squashes)
30
30
  * **Connection timeouts** on large repositories or when pushing big files, due to HTTP-based transfers
31
+ * **No git worktree support**: deploying from a linked git worktree (`git worktree add`) fails with `Could not find HEAD`. The system git backend deploys from worktrees just like from the main working tree
31
32
 
32
33
  If you experience any of these issues, you can enable the **system git backend** which uses the `git` command installed on your system (it must be in your `PATH` environment variable).
33
34
 
@@ -0,0 +1,43 @@
1
+ import { checkDrain } from '../../clever-client/drains.js';
2
+ import { defineCommand } from '../../lib/define-command.js';
3
+ import { styleText } from '../../lib/style-text.js';
4
+ import { Logger } from '../../logger.js';
5
+ import { resolveDrainResource } from '../../models/drain.js';
6
+ import { sendToApi } from '../../models/send-to-api.js';
7
+ import {
8
+ addonIdOrRealIdOption,
9
+ aliasOption,
10
+ appIdOrNameOption,
11
+ humanJsonOutputFormatOption,
12
+ } from '../global.options.js';
13
+ import { drainIdArg } from './drain.args.js';
14
+
15
+ export const drainCheckCommand = defineCommand({
16
+ description: "Check that a drain's recipient is reachable and accepts deliveries",
17
+ since: '4.11.0',
18
+ options: {
19
+ alias: aliasOption,
20
+ appIdOrName: appIdOrNameOption,
21
+ addonIdOrRealId: addonIdOrRealIdOption,
22
+ format: humanJsonOutputFormatOption,
23
+ },
24
+ args: [drainIdArg],
25
+ async handler(options, drainId) {
26
+ const { alias, appIdOrName, addonIdOrRealId, format } = options;
27
+ const { ownerId, resourceId } = await resolveDrainResource(alias, appIdOrName, addonIdOrRealId);
28
+
29
+ const probe = await checkDrain({ ownerId, resourceId, drainId }).then(sendToApi);
30
+ switch (format) {
31
+ case 'json': {
32
+ Logger.printJson({ ok: probe.ok, message: probe.message });
33
+ break;
34
+ }
35
+ case 'human':
36
+ default: {
37
+ const status = probe.ok ? styleText(['bold', 'green'], 'OK') : styleText(['bold', 'red'], 'FAILED');
38
+ Logger.println(`Probe: ${status}`);
39
+ Logger.println(probe.message);
40
+ }
41
+ }
42
+ },
43
+ });
@@ -34,6 +34,13 @@ export const drainCreateCommand = defineCommand({
34
34
  aliases: ['k'],
35
35
  placeholder: 'api-key',
36
36
  }),
37
+ sourceToken: defineOption({
38
+ name: 'source-token',
39
+ schema: z.string().optional(),
40
+ description: 'Source token (for betterstack)',
41
+ aliases: ['t'],
42
+ placeholder: 'source-token',
43
+ }),
37
44
  indexPrefix: defineOption({
38
45
  name: 'index-prefix',
39
46
  schema: z.string().optional(),
@@ -66,7 +73,7 @@ export const drainCreateCommand = defineCommand({
66
73
  ],
67
74
  async handler(options, drainTypeCliCode, url) {
68
75
  const { alias, appIdOrName, addonIdOrRealId } = options;
69
- const { username, password, apiKey, indexPrefix, rfc5424StructuredDataParameters } = options;
76
+ const { username, password, apiKey, sourceToken, indexPrefix, rfc5424StructuredDataParameters } = options;
70
77
 
71
78
  const drainType = Object.values(DRAIN_TYPES).find((drainType) => drainType.cliCode === drainTypeCliCode);
72
79
 
@@ -108,6 +115,13 @@ export const drainCreateCommand = defineCommand({
108
115
  body.recipient.apiKey = apiKey;
109
116
  }
110
117
 
118
+ if (drainTypeCliCode === DRAIN_TYPES.BETTERSTACK.cliCode) {
119
+ if (!sourceToken) {
120
+ throw new Error(`${DRAIN_TYPES.BETTERSTACK.cliCode} drains require a source token (--source-token) to be set`);
121
+ }
122
+ body.recipient.sourceToken = sourceToken;
123
+ }
124
+
111
125
  if (
112
126
  drainTypeCliCode === DRAIN_TYPES.OVH_TCP.cliCode ||
113
127
  drainTypeCliCode === DRAIN_TYPES.SYSLOG_TCP.cliCode ||
@@ -17,6 +17,29 @@ clever drain [options]
17
17
  |`--app` `<app-id\|app-name>`|Application to manage by its ID (or name, if unambiguous)|
18
18
  |`-F`, `--format` `<format>`|Output format (human, json) (default: human)|
19
19
 
20
+ ## ➡️ `clever drain check` <kbd>Since 4.11.0</kbd>
21
+
22
+ Check that a drain's recipient is reachable and accepts deliveries
23
+
24
+ ```bash
25
+ clever drain check <drain-id> [options]
26
+ ```
27
+
28
+ ### 📥 Arguments
29
+
30
+ |Name|Description|
31
+ |---|---|
32
+ |`drain-id`|Drain ID|
33
+
34
+ ### ⚙️ Options
35
+
36
+ |Name|Description|
37
+ |---|---|
38
+ |`--addon` `<addon-id>`|Add-on ID or real ID|
39
+ |`-a`, `--alias` `<alias>`|Short name for the application|
40
+ |`--app` `<app-id\|app-name>`|Application to manage by its ID (or name, if unambiguous)|
41
+ |`-F`, `--format` `<format>`|Output format (human, json) (default: human)|
42
+
20
43
  ## ➡️ `clever drain create` <kbd>Since 0.9.0</kbd>
21
44
 
22
45
  Create a drain
@@ -29,7 +52,7 @@ clever drain create <drain-type> <drain-url> [options]
29
52
 
30
53
  |Name|Description|
31
54
  |---|---|
32
- |`drain-type`|Drain type (datadog, elasticsearch, newrelic, ovh-tcp, raw-http, syslog-tcp, syslog-udp)|
55
+ |`drain-type`|Drain type (betterstack, datadog, elasticsearch, newrelic, ovh-tcp, raw-http, syslog-tcp, syslog-udp)|
33
56
  |`drain-url`|Drain URL|
34
57
 
35
58
  ### ⚙️ Options
@@ -43,6 +66,7 @@ clever drain create <drain-type> <drain-url> [options]
43
66
  |`-i`, `--index-prefix` `<index-prefix>`|Optional index prefix (for elasticsearch), `logstash` value is used if not set|
44
67
  |`-p`, `--password` `<password>`|Basic auth password (for elasticsearch or raw-http)|
45
68
  |`-s`, `--sd-params` `<sd-params>`|RFC5424 structured data parameters (for ovh-tcp), e.g.: `X-OVH-TOKEN=\"REDACTED\"`|
69
+ |`-t`, `--source-token` `<source-token>`|Source token (for betterstack)|
46
70
  |`-u`, `--username` `<username>`|Basic auth username (for elasticsearch or raw-http)|
47
71
 
48
72
  ## ➡️ `clever drain disable` <kbd>Since 0.9.0</kbd>
@@ -39,6 +39,7 @@ import { domainFavouriteSetCommand } from './domain/domain.favourite.set.command
39
39
  import { domainFavouriteUnsetCommand } from './domain/domain.favourite.unset.command.js';
40
40
  import { domainOverviewCommand } from './domain/domain.overview.command.js';
41
41
  import { domainRmCommand } from './domain/domain.rm.command.js';
42
+ import { drainCheckCommand } from './drain/drain.check.command.js';
42
43
  import { drainCommand } from './drain/drain.command.js';
43
44
  import { drainCreateCommand } from './drain/drain.create.command.js';
44
45
  import { drainDisableCommand } from './drain/drain.disable.command.js';
@@ -268,6 +269,7 @@ export const globalCommands = {
268
269
  drain: [
269
270
  drainCommand,
270
271
  {
272
+ check: drainCheckCommand,
271
273
  create: drainCreateCommand,
272
274
  disable: drainDisableCommand,
273
275
  enable: drainEnableCommand,
@@ -28,6 +28,14 @@ export const logsFormatOption = defineOption({
28
28
  placeholder: 'format',
29
29
  });
30
30
 
31
+ export const accessLogsFormatOption = defineOption({
32
+ name: 'format',
33
+ schema: z.enum(['human', 'json', 'json-stream', 'clf']).default('human'),
34
+ description: 'Output format',
35
+ aliases: ['F'],
36
+ placeholder: 'format',
37
+ });
38
+
31
39
  export const beforeOption = defineOption({
32
40
  name: 'before',
33
41
  schema: z.string().transform(date).optional(),
@@ -75,6 +75,10 @@ async function resolveTargetProfile(profiles, requestedAlias) {
75
75
  name: formatProfile(profile),
76
76
  value: profile.alias,
77
77
  }));
78
- const selectedAlias = await selectAnswer('Select a profile:', choices);
78
+ const selectedAlias = await selectAnswer(
79
+ 'Select a profile:',
80
+ choices,
81
+ 'Use --alias <name> to select a profile directly.',
82
+ );
79
83
  return profiles.find((profile) => profile.alias === selectedAlias);
80
84
  }
@@ -45,7 +45,7 @@ export const sshCommand = defineCommand({
45
45
  let sshTarget;
46
46
  if (instances.length === 1) {
47
47
  sshTarget = instances[0].id;
48
- } else if (process.stdout.isTTY) {
48
+ } else if (process.stdin.isTTY) {
49
49
  const choices = instances
50
50
  .sort((a, b) => a.instanceNumber - b.instanceNumber)
51
51
  .map((inst) => ({
@@ -0,0 +1,52 @@
1
+ const CLF_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
2
+
3
+ /**
4
+ * Format a `Date` as a Common Log Format timestamp in UTC, e.g. `10/Oct/2000:13:55:36 +0000`.
5
+ * @param {Date} date
6
+ * @returns {string}
7
+ */
8
+ function formatClfDate(date) {
9
+ const pad = (n) => String(n).padStart(2, '0');
10
+ const day = pad(date.getUTCDate());
11
+ const month = CLF_MONTHS[date.getUTCMonth()];
12
+ const year = date.getUTCFullYear();
13
+ const hours = pad(date.getUTCHours());
14
+ const minutes = pad(date.getUTCMinutes());
15
+ const seconds = pad(date.getUTCSeconds());
16
+ return `${day}/${month}/${year}:${hours}:${minutes}:${seconds} +0000`;
17
+ }
18
+
19
+ /**
20
+ * Escape a value so it can be safely embedded inside a double-quoted CLF field.
21
+ * Backslashes are escaped first, then double-quotes, otherwise a `"` would be escaped
22
+ * twice. Without this, a `"` in the value would close the quoted field early and shift
23
+ * every subsequent column.
24
+ * @param {string} value
25
+ * @returns {string}
26
+ */
27
+ function escapeClfQuoted(value) {
28
+ return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
29
+ }
30
+
31
+ /**
32
+ * Format an HTTP access log as a Common Log Format line.
33
+ *
34
+ * The HTTP protocol version is absent from the v4 access log payload, so the request line is
35
+ * limited to `method path` (no `HTTP/x.y` token). The protocol token is optional in CLF parsers
36
+ * (grok, GoAccess), and emitting a `-` placeholder would actually break their structured parsing.
37
+ *
38
+ * @see https://en.wikipedia.org/wiki/Common_Log_Format
39
+ * @param {object} log an HTTP access log (the `http` section must be present)
40
+ * @returns {string}
41
+ */
42
+ export function formatClf(log) {
43
+ const host = log.source.ip;
44
+ const ident = '-';
45
+ const authuser = '-';
46
+ const date = formatClfDate(log.date);
47
+ const request = `${log.http.request.method} ${escapeClfQuoted(log.http.request.path)}`;
48
+ const status = log.http.response.statusCode;
49
+ const bytes = log.bytesOut;
50
+
51
+ return `${host} ${ident} ${authuser} [${date}] "${request}" ${status} ${bytes}`;
52
+ }
@@ -1,4 +1,5 @@
1
1
  import cliparseOriginal from 'cliparse';
2
+ import cliparseArgumentModule from 'cliparse/src/argument.js';
2
3
  import cliparseCommandModule from 'cliparse/src/command.js';
3
4
  import semver from 'semver';
4
5
  import pkg from '../../package.json' with { type: 'json' };
@@ -33,6 +34,23 @@ cliparseOriginal.command = function (name, options, commandFunction) {
33
34
  return command;
34
35
  };
35
36
 
37
+ // Patch cliparse.argument.parseList to drop the parse results that succeeded
38
+ // from its error payload. When several positional args are given and only one
39
+ // fails its parser, parseList returns the *whole* result list (successes
40
+ // included) as the error. Downstream, displayErrors prints the valid siblings
41
+ // as `<arg-name>: undefined` (they carry an `.argument` but no `.error`).
42
+ // We keep only the entries that actually failed, fixing the issue at its root.
43
+ // The success path is untouched (we only rewrite the error array), and
44
+ // missing-value errors carry an `.error` ("missing value") so they are kept.
45
+ const originalParseList = cliparseArgumentModule.parseList;
46
+ cliparseArgumentModule.parseList = function (args, providedArguments) {
47
+ const result = originalParseList(args, providedArguments);
48
+ if (Array.isArray(result.error)) {
49
+ return cliparseOriginal.parsers.error(result.error.filter((entry) => entry.error != null));
50
+ }
51
+ return result;
52
+ };
53
+
36
54
  /**
37
55
  * Map options from cliparse format (using option.name) to the original definition keys.
38
56
  * For example, { theOptionName: defineOption({ name: 'the-option-name', ... }) },
@@ -41,8 +41,13 @@ export interface CommandDefinition<
41
41
  */
42
42
  featureFlag?: string;
43
43
 
44
- /** Version when this command was introduced (semver format, e.g., '2.1.0'). */
45
- since?: `${number}.${number}.${number}`;
44
+ /**
45
+ * Version when this command was introduced (semver format, e.g., '2.1.0').
46
+ * Use `null` for a command not yet released: the release workflow resolves it
47
+ * to the upcoming version (see `scripts/resolve-since.js`). Required on purpose,
48
+ * so a missing value is a type error rather than a silent omission.
49
+ */
50
+ since: `${number}.${number}.${number}` | null;
46
51
 
47
52
  /** Options (named options like --type, --region, --format). */
48
53
  options?: O;
package/src/lib/k8s.js CHANGED
@@ -503,6 +503,7 @@ export async function k8sUpdateVersion(orgIdOrName, clusterIdOrName, askedVersio
503
503
  (await selectAnswer(
504
504
  `Which version do you want to update ${styleText('blue', name)} to, current is ${styleText('blue', versions.installed)}?`,
505
505
  [...versions.available].reverse(),
506
+ 'Use --target <version> to update directly.',
506
507
  ));
507
508
 
508
509
  if (!versions.available.includes(targetVersion)) {
@@ -77,6 +77,7 @@ export async function operatorUpdateVersion(provider, askedVersion, addonIdOrNam
77
77
  (await selectAnswer(
78
78
  `Which version do you want to update ${styleText('blue', name)} to, current is ${styleText('blue', versions.installed)}?`,
79
79
  versions.available.reverse(),
80
+ 'Use --target <version> to update directly.',
80
81
  ));
81
82
 
82
83
  if (!versions.available.includes(targetVersion)) {
@@ -59,7 +59,14 @@ export async function getProfileDetails({ profile, isActive }) {
59
59
  const [user, token] = await Promise.all([
60
60
  getUser({}).then(sendWithCredentials),
61
61
  getCurrentTokenInfo().then(sendWithCredentials),
62
- ]).catch(() => [null, null]);
62
+ ]).catch((error) => {
63
+ // An expired/invalid token surfaces as a 401: degrade gracefully so the command can report it.
64
+ // Any other failure (TLS, network…) must bubble up instead of being masked as "token invalid".
65
+ if (error?.cause?.response?.status === 401) {
66
+ return [null, null];
67
+ }
68
+ throw error;
69
+ });
63
70
 
64
71
  return {
65
72
  id: user?.id ?? profile.userId,
@@ -25,11 +25,23 @@ export async function confirmAnswer(message, rejectionMessage, expectedAnswer) {
25
25
  }
26
26
  }
27
27
 
28
- export function selectAnswer(message, choices) {
28
+ /**
29
+ * @param {string} message
30
+ * @param {Array<unknown>} choices
31
+ * @param {string} [nonInteractiveHint] How to achieve the same result without a prompt (e.g. an option to use)
32
+ */
33
+ export function selectAnswer(message, choices, nonInteractiveHint) {
34
+ assertInteractiveTerminal(nonInteractiveHint);
29
35
  return select({ message, choices }).catch(exitOnPromptError);
30
36
  }
31
37
 
32
- export function promptCheckbox(message, choices) {
38
+ /**
39
+ * @param {string} message
40
+ * @param {Array<unknown>} choices
41
+ * @param {string} [nonInteractiveHint] How to achieve the same result without a prompt (e.g. an option to use)
42
+ */
43
+ export function promptCheckbox(message, choices, nonInteractiveHint) {
44
+ assertInteractiveTerminal(nonInteractiveHint);
33
45
  return checkbox({ message, choices }).catch(exitOnPromptError);
34
46
  }
35
47
 
@@ -50,6 +62,22 @@ export function promptTextOption(option, defaultValue) {
50
62
  return input({ message, default: defaultValue, validate }).catch(exitOnPromptError);
51
63
  }
52
64
 
65
+ /**
66
+ * Throw an explicit error when no interactive terminal is available.
67
+ *
68
+ * Raw-mode prompts (select, checkbox) need a TTY on stdin. Without one (CI, scripts,
69
+ * pipes, `< /dev/null`), Inquirer reads EOF and throws ExitPromptError, which
70
+ * exitOnPromptError turns into a silent `process.exit(1)` — indistinguishable from a
71
+ * user pressing Ctrl+C. Guarding up front lets us surface a clear message instead.
72
+ * @param {string} [hint] How to achieve the same result without a prompt (e.g. an option to use)
73
+ */
74
+ function assertInteractiveTerminal(hint) {
75
+ if (!process.stdin.isTTY) {
76
+ const suffix = hint != null ? ` ${hint}` : '';
77
+ throw new Error(`This command requires an interactive terminal.${suffix}`);
78
+ }
79
+ }
80
+
53
81
  function exitOnPromptError(error) {
54
82
  if (error instanceof Error && error.name === 'ExitPromptError') {
55
83
  process.exit(1);
@@ -16,6 +16,7 @@ export async function resolveDrainResource(alias, appIdOrName, addonIdOrRealId)
16
16
  }
17
17
 
18
18
  export const DRAIN_TYPES = {
19
+ BETTERSTACK: { apiCode: 'BETTERSTACK', cliCode: 'betterstack', label: 'Better Stack' },
19
20
  DATADOG: { apiCode: 'DATADOG', cliCode: 'datadog', label: 'Datadog' },
20
21
  ELASTICSEARCH: { apiCode: 'ELASTICSEARCH', cliCode: 'elasticsearch', label: 'Elasticsearch' },
21
22
  NEWRELIC: { apiCode: 'NEWRELIC', cliCode: 'newrelic', label: 'New Relic' },
@@ -13,6 +13,9 @@ export class GitIsomorphic extends Git {
13
13
 
14
14
  async #getRepo() {
15
15
  const dir = await this._getRepoDir();
16
+ if (await this._isLinkedWorktree(dir)) {
17
+ throw new LinkedWorktreeNotSupportedError();
18
+ }
16
19
  return { fs, dir, http };
17
20
  }
18
21
 
@@ -128,7 +131,8 @@ export class GitIsomorphic extends Git {
128
131
  */
129
132
  async isInsideGitRepo() {
130
133
  this._debug('isInsideGitRepo');
131
- return this.#getRepo()
134
+ // Don't go through #getRepo: it rejects linked worktrees, which are still git repos
135
+ return this._getRepoDir()
132
136
  .then(() => true)
133
137
  .catch(() => false);
134
138
  }
@@ -151,3 +155,14 @@ export class GitIsomorphic extends Git {
151
155
  return isStatusEmpty;
152
156
  }
153
157
  }
158
+
159
+ export class LinkedWorktreeNotSupportedError extends Error {
160
+ constructor() {
161
+ super(
162
+ "Linked git worktrees aren't supported by the default JS git backend.\n" +
163
+ 'Enable the system git backend (it uses your installed git):\n' +
164
+ ' clever features enable system-git',
165
+ );
166
+ this.name = 'LinkedWorktreeNotSupportedError';
167
+ }
168
+ }
package/src/models/git.js CHANGED
@@ -66,6 +66,24 @@ export class Git {
66
66
  }
67
67
  }
68
68
 
69
+ /**
70
+ * Check if the repository is a linked git worktree.
71
+ * In a linked worktree (created with `git worktree add`), the top-level `.git`
72
+ * is a file holding a `gitdir:` pointer instead of a directory.
73
+ * @protected
74
+ * @param {string} [dir] - Repository directory (resolved automatically when omitted)
75
+ * @returns {Promise<boolean>}
76
+ */
77
+ async _isLinkedWorktree(dir) {
78
+ const repoDir = dir ?? (await this._getRepoDir());
79
+ try {
80
+ const stats = await fs.promises.stat(path.join(repoDir, '.git'));
81
+ return stats.isFile();
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
69
87
  /**
70
88
  * Add a remote to the repository
71
89
  * @param {string} remoteName
@@ -47,7 +47,7 @@ export async function promptRights(existingRights) {
47
47
  checked: existingRights?.[apiName] ?? false,
48
48
  }));
49
49
 
50
- const selected = await promptCheckbox('Select rights', choices);
50
+ const selected = await promptCheckbox('Select rights', choices, 'Use --rights <list> to set rights directly.');
51
51
 
52
52
  return rightsFromList(selected);
53
53
  }
@@ -75,6 +75,15 @@ async function executeRequest(requestParams, customConfig = {}) {
75
75
  .catch(processError);
76
76
  }
77
77
 
78
+ // OpenSSL error codes raised when the server certificate chain isn't trusted
79
+ // (corporate TLS-intercepting proxy, private or self-signed CA…).
80
+ const TLS_ERROR_CODES = [
81
+ 'SELF_SIGNED_CERT_IN_CHAIN',
82
+ 'DEPTH_ZERO_SELF_SIGNED_CERT',
83
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
84
+ 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
85
+ ];
86
+
78
87
  export function processError(error) {
79
88
  const code = error.code ?? error?.cause?.code;
80
89
  if (code === 'EAI_AGAIN') {
@@ -89,6 +98,12 @@ export function processError(error) {
89
98
  { cause: error },
90
99
  );
91
100
  }
101
+ if (TLS_ERROR_CODES.includes(code)) {
102
+ throw new Error(
103
+ `TLS certificate verification failed (${code}). If you're behind a corporate proxy or using a private/self-signed Certificate Authority, trust your CA and follow the "TLS certificates" section of the documentation.`,
104
+ { cause: error },
105
+ );
106
+ }
92
107
  throw error;
93
108
  }
94
109