@designcrowd/fe-shared-lib 1.8.4-edge-fallback-2 → 1.8.4-edge-fallback-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.
package/CLAUDE.md CHANGED
@@ -123,15 +123,7 @@ npm link @designcrowd/fe-shared-lib # in consumer project
123
123
 
124
124
  ## Publishing (UAT)
125
125
 
126
- Use `scripts/publish-uat.sh <suffix>` to publish a UAT build for testing in a consumer (e.g. BrandCrowd.Net). The script handles the version bump, bundle-translation, secret scanning of the tarball, and the commit. Examples:
127
-
128
- ```bash
129
- ./scripts/publish-uat.sh edge-fallback-0
130
- ./scripts/publish-uat.sh edge-fallback-1 # next iteration
131
- ```
132
-
133
- It refuses to run on `master`/`main`, refuses if the version is already published, aborts on dangerous filenames or token-shaped strings inside the tarball, and only commits the bump after the registry confirms upload.
134
-
135
- The npm publish token is read from `~/.config/designcrowd/npm-publish-token` (mode 600). Override with `NPM_PUBLISH_TOKEN_FILE` if it lives elsewhere. The token file is **not** stored anywhere in the repo.
136
-
137
- The legacy `docker build . --build-arg NPM_TOKEN=...` flow still works but is brittle — prefer the script.
126
+ To test experimental versions without publishing to production:
127
+ 1. Update `package.json` version to `[current]-[description]`
128
+ 2. Run `docker build . --build-arg NPM_TOKEN=$NPM_TOKEN`
129
+ 3. Update consumer package reference to the new version
package/index.js CHANGED
@@ -23,6 +23,7 @@ export { WEBSITE_UPGRADE_CONTEXT_TYPES } from './src/experiences/models/websiteC
23
23
 
24
24
  export { setSharedLibLocaleAsync, tr } from './src/useSharedLibTranslate';
25
25
  export { useVoiceToText } from './src/useVoiceToText';
26
+ export { setVoiceToTextSessionDisabledForTesting, VOICE_UNAVAILABLE_MESSAGE } from './src/useVoiceToText';
26
27
 
27
28
  export { default as Button } from './src/atoms/components/Button/Button.vue';
28
29
  export { default as ButtonGroup } from './src/atoms/components/ButtonGroup/ButtonGroup.vue';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@designcrowd/fe-shared-lib",
3
- "version": "1.8.4-edge-fallback-2",
3
+ "version": "1.8.4-edge-fallback-4",
4
4
  "scripts": {
5
5
  "start": "run-p storybook watch:translation",
6
6
  "build": "npm run build:css --production",
@@ -1,5 +1,5 @@
1
1
  import VoiceToTextButton from './VoiceToTextButton.vue';
2
- import { __setVoiceToTextSessionDisabled } from '../../../useVoiceToText';
2
+ import { setVoiceToTextSessionDisabledForTesting } from '../../../useVoiceToText';
3
3
 
4
4
  export default {
5
5
  title: 'Components/VoiceToTextButton',
@@ -244,22 +244,12 @@ SideBySide.story = {
244
244
 
245
245
  export const ForceUnsupported = () => ({
246
246
  components: { VoiceToTextButton },
247
- data() {
248
- return {
249
- // Reactive ping so the button re-renders when we flip the session flag.
250
- // The composable's isSupported is reactive on its own, but the parent
251
- // template uses :key to make the toggle obvious in Storybook.
252
- tick: 0,
253
- };
254
- },
255
247
  methods: {
256
248
  forceUnsupported() {
257
- __setVoiceToTextSessionDisabled(true);
258
- this.tick += 1;
249
+ setVoiceToTextSessionDisabledForTesting(true);
259
250
  },
260
251
  reset() {
261
- __setVoiceToTextSessionDisabled(false);
262
- this.tick += 1;
252
+ setVoiceToTextSessionDisabledForTesting(false);
263
253
  },
264
254
  },
265
255
  template: `
@@ -267,8 +257,8 @@ export const ForceUnsupported = () => ({
267
257
  <h3 class="tw-text-grayscale-800 tw-text-lg tw-font-semibold tw-mb-2">Force unsupported preview</h3>
268
258
  <p class="tw-text-grayscale-600 tw-text-sm tw-mb-6 tw-text-center tw-max-w-md">
269
259
  Simulates the Edge fallback: after a 'network' SpeechRecognitionError, the composable latches
270
- <code class="tw-bg-grayscale-100 tw-px-1 tw-rounded">isSupported</code> to false for the rest of the session
271
- and the button hides itself. Reset clears the session flag.
260
+ <code class="tw-bg-grayscale-100 tw-px-1 tw-rounded">isSupported</code> to false for the rest of the session.
261
+ The button now stays visible but disabled, with a tooltip explaining the situation. Reset clears the session flag.
272
262
  </p>
273
263
 
274
264
  <div class="tw-flex tw-gap-3 tw-mb-6">
@@ -294,7 +284,7 @@ export const ForceUnsupported = () => ({
294
284
  placeholder="Voice input would appear on the right..."
295
285
  class="tw-flex-1 tw-bg-transparent tw-border-none tw-text-grayscale-800 tw-placeholder-grayscale-500 focus:tw-outline-none tw-text-base"
296
286
  />
297
- <VoiceToTextButton :key="tick" variant="light" size="md" />
287
+ <VoiceToTextButton variant="light" size="md" />
298
288
  </div>
299
289
 
300
290
  <p class="tw-text-grayscale-500 tw-text-xs tw-mt-4">
@@ -308,3 +298,67 @@ export const ForceUnsupported = () => ({
308
298
  ForceUnsupported.story = {
309
299
  name: 'Force Unsupported (Edge Fallback)',
310
300
  };
301
+
302
+ export const ControlVsUnsupported = () => ({
303
+ components: { VoiceToTextButton },
304
+ methods: {
305
+ forceUnsupported() {
306
+ setVoiceToTextSessionDisabledForTesting(true);
307
+ },
308
+ reset() {
309
+ setVoiceToTextSessionDisabledForTesting(false);
310
+ },
311
+ },
312
+ template: `
313
+ <div class="tw-min-h-[400px] tw-p-8" style="background: #1a1a2e;">
314
+ <h3 class="tw-text-white tw-text-lg tw-font-semibold tw-mb-2">Control vs Unsupported (side-by-side)</h3>
315
+ <p class="tw-text-grayscale-400 tw-text-sm tw-mb-6 tw-max-w-2xl">
316
+ Both buttons share variant + size. The right one is rendered with the session-disabled latch
317
+ flipped on, so it shows the new disabled state with the explanatory tooltip. Use this to compare
318
+ visual treatments for design review.
319
+ </p>
320
+
321
+ <div class="tw-flex tw-gap-3 tw-mb-6">
322
+ <button
323
+ type="button"
324
+ @click="forceUnsupported"
325
+ class="tw-px-4 tw-py-2 tw-rounded tw-bg-error-500 tw-text-white tw-text-sm hover:tw-bg-error-600"
326
+ >
327
+ Force unsupported
328
+ </button>
329
+ <button
330
+ type="button"
331
+ @click="reset"
332
+ class="tw-px-4 tw-py-2 tw-rounded tw-bg-grayscale-200 tw-text-grayscale-800 tw-text-sm hover:tw-bg-grayscale-300"
333
+ >
334
+ Reset
335
+ </button>
336
+ </div>
337
+
338
+ <div class="tw-flex tw-gap-12 tw-items-start">
339
+ <div class="tw-text-center">
340
+ <div class="tw-mb-2 tw-p-4 tw-bg-grayscale-800 tw-rounded-lg tw-inline-block">
341
+ <VoiceToTextButton variant="dark" size="md" />
342
+ </div>
343
+ <p class="tw-text-grayscale-400 tw-text-xs">Control (default state)</p>
344
+ </div>
345
+
346
+ <div class="tw-text-center">
347
+ <div class="tw-mb-2 tw-p-4 tw-bg-grayscale-800 tw-rounded-lg tw-inline-block">
348
+ <VoiceToTextButton variant="dark" size="md" />
349
+ </div>
350
+ <p class="tw-text-grayscale-400 tw-text-xs">Unsupported (after latch)</p>
351
+ </div>
352
+ </div>
353
+
354
+ <p class="tw-text-grayscale-500 tw-text-xs tw-mt-6">
355
+ Note: the composable is a singleton, so flipping the latch affects every mounted instance —
356
+ which is exactly what the production Edge case does. Both buttons will switch together.
357
+ </p>
358
+ </div>
359
+ `,
360
+ });
361
+
362
+ ControlVsUnsupported.story = {
363
+ name: 'Control vs Unsupported',
364
+ };
@@ -1,16 +1,23 @@
1
1
  <template>
2
2
  <button
3
- v-if="isSupported"
4
3
  type="button"
5
- :disabled="disabled"
6
- :aria-label="isListening ? 'Stop voice input' : 'Start voice input'"
4
+ :disabled="disabled || unavailableForSession"
5
+ :aria-label="
6
+ unavailableForSession
7
+ ? 'Voice input not available in this browser'
8
+ : isListening
9
+ ? 'Stop voice input'
10
+ : 'Start voice input'
11
+ "
12
+ :title="effectiveTitle"
7
13
  data-test="voice-to-text-button"
8
14
  class="voice-prompt-button tw-rounded-full tw-border-0 tw-flex tw-items-center tw-justify-center tw-transition-all tw-duration-200 tw-cursor-pointer focus:tw-outline-none focus-visible:tw-ring-2 focus-visible:tw-ring-offset-2 focus-visible:tw-ring-primary-500"
9
15
  :class="[
10
16
  sizeClasses,
11
17
  variantClasses.button,
12
18
  isListening ? 'voice-prompt-listening' : '',
13
- disabled ? 'tw-opacity-50 tw-cursor-not-allowed' : '',
19
+ (disabled || unavailableForSession) ? 'tw-opacity-50 tw-cursor-not-allowed' : '',
20
+ unavailableForSession ? 'voice-prompt-unavailable' : '',
14
21
  ]"
15
22
  :style="isListening ? { '--voice-bg': variantClasses.recordingBg } : {}"
16
23
  @click="toggle"
@@ -27,7 +34,7 @@
27
34
  <script setup lang="ts">
28
35
  import { watch, toRef, computed } from 'vue';
29
36
  import Icon from '../Icon/Icon.vue';
30
- import { useVoiceToText } from '../../../useVoiceToText';
37
+ import { useVoiceToText, VOICE_UNAVAILABLE_MESSAGE } from '../../../useVoiceToText';
31
38
 
32
39
  type ButtonSize = 'sm' | 'md' | 'lg';
33
40
  type ButtonVariant = 'light' | 'dark';
@@ -37,6 +44,7 @@ interface VoiceToTextButtonProps {
37
44
  disabled?: boolean;
38
45
  size?: ButtonSize;
39
46
  variant?: ButtonVariant;
47
+ title?: string;
40
48
  }
41
49
 
42
50
  const props = withDefaults(defineProps<VoiceToTextButtonProps>(), {
@@ -44,6 +52,7 @@ const props = withDefaults(defineProps<VoiceToTextButtonProps>(), {
44
52
  disabled: false,
45
53
  size: 'md',
46
54
  variant: 'dark',
55
+ title: undefined,
47
56
  });
48
57
 
49
58
  const sizeClasses = computed(() => {
@@ -91,6 +100,9 @@ const variantClasses = computed(() => {
91
100
  };
92
101
  });
93
102
 
103
+ const unavailableForSession = computed(() => !isSupported.value);
104
+ const effectiveTitle = computed(() => (unavailableForSession.value ? VOICE_UNAVAILABLE_MESSAGE : props.title));
105
+
94
106
  // Keep recognition language in sync with prop
95
107
  watch(toRef(props, 'lang'), setLang);
96
108
 
@@ -144,4 +156,23 @@ watch(error, (newError) => {
144
156
  opacity: 0.5;
145
157
  }
146
158
  }
159
+
160
+ .voice-prompt-unavailable {
161
+ position: relative;
162
+ }
163
+
164
+ .voice-prompt-unavailable::after {
165
+ content: '';
166
+ position: absolute;
167
+ inset: 18%;
168
+ background: linear-gradient(
169
+ 135deg,
170
+ transparent calc(50% - 1px),
171
+ #E34935 calc(50% - 1px),
172
+ #E34935 calc(50% + 1px),
173
+ transparent calc(50% + 1px)
174
+ );
175
+ pointer-events: none;
176
+ border-radius: inherit;
177
+ }
147
178
  </style>
@@ -31,6 +31,7 @@ let isInitialized = false;
31
31
  let errorClearTimeout: ReturnType<typeof setTimeout> | null = null;
32
32
  let state: VoiceToTextState | null = null;
33
33
  let sessionDisabled: Ref<boolean> | null = null;
34
+ let hasReceivedResult = false;
34
35
 
35
36
  // Edge ships window.SpeechRecognition but routes it through Microsoft's Online
36
37
  // Speech service, which is gated by a Windows privacy toggle that's commonly
@@ -47,8 +48,24 @@ const ERROR_MESSAGES: Record<string, string> = {
47
48
  'audio-capture': 'No microphone was found or microphone is not working.',
48
49
  };
49
50
 
51
+ export const VOICE_UNAVAILABLE_MESSAGE =
52
+ "Voice input isn't available in this browser. Try Chrome or Safari for the best experience.";
53
+
50
54
  const ERROR_CLEAR_DELAY = 5000;
51
55
 
56
+ async function checkMicPermission(): Promise<'granted' | 'denied' | 'prompt' | 'unknown'> {
57
+ try {
58
+ if (typeof navigator === 'undefined' || !navigator.permissions?.query) {
59
+ return 'unknown';
60
+ }
61
+ const result = await navigator.permissions.query({ name: 'microphone' as PermissionName });
62
+ return result.state as 'granted' | 'denied' | 'prompt';
63
+ } catch {
64
+ // Permissions API or the 'microphone' descriptor isn't supported (older Safari, Firefox quirks).
65
+ return 'unknown';
66
+ }
67
+ }
68
+
52
69
  function getState(): VoiceToTextState {
53
70
  if (!state) {
54
71
  state = {
@@ -89,12 +106,23 @@ function setSessionDisabled(disabled: boolean): void {
89
106
  }
90
107
  }
91
108
 
109
+ function latchAsUnavailable(): void {
110
+ const { error: errorRef } = getState();
111
+ setSessionDisabled(true);
112
+ errorRef.value = VOICE_UNAVAILABLE_MESSAGE;
113
+ if (errorClearTimeout) {
114
+ clearTimeout(errorClearTimeout);
115
+ }
116
+ errorClearTimeout = setTimeout(() => {
117
+ errorRef.value = null;
118
+ }, ERROR_CLEAR_DELAY);
119
+ }
120
+
92
121
  /**
93
122
  * Test/Storybook helper: force the session-disabled latch on or off without
94
123
  * needing a real network error. Not part of the public consumer API.
95
124
  */
96
- // eslint-disable-next-line no-underscore-dangle
97
- export function __setVoiceToTextSessionDisabled(disabled: boolean): void {
125
+ export function setVoiceToTextSessionDisabledForTesting(disabled: boolean): void {
98
126
  setSessionDisabled(disabled);
99
127
  }
100
128
 
@@ -134,27 +162,67 @@ export function useVoiceToText(options: UseVoiceToTextOptions = {}): UseVoiceToT
134
162
  }
135
163
  }
136
164
 
165
+ if (finalTranscript.length > 0 || interimTranscript.length > 0) {
166
+ hasReceivedResult = true;
167
+ }
168
+
137
169
  transcript.value = finalTranscript + interimTranscript;
138
170
  isFinal.value = interimTranscript === '';
139
171
  };
140
172
 
141
- recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
173
+ recognition.onerror = async (event: SpeechRecognitionErrorEvent) => {
142
174
  // Suppress no-speech and aborted errors per spec
143
175
  if (event.error === 'no-speech' || event.error === 'aborted') {
144
176
  return;
145
177
  }
146
178
 
147
- // Latch on first network error: Edge exposes SpeechRecognition but the
148
- // backend doesn't actually work for most users. Hide the feature for the
149
- // rest of the session rather than surfacing a recurring toast.
179
+ // Diagnostic instrumentation: capture state at every non-suppressed error
180
+ // so QA has a data point on first failure across browser/permission combos.
181
+ const micPermission = await checkMicPermission();
182
+ const sessionDisabledFlag = getSessionDisabled().value;
183
+ // eslint-disable-next-line no-console
184
+ console.warn('[useVoiceToText] error event', {
185
+ error: event.error,
186
+ micPermission,
187
+ hasReceivedResult,
188
+ sessionDisabled: sessionDisabledFlag,
189
+ });
190
+
150
191
  if (event.error === 'network') {
151
- // eslint-disable-next-line no-console
152
- console.warn('[useVoiceToText] network error disabling voice input for this session');
153
- isListening.value = false;
154
- setSessionDisabled(true);
192
+ if (!hasReceivedResult) {
193
+ // First-attempt network failure on Edge with the privacy toggle off (or
194
+ // an actually-broken network). Latch the feature off for the session.
195
+ // eslint-disable-next-line no-console
196
+ console.warn('[useVoiceToText] network error before any result — disabling voice input for this session');
197
+ isListening.value = false;
198
+ latchAsUnavailable();
199
+ } else {
200
+ // Trailing network error after a successful result is a known Edge teardown
201
+ // quirk — swallow it instead of latching, since voice clearly works for this user.
202
+ // eslint-disable-next-line no-console
203
+ console.warn('[useVoiceToText] swallowing trailing network error after successful result');
204
+ isListening.value = false;
205
+ }
155
206
  return;
156
207
  }
157
208
 
209
+ if (event.error === 'not-allowed') {
210
+ if (micPermission === 'granted') {
211
+ // Page-level mic permission was granted but the underlying speech backend
212
+ // (Edge's Microsoft Online Speech service) refused. This is the Mac
213
+ // InPrivate / Windows-toggle-off failure mode. Latch instead of showing a
214
+ // misleading "permission denied" toast.
215
+ // eslint-disable-next-line no-console
216
+ console.warn(
217
+ '[useVoiceToText] not-allowed despite granted mic permission — disabling voice input for this session',
218
+ );
219
+ isListening.value = false;
220
+ latchAsUnavailable();
221
+ return;
222
+ }
223
+ // Otherwise fall through to the existing "permission was denied" toast.
224
+ }
225
+
158
226
  const message = ERROR_MESSAGES[event.error] || 'An error occurred with speech recognition.';
159
227
  error.value = message;
160
228
 
@@ -1,120 +0,0 @@
1
- #!/usr/bin/env bash
2
- #
3
- # publish-uat.sh — publish a UAT-suffixed version of @designcrowd/fe-shared-lib
4
- #
5
- # Usage: ./scripts/publish-uat.sh <suffix>
6
- # Example: ./scripts/publish-uat.sh edge-fallback-2
7
- #
8
- # Reads the npm publish token from $NPM_PUBLISH_TOKEN_FILE
9
- # (default ~/.config/designcrowd/npm-publish-token, mode 600).
10
- # The token is never written into the repo.
11
- #
12
- # Workflow:
13
- # 1. Validate branch (refuse master) and suffix.
14
- # 2. Compute new version = <base>-<suffix>, refuse if already published.
15
- # 3. Bump package.json, run bundle-translation.
16
- # 4. npm pack — produces a tarball.
17
- # 5. Secret scan: dangerous filenames + token-shaped strings + the
18
- # literal token bytes from the token file (if it leaked into the
19
- # tree somehow, this catches it).
20
- # 6. npm publish <tarball> via a temp .npmrc in /tmp (mode 600,
21
- # removed on exit).
22
- # 7. git add + commit the version bump.
23
- #
24
- # Anything in steps 4-6 that fails leaves the working tree dirty so you
25
- # can investigate. The commit only lands after the registry confirms
26
- # the upload.
27
-
28
- set -euo pipefail
29
-
30
- PKG="@designcrowd/fe-shared-lib"
31
- TOKEN_FILE="${NPM_PUBLISH_TOKEN_FILE:-$HOME/.config/designcrowd/npm-publish-token}"
32
-
33
- abort() { echo "ABORT: $*" >&2; exit 1; }
34
-
35
- # --- args ---
36
- SUFFIX="${1:-}"
37
- [[ -z "$SUFFIX" ]] && abort "usage: $0 <suffix> e.g. $0 edge-fallback-2"
38
- [[ "$SUFFIX" =~ ^[A-Za-z0-9._-]+$ ]] || abort "suffix must be [A-Za-z0-9._-]+"
39
-
40
- # --- repo state ---
41
- cd "$(git rev-parse --show-toplevel)"
42
- BRANCH=$(git symbolic-ref --short HEAD)
43
- [[ "$BRANCH" == "master" || "$BRANCH" == "main" ]] && abort "refusing to publish from $BRANCH"
44
-
45
- # --- token ---
46
- [[ -f "$TOKEN_FILE" ]] || abort "no token at $TOKEN_FILE (set NPM_PUBLISH_TOKEN_FILE to override)"
47
- TOKEN=$(tr -d '[:space:]' < "$TOKEN_FILE")
48
- [[ -n "$TOKEN" ]] || abort "token file is empty: $TOKEN_FILE"
49
-
50
- # --- compute version ---
51
- BASE=$(node -p "require('./package.json').version.replace(/-.*/, '')")
52
- NEW="$BASE-$SUFFIX"
53
-
54
- if npm view "$PKG@$NEW" version >/dev/null 2>&1; then
55
- abort "$PKG@$NEW already published — pick a new suffix"
56
- fi
57
-
58
- echo "==> publishing $PKG@$NEW from branch $BRANCH"
59
-
60
- # --- bump + bundle ---
61
- node -e "const fs=require('fs');const p=require('./package.json');p.version='$NEW';fs.writeFileSync('./package.json', JSON.stringify(p, null, 2)+'\n');"
62
- echo "==> running bundle-translation"
63
- npm run --silent bundle-translation >/dev/null
64
-
65
- # --- pack ---
66
- echo "==> packing"
67
- TARBALL=$(npm pack --silent)
68
- [[ -f "$TARBALL" ]] || abort "npm pack did not produce a tarball"
69
-
70
- # Always clean up temp artifacts on exit
71
- SCAN_DIR=$(mktemp -d)
72
- TMP_NPMRC=$(mktemp /tmp/fe-shared-lib-publish.XXXXXX.npmrc)
73
- chmod 600 "$TMP_NPMRC"
74
- cleanup() { rm -rf "$SCAN_DIR"; rm -f "$TMP_NPMRC" "$TARBALL"; }
75
- trap cleanup EXIT
76
-
77
- # --- secret scan: filenames ---
78
- echo "==> scanning $TARBALL"
79
- DANGEROUS_PATHS='(^|/)(\.env(\..+)?|\.npmrc|\.npm-publish-token|id_rsa.*|id_ed25519.*|.*\.pem|.*\.key|.*token.*|.*secret.*|.*credential.*)$|(^|/)\.(aws|ssh|gnupg)/'
80
- if BAD=$(tar tzf "$TARBALL" | grep -E -i "$DANGEROUS_PATHS" || true); [[ -n "$BAD" ]]; then
81
- echo "$BAD" >&2
82
- abort "dangerous filenames in tarball"
83
- fi
84
-
85
- # --- secret scan: contents ---
86
- tar xzf "$TARBALL" -C "$SCAN_DIR"
87
-
88
- # Token-shaped patterns. Tightened so the literal '${NPM_TOKEN}' template
89
- # in the tracked npmrc file doesn't trip the check.
90
- SECRET_PATTERNS='(npm_[A-Za-z0-9]{30,}|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|sk_live_[A-Za-z0-9]{20,}|-----BEGIN [A-Z ]*PRIVATE KEY-----)'
91
- if HITS=$(grep -REn -I -E "$SECRET_PATTERNS" "$SCAN_DIR" 2>/dev/null || true); [[ -n "$HITS" ]]; then
92
- echo "$HITS" >&2
93
- abort "secret-shaped string in tarball"
94
- fi
95
-
96
- # Belt-and-braces: the token's own bytes. If our publish token ever
97
- # lands in a tracked file by accident, this catches it regardless of
98
- # pattern-matching.
99
- if grep -RFq "$TOKEN" "$SCAN_DIR" 2>/dev/null; then
100
- abort "publish token bytes appear in tarball — DO NOT publish"
101
- fi
102
-
103
- FILES=$(tar tzf "$TARBALL" | wc -l | tr -d ' ')
104
- SIZE=$(du -h "$TARBALL" | cut -f1)
105
- echo "==> scan clean. $FILES files, $SIZE"
106
-
107
- # --- publish ---
108
- printf '//registry.npmjs.org/:_authToken=%s\n' "$TOKEN" > "$TMP_NPMRC"
109
- echo "==> publishing"
110
- npm publish "$TARBALL" --userconfig "$TMP_NPMRC"
111
-
112
- # --- commit bump ---
113
- git add -- package.json
114
- git add -- src/bundles 2>/dev/null || true
115
- git commit -m "bump version to $NEW" >/dev/null
116
- echo
117
- echo "Published $PKG@$NEW (commit $(git rev-parse --short HEAD))"
118
- echo
119
- echo "Install in BrandCrowd.Net:"
120
- echo " npm i $PKG@$NEW"