crewly 1.20.209 → 1.20.211

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.
Files changed (93) hide show
  1. package/config/skills/agent/core/app-data/SKILL.md +117 -0
  2. package/config/skills/agent/core/app-data/execute.sh +141 -0
  3. package/config/skills/agent/core/app-data/execute.test.sh +108 -0
  4. package/config/skills/agent/core/publish-app/SKILL.md +248 -0
  5. package/config/skills/agent/core/publish-app/execute.sh +302 -0
  6. package/config/skills/agent/core/publish-app/execute.test.sh +205 -0
  7. package/dist/backend/backend/src/constants.d.ts +115 -0
  8. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  9. package/dist/backend/backend/src/constants.js +115 -0
  10. package/dist/backend/backend/src/constants.js.map +1 -1
  11. package/dist/backend/backend/src/controllers/apps/apps.controller.d.ts +55 -0
  12. package/dist/backend/backend/src/controllers/apps/apps.controller.d.ts.map +1 -0
  13. package/dist/backend/backend/src/controllers/apps/apps.controller.js +131 -0
  14. package/dist/backend/backend/src/controllers/apps/apps.controller.js.map +1 -0
  15. package/dist/backend/backend/src/controllers/apps/apps.routes.d.ts +53 -0
  16. package/dist/backend/backend/src/controllers/apps/apps.routes.d.ts.map +1 -0
  17. package/dist/backend/backend/src/controllers/apps/apps.routes.js +108 -0
  18. package/dist/backend/backend/src/controllers/apps/apps.routes.js.map +1 -0
  19. package/dist/backend/backend/src/index.d.ts +1 -0
  20. package/dist/backend/backend/src/index.d.ts.map +1 -1
  21. package/dist/backend/backend/src/index.js +57 -5
  22. package/dist/backend/backend/src/index.js.map +1 -1
  23. package/dist/backend/backend/src/middleware/body-parser-except.d.ts +25 -0
  24. package/dist/backend/backend/src/middleware/body-parser-except.d.ts.map +1 -0
  25. package/dist/backend/backend/src/middleware/body-parser-except.js +33 -0
  26. package/dist/backend/backend/src/middleware/body-parser-except.js.map +1 -0
  27. package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
  28. package/dist/backend/backend/src/routes/api.routes.js +4 -0
  29. package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
  30. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
  31. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js +5 -0
  32. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
  33. package/dist/backend/backend/src/services/apps/app-open-link.d.ts +61 -0
  34. package/dist/backend/backend/src/services/apps/app-open-link.d.ts.map +1 -0
  35. package/dist/backend/backend/src/services/apps/app-open-link.js +112 -0
  36. package/dist/backend/backend/src/services/apps/app-open-link.js.map +1 -0
  37. package/dist/backend/backend/src/services/apps/app-wake-message.d.ts +136 -0
  38. package/dist/backend/backend/src/services/apps/app-wake-message.d.ts.map +1 -0
  39. package/dist/backend/backend/src/services/apps/app-wake-message.js +253 -0
  40. package/dist/backend/backend/src/services/apps/app-wake-message.js.map +1 -0
  41. package/dist/backend/backend/src/services/apps/app-wake.service.d.ts +145 -0
  42. package/dist/backend/backend/src/services/apps/app-wake.service.d.ts.map +1 -0
  43. package/dist/backend/backend/src/services/apps/app-wake.service.js +455 -0
  44. package/dist/backend/backend/src/services/apps/app-wake.service.js.map +1 -0
  45. package/dist/backend/backend/src/services/apps/apps-cloud.client.d.ts +74 -0
  46. package/dist/backend/backend/src/services/apps/apps-cloud.client.d.ts.map +1 -0
  47. package/dist/backend/backend/src/services/apps/apps-cloud.client.js +120 -0
  48. package/dist/backend/backend/src/services/apps/apps-cloud.client.js.map +1 -0
  49. package/dist/backend/backend/src/services/apps/apps-registry.service.d.ts +125 -0
  50. package/dist/backend/backend/src/services/apps/apps-registry.service.d.ts.map +1 -0
  51. package/dist/backend/backend/src/services/apps/apps-registry.service.js +192 -0
  52. package/dist/backend/backend/src/services/apps/apps-registry.service.js.map +1 -0
  53. package/dist/backend/backend/src/services/apps/apps.service.d.ts +466 -0
  54. package/dist/backend/backend/src/services/apps/apps.service.d.ts.map +1 -0
  55. package/dist/backend/backend/src/services/apps/apps.service.js +740 -0
  56. package/dist/backend/backend/src/services/apps/apps.service.js.map +1 -0
  57. package/dist/backend/backend/src/services/apps/apps.wiring.d.ts +97 -0
  58. package/dist/backend/backend/src/services/apps/apps.wiring.d.ts.map +1 -0
  59. package/dist/backend/backend/src/services/apps/apps.wiring.js +153 -0
  60. package/dist/backend/backend/src/services/apps/apps.wiring.js.map +1 -0
  61. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts.map +1 -1
  62. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.js +3 -1
  63. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.js.map +1 -1
  64. package/dist/backend/backend/src/services/core/logger.service.d.ts +5 -0
  65. package/dist/backend/backend/src/services/core/logger.service.d.ts.map +1 -1
  66. package/dist/backend/backend/src/services/core/logger.service.js +8 -0
  67. package/dist/backend/backend/src/services/core/logger.service.js.map +1 -1
  68. package/dist/backend/backend/src/services/slack/slack-thread-context.service.d.ts.map +1 -1
  69. package/dist/backend/backend/src/services/slack/slack-thread-context.service.js +3 -1
  70. package/dist/backend/backend/src/services/slack/slack-thread-context.service.js.map +1 -1
  71. package/dist/backend/build-info.json +2 -2
  72. package/dist/cli/backend/src/constants.d.ts +115 -0
  73. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  74. package/dist/cli/backend/src/constants.js +115 -0
  75. package/dist/cli/backend/src/constants.js.map +1 -1
  76. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
  77. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js +5 -0
  78. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
  79. package/dist/cli/backend/src/services/apps/app-open-link.d.ts +61 -0
  80. package/dist/cli/backend/src/services/apps/app-open-link.d.ts.map +1 -0
  81. package/dist/cli/backend/src/services/apps/app-open-link.js +112 -0
  82. package/dist/cli/backend/src/services/apps/app-open-link.js.map +1 -0
  83. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts.map +1 -1
  84. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.js +3 -1
  85. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.js.map +1 -1
  86. package/dist/cli/backend/src/services/core/logger.service.d.ts +5 -0
  87. package/dist/cli/backend/src/services/core/logger.service.d.ts.map +1 -1
  88. package/dist/cli/backend/src/services/core/logger.service.js +8 -0
  89. package/dist/cli/backend/src/services/core/logger.service.js.map +1 -1
  90. package/dist/cli/backend/src/services/slack/slack-thread-context.service.d.ts.map +1 -1
  91. package/dist/cli/backend/src/services/slack/slack-thread-context.service.js +3 -1
  92. package/dist/cli/backend/src/services/slack/slack-thread-context.service.js.map +1 -1
  93. package/package.json +1 -1
@@ -0,0 +1,302 @@
1
+ #!/bin/bash
2
+ # =============================================================================
3
+ # publish-app — publish a small web app to https://apps.crewlyai.com/<appId>
4
+ #
5
+ # Backed by POST /api/apps/publish (and /api/apps/:id/rollback, /versions,
6
+ # GET /api/apps). The backend calls Crewly Cloud with its own login; this
7
+ # script only reads the bundle files (as you, so the credential guard
8
+ # applies) and sends their contents. specs/2026-10-04-crewly-apps-p2.md
9
+ #
10
+ # Publishing or rolling back a PUBLIC app takes it private until the owner
11
+ # re-approves it (Cloud); the output then has "publicPaused": true.
12
+ #
13
+ # P3 (specs/2026-10-04-crewly-apps-p3.md): the card carries a signed
14
+ # one-tap link the backend mints and posts to the owner DM only — this
15
+ # script never sees it. --share / --links / --revoke-link(s) manage those
16
+ # links; --public asks the OWNER to make the app public (only the owner can,
17
+ # in the app); --private / --cancel-public undo.
18
+ # =============================================================================
19
+ set -euo pipefail
20
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
+ source "${SCRIPT_DIR}/../../_common/lib.sh"
22
+
23
+ print_usage() {
24
+ cat <<'EOF_USAGE'
25
+ Usage:
26
+ bash execute.sh --dir ./my-app [--name "Groceries"] [--entry index.html] [--note "what changed"] [--notify]
27
+ bash execute.sh --html ./timer.html --name "Timer" [--notify]
28
+ bash execute.sh --app <appId> --dir ./my-app # republish to a known app explicitly
29
+ bash execute.sh --app <appId> --rollback <version> # make an earlier version current
30
+ bash execute.sh --app <appId> --versions # list versions
31
+ bash execute.sh --list # apps published from this machine
32
+ bash execute.sh --app <appId> --share [--ttl-days 7] # fresh one-tap link card to the owner (no publish)
33
+ bash execute.sh --app <appId> --links # open-links of the app (no secrets)
34
+ bash execute.sh --app <appId> --revoke-link <linkId> # revoke one link
35
+ bash execute.sh --app <appId> --revoke-links # revoke every link
36
+ bash execute.sh --app <appId> --public --public-read items,stats --public-submit votes [--public-note "why"]
37
+ # ASK the owner to make it public (they approve in the app)
38
+ bash execute.sh --dir ./my-app --public --public-read items # publish and ask in one go
39
+ bash execute.sh --app <appId> --cancel-public # withdraw a pending request
40
+ bash execute.sh --app <appId> --private # private again (instant)
41
+
42
+ Options:
43
+ --dir Directory to publish (index.html at its root unless --entry)
44
+ --html A single HTML file (published as index.html)
45
+ --name App name (new app; renames an existing one)
46
+ --app Publish to / act on this appId instead of the one recorded for you
47
+ --entry Entry file inside the bundle (default index.html)
48
+ --note Short note stored with the version
49
+ --notify Post "📱 <name> · Open app" to the owner (one-tap signed link in your DM with them)
50
+ --share Post the card again with a fresh link, without publishing (needs --app)
51
+ --ttl-days Lifetime of the shared link, 1-30 days (default 7)
52
+ --links List the app's open-links (needs --app)
53
+ --revoke-link <linkId> / --revoke-links Revoke one / all open-links (needs --app)
54
+ --public Ask the owner to make the app public; name collections with:
55
+ --public-read Collections anonymous visitors may read (comma-separated, max 20)
56
+ --public-submit Collections anonymous visitors may add to (append-only, max 20)
57
+ --public-note Why, shown to the owner
58
+ --cancel-public Withdraw a pending public request (needs --app)
59
+ --private Make the app private again, instantly (needs --app)
60
+ --rollback Version number to make current (needs --app)
61
+ --versions List versions (needs --app)
62
+ --list List apps published from this machine
63
+ --help | -h Show this help
64
+ EOF_USAGE
65
+ }
66
+
67
+ fail_from() {
68
+ printf '%s' "$1" | jq -c '{success: false, reason: (.details.error // .error // "unknown"), message: (.details.message // .details // ""), hint: (.details.hint // "")}' 2>/dev/null \
69
+ || jq -n --arg r "$1" '{success: false, reason: $r}'
70
+ exit 1
71
+ }
72
+
73
+ # api_call may print a one-line warning to stderr; the answer is the last line.
74
+ call() {
75
+ local out
76
+ out=$(api_call "$@" 2>&1) || { fail_from "$(printf '%s\n' "$out" | tail -n 1)"; }
77
+ printf '%s\n' "$out" | tail -n 1
78
+ }
79
+
80
+ DIR=""; FILE=""; NAME=""; APP=""; ENTRY=""; NOTE=""; NOTIFY=false; ROLLBACK=""; VERSIONS=false; LIST=false
81
+ SHARE=false; TTL_DAYS=""; LINKS=false; REVOKE_LINK=""; REVOKE_LINKS=false
82
+ PUBLIC=false; PUBLIC_READ=""; PUBLIC_SUBMIT=""; PUBLIC_NOTE=""; CANCEL_PUBLIC=false; PRIVATE=false
83
+ while [[ $# -gt 0 ]]; do
84
+ case "$1" in
85
+ --dir) [ $# -ge 2 ] || error_exit "--dir requires a value"; DIR="$2"; shift 2 ;;
86
+ --html) [ $# -ge 2 ] || error_exit "--html requires a value"; FILE="$2"; shift 2 ;;
87
+ --name) [ $# -ge 2 ] || error_exit "--name requires a value"; NAME="$2"; shift 2 ;;
88
+ --app) [ $# -ge 2 ] || error_exit "--app requires a value"; APP="$2"; shift 2 ;;
89
+ --entry) [ $# -ge 2 ] || error_exit "--entry requires a value"; ENTRY="$2"; shift 2 ;;
90
+ --note) [ $# -ge 2 ] || error_exit "--note requires a value"; NOTE="$2"; shift 2 ;;
91
+ --rollback) [ $# -ge 2 ] || error_exit "--rollback requires a value"; ROLLBACK="$2"; shift 2 ;;
92
+ --notify) NOTIFY=true; shift ;;
93
+ --share) SHARE=true; shift ;;
94
+ --ttl-days) [ $# -ge 2 ] || error_exit "--ttl-days requires a value"; TTL_DAYS="$2"; shift 2 ;;
95
+ --links) LINKS=true; shift ;;
96
+ --revoke-link) [ $# -ge 2 ] || error_exit "--revoke-link requires a link id"; REVOKE_LINK="$2"; shift 2 ;;
97
+ --revoke-links) REVOKE_LINKS=true; shift ;;
98
+ --public) PUBLIC=true; shift ;;
99
+ --public-read) [ $# -ge 2 ] || error_exit "--public-read requires collection names"; PUBLIC_READ="$2"; PUBLIC=true; shift 2 ;;
100
+ --public-submit) [ $# -ge 2 ] || error_exit "--public-submit requires collection names"; PUBLIC_SUBMIT="$2"; PUBLIC=true; shift 2 ;;
101
+ --public-note) [ $# -ge 2 ] || error_exit "--public-note requires a value"; PUBLIC_NOTE="$2"; shift 2 ;;
102
+ --cancel-public) CANCEL_PUBLIC=true; shift ;;
103
+ --private) PRIVATE=true; shift ;;
104
+ --versions) VERSIONS=true; shift ;;
105
+ --list) LIST=true; shift ;;
106
+ --help|-h) print_usage; exit 0 ;;
107
+ *) error_exit "Unknown option: $1" ;;
108
+ esac
109
+ done
110
+
111
+ if $LIST; then
112
+ RESPONSE=$(call GET "/apps") || { printf '%s\n' "$RESPONSE"; exit 1; }
113
+ printf '%s' "$RESPONSE" | jq -c '{success: true, apps: [.data[]? | select(.deleted != true) | {appId, name, url, agent: .agentSession, currentVersion, source}]}'
114
+ exit 0
115
+ fi
116
+
117
+ # Comma-separated collection names → a JSON array, checked like the backend
118
+ # does (P2 collection pattern, at most 20).
119
+ collections_json() {
120
+ local flag="$1" list="$2" out="[]" n=0 name
121
+ local IFS=','
122
+ for name in $list; do
123
+ name="$(printf '%s' "$name" | tr -d '[:space:]')"
124
+ [ -n "$name" ] || continue
125
+ [[ "$name" =~ ^[A-Za-z0-9_-]{1,64}$ ]] || error_exit "$flag: \"$name\" is not a collection name (1-64 of A-Z a-z 0-9 _ -)"
126
+ out=$(printf '%s' "$out" | jq -c --arg n "$name" 'if index($n) then . else . + [$n] end')
127
+ n=$(printf '%s' "$out" | jq 'length')
128
+ [ "$n" -le 20 ] || error_exit "$flag can name at most 20 collections"
129
+ done
130
+ printf '%s' "$out"
131
+ }
132
+
133
+ PUBLIC_BODY=""
134
+ if $PUBLIC; then
135
+ [ -n "$PUBLIC_READ" ] || [ -n "$PUBLIC_SUBMIT" ] || error_exit "--public needs --public-read and/or --public-submit (the collections visitors may read / add to)"
136
+ PUBLIC_BODY=$(jq -cn --argjson r "$(collections_json --public-read "$PUBLIC_READ")" --argjson s "$(collections_json --public-submit "$PUBLIC_SUBMIT")" --arg note "$PUBLIC_NOTE" \
137
+ '{publicRead: $r, publicSubmit: $s} + (if $note != "" then {note: $note} else {} end)')
138
+ fi
139
+ if [ -n "$TTL_DAYS" ]; then
140
+ [[ "$TTL_DAYS" =~ ^[0-9]+$ ]] && [ "$TTL_DAYS" -ge 1 ] && [ "$TTL_DAYS" -le 30 ] || error_exit "--ttl-days must be 1-30"
141
+ fi
142
+
143
+ # The card fields the backend reports. Never a link: the signed URL stays
144
+ # in the owner's DM.
145
+ CARD_JQ='(if .notified != null then {notified} else {} end)
146
+ + (if .card then {card} else {} end) + (if .cardPlace then {cardPlace} else {} end)
147
+ + (if .linkId then {linkId} else {} end) + (if .linkExpiresAt then {linkExpiresAt} else {} end)
148
+ + (if .notifyError then {notifyError} else {} end) + (if .linkError then {linkError} else {} end)'
149
+ PUBLIC_MSG='Requested: the owner approves it by opening the app. It stays private until they do; you cannot make it public yourself.'
150
+
151
+ if [ -n "$DIR" ] || [ -n "$FILE" ]; then
152
+ if $LINKS || $REVOKE_LINKS || $CANCEL_PUBLIC || $PRIVATE || [ -n "$REVOKE_LINK" ]; then
153
+ error_exit "--links, --revoke-link(s), --cancel-public and --private act on an app (--app <id>), not on a publish"
154
+ fi
155
+ fi
156
+
157
+ if [ -z "$DIR" ] && [ -z "$FILE" ]; then
158
+ ACTIONS=0
159
+ for a in "$SHARE" "$LINKS" "$REVOKE_LINKS" "$PUBLIC" "$CANCEL_PUBLIC" "$PRIVATE"; do $a && ACTIONS=$((ACTIONS+1)); done
160
+ [ -n "$REVOKE_LINK" ] && ACTIONS=$((ACTIONS+1))
161
+ if [ "$ACTIONS" -gt 1 ]; then error_exit "use one of --share, --links, --revoke-link, --revoke-links, --public, --cancel-public, --private at a time"; fi
162
+ if [ "$ACTIONS" -eq 1 ]; then
163
+ [ -n "$APP" ] || error_exit "--app <appId> is required (see --list)"
164
+ if $SHARE; then
165
+ BODY=$(jq -cn --arg t "$TTL_DAYS" 'if $t != "" then {ttlDays: ($t|tonumber)} else {} end')
166
+ RESPONSE=$(call POST "/apps/${APP}/share" "$BODY") || { printf '%s\n' "$RESPONSE"; exit 1; }
167
+ printf '%s' "$RESPONSE" | jq -c '.data | {success: true, appId, url: ("https://apps.crewlyai.com/" + .appId), visibility, publicRequestPending} + '"$CARD_JQ"
168
+ elif $LINKS; then
169
+ RESPONSE=$(call GET "/apps/${APP}/links") || { printf '%s\n' "$RESPONSE"; exit 1; }
170
+ printf '%s' "$RESPONSE" | jq -c '{success: true, links: [.data[]? | {linkId, active, uses, createdAt, expiresAt, lastUsedAt, revokedAt, createdBy}]}'
171
+ elif [ -n "$REVOKE_LINK" ]; then
172
+ [[ "$REVOKE_LINK" =~ ^[A-Za-z0-9_-]{1,64}$ ]] || error_exit "--revoke-link takes a link id from --links"
173
+ RESPONSE=$(call DELETE "/apps/${APP}/links/${REVOKE_LINK}") || { printf '%s\n' "$RESPONSE"; exit 1; }
174
+ printf '%s' "$RESPONSE" | jq -c --arg id "$REVOKE_LINK" '{success: true, linkId: $id, revoked: .data.revoked}'
175
+ elif $REVOKE_LINKS; then
176
+ RESPONSE=$(call DELETE "/apps/${APP}/links") || { printf '%s\n' "$RESPONSE"; exit 1; }
177
+ printf '%s' "$RESPONSE" | jq -c '{success: true, revoked: .data.revoked}'
178
+ elif $PUBLIC; then
179
+ RESPONSE=$(call POST "/apps/${APP}/visibility-request" "$PUBLIC_BODY") || { printf '%s\n' "$RESPONSE"; exit 1; }
180
+ printf '%s' "$RESPONSE" | jq -c --arg msg "$PUBLIC_MSG" '.data | {success: true, appId, url: ("https://apps.crewlyai.com/" + .appId), requested: true, message: $msg, visibility,
181
+ publicRequest: (if .publicRequest then (.publicRequest | {publicRead, publicSubmit, note}) else null end)} + '"$CARD_JQ"
182
+ elif $CANCEL_PUBLIC; then
183
+ RESPONSE=$(call DELETE "/apps/${APP}/visibility-request") || { printf '%s\n' "$RESPONSE"; exit 1; }
184
+ printf '%s' "$RESPONSE" | jq -c '.data | {success: true, appId, cancelled: true, visibility}'
185
+ else
186
+ RESPONSE=$(call POST "/apps/${APP}/make-private") || { printf '%s\n' "$RESPONSE"; exit 1; }
187
+ printf '%s' "$RESPONSE" | jq -c '.data | {success: true, appId, visibility}'
188
+ fi
189
+ exit 0
190
+ fi
191
+ fi
192
+
193
+ if [ -n "$ROLLBACK" ] || $VERSIONS; then
194
+ [ -n "$APP" ] || error_exit "--app is required with --rollback / --versions"
195
+ if $VERSIONS; then
196
+ RESPONSE=$(call GET "/apps/${APP}/versions") || { printf '%s\n' "$RESPONSE"; exit 1; }
197
+ printf '%s' "$RESPONSE" | jq -c '{success: true, versions: [.data[]? | {version, current, note, files, totalBytes, createdAt}]}'
198
+ exit 0
199
+ fi
200
+ [[ "$ROLLBACK" =~ ^[0-9]+$ ]] || error_exit "--rollback must be a version number"
201
+ BODY=$(jq -cn --argjson v "$ROLLBACK" '{version: $v}')
202
+ RESPONSE=$(call POST "/apps/${APP}/rollback" "$BODY") || { printf '%s\n' "$RESPONSE"; exit 1; }
203
+ printf '%s' "$RESPONSE" | jq -c '{success: true, appId: .data.appId, url: ("https://apps.crewlyai.com/" + .data.appId), currentVersion: .data.currentVersion}
204
+ + (if .data.publicPaused == true then {publicPaused: true, message: .data.publicPausedMessage} else {} end)'
205
+ exit 0
206
+ fi
207
+
208
+ [ -n "$DIR" ] || [ -n "$FILE" ] || error_exit "--dir or --html is required (see --help)"
209
+ [ -z "$DIR" ] || [ -z "$FILE" ] || error_exit "use --dir or --html, not both"
210
+ command -v node >/dev/null 2>&1 || error_exit "node is required to package the app"
211
+
212
+ BODY_FILE="$(mktemp "${TMPDIR:-/tmp}/crewly-publish-XXXXXX")"
213
+ trap 'rm -f "$BODY_FILE"' EXIT
214
+
215
+ # Package the bundle: every file under --dir except dotfiles, node_modules
216
+ # and anything under a dot-directory (.git, .env, …). Limits are Crewly
217
+ # Apps': 300 files, 5 MB per file, 25 MB per version.
218
+ PUB_PROJECT="${CREWLY_PROJECT_PATH:-$PWD}" PUB_DIR="$DIR" PUB_FILE="$FILE" PUB_NAME="$NAME" PUB_APP="$APP" PUB_ENTRY="$ENTRY" PUB_NOTE="$NOTE" PUB_NOTIFY="$( $NOTIFY || $SHARE && echo true || echo false )" PUB_PUBLIC="$PUBLIC_BODY" PUB_OUT="$BODY_FILE" \
219
+ node <<'NODE' || exit 1
220
+ const fs = require('fs');
221
+ const path = require('path');
222
+ const env = process.env;
223
+ const fail = (msg) => { process.stderr.write(JSON.stringify({ error: msg }) + '\n'); process.exit(1); };
224
+ const MAX_FILES = 300, MAX_FILE = 5 * 1024 * 1024, MAX_TOTAL = 25 * 1024 * 1024;
225
+ const files = [];
226
+ let total = 0;
227
+ const add = (abs, rel) => {
228
+ const size = fs.statSync(abs).size;
229
+ if (size > MAX_FILE) fail(`${rel} is ${(size / 1048576).toFixed(1)} MB; a file can be at most 5 MB`);
230
+ total += size;
231
+ if (total > MAX_TOTAL) fail('the app is larger than 25 MB');
232
+ if (files.length >= MAX_FILES) fail('the app has more than 300 files');
233
+ files.push({ path: rel, contentBase64: fs.readFileSync(abs).toString('base64') });
234
+ };
235
+ // The bundle root must be a real (non-symlink) path inside the project
236
+ // directory, and never Crewly's own home (credentials live there).
237
+ const os = require('os');
238
+ const real = (p) => { try { return fs.realpathSync(p); } catch { return null; } };
239
+ const within = (child, parent) => child === parent || child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
240
+ const project = real(env.PUB_PROJECT || process.cwd());
241
+ if (!project) fail('cannot resolve the project directory');
242
+ const crewlyHomes = [path.join(os.homedir(), '.crewly'), env.CREWLY_HOME].filter(Boolean).map((p) => real(p) || path.resolve(p));
243
+ const checkRoot = (given, kind) => {
244
+ const abs = path.resolve(given);
245
+ let st;
246
+ try { st = fs.lstatSync(abs); } catch { fail(`${kind === 'file' ? 'file' : 'directory'} not found: ${given}`); }
247
+ if (st.isSymbolicLink()) fail(`${given} is a symbolic link; publish the real ${kind === 'file' ? 'file' : 'directory'}`);
248
+ if (kind === 'file' ? !st.isFile() : !st.isDirectory()) fail(`${kind === 'file' ? 'file' : 'directory'} not found: ${given}`);
249
+ const r = real(abs);
250
+ if (crewlyHomes.some((h) => within(r, h))) fail(`${given} is inside Crewly's home directory; that is never published`);
251
+ if (!within(r, project)) fail(`${given} is outside your project directory (${project})`);
252
+ return r;
253
+ };
254
+ let source;
255
+ if (env.PUB_FILE) {
256
+ const abs = checkRoot(env.PUB_FILE, 'file');
257
+ if (!/\.html?$/i.test(abs)) fail('--html must be an .html file; use --dir for a multi-file app');
258
+ source = abs;
259
+ add(abs, 'index.html');
260
+ } else {
261
+ const root = checkRoot(env.PUB_DIR, 'dir');
262
+ source = root;
263
+ const walk = (dir, prefix) => {
264
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
265
+ if (ent.name.startsWith('.') || ent.name === 'node_modules') continue;
266
+ const abs = path.join(dir, ent.name);
267
+ const rel = prefix ? `${prefix}/${ent.name}` : ent.name;
268
+ if (ent.isDirectory()) walk(abs, rel);
269
+ else if (ent.isFile()) add(abs, rel);
270
+ }
271
+ };
272
+ walk(root, '');
273
+ const entry = env.PUB_ENTRY || 'index.html';
274
+ if (!files.some((f) => f.path === entry)) fail(`${entry} is not in ${env.PUB_DIR} (set --entry)`);
275
+ }
276
+ if (files.length === 0) fail('nothing to publish');
277
+ const body = { files, source };
278
+ if (env.PUB_NAME) body.name = env.PUB_NAME;
279
+ if (env.PUB_APP) body.appId = env.PUB_APP;
280
+ if (env.PUB_ENTRY && !env.PUB_FILE) body.entry = env.PUB_ENTRY;
281
+ if (env.PUB_NOTE) body.note = env.PUB_NOTE;
282
+ if (env.PUB_NOTIFY === 'true') body.notify = true;
283
+ if (env.PUB_PUBLIC) body.publicRequest = JSON.parse(env.PUB_PUBLIC);
284
+ fs.writeFileSync(env.PUB_OUT, JSON.stringify(body));
285
+ NODE
286
+
287
+ AUTH=()
288
+ while IFS= read -r a; do AUTH+=("$a"); done < <(agent_auth_curl_args)
289
+ [ -n "${CREWLY_SESSION_NAME:-}" ] && AUTH+=(-H "X-Agent-Pid: $$")
290
+ OUT=$(curl -s -w '\n%{http_code}' -X POST -H "Content-Type: application/json" ${AUTH[@]+"${AUTH[@]}"} \
291
+ --data-binary "@${BODY_FILE}" "${CREWLY_API_URL}/api/apps/publish") || error_exit "could not reach the Crewly backend at ${CREWLY_API_URL}"
292
+ CODE=$(printf '%s\n' "$OUT" | tail -n 1)
293
+ RESP=$(printf '%s\n' "$OUT" | sed '$d')
294
+ if [ "$CODE" -ge 200 ] 2>/dev/null && [ "$CODE" -lt 300 ] 2>/dev/null; then
295
+ printf '%s' "$RESP" | jq -c --arg msg "$PUBLIC_MSG" '.data | {success: true, appId, name, url: ("https://apps.crewlyai.com/" + .appId), version, created, notified} + '"$CARD_JQ"'
296
+ + (if .publicRequested == true then {publicRequested: true, message: $msg} elif .publicRequested == false then {publicRequested: false, publicError} else {} end)
297
+ + (if .publicPaused == true then {publicPaused: true, publicPausedMessage} else {} end)'
298
+ else
299
+ printf '%s' "$RESP" | jq -c --arg code "$CODE" '{success: false, status: ($code|tonumber), reason: (.error // "unknown"), message: (.message // ""), hint: (.hint // "")}' 2>/dev/null \
300
+ || jq -cn --arg code "$CODE" '{success: false, status: ($code|tonumber? // 0), reason: "http_error"}'
301
+ exit 1
302
+ fi
@@ -0,0 +1,205 @@
1
+ #!/bin/bash
2
+ # Tests for publish-app — run with: bash execute.test.sh
3
+ # Python HTTP stub as the backend; asserts the request the skill sends and
4
+ # the JSON it prints. Exit 0 on pass.
5
+ set -u
6
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ EXEC="$HERE/execute.sh"
8
+ PASS=0; FAIL=0
9
+ check() {
10
+ local name="$1"; local got="$2"; local want="$3"
11
+ if [ "$got" = "$want" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); echo "FAIL: $name"; echo " want: $want"; echo " got: $got"; fi
12
+ }
13
+
14
+ PORT=18841
15
+ STUB_LOG="$(mktemp)"; export STUB_LOG; export STUB_PORT="$PORT"
16
+ python3 -u <<'PY' >/dev/null 2>&1 &
17
+ from http.server import BaseHTTPRequestHandler, HTTPServer
18
+ import json, os
19
+ LOG = os.environ['STUB_LOG']; PORT = int(os.environ['STUB_PORT'])
20
+ class H(BaseHTTPRequestHandler):
21
+ def _reply(self, method, data=None):
22
+ open(LOG, 'w').write(json.dumps({'method': method, 'path': self.path, 'body': data,
23
+ 'badge': self.headers.get('X-Agent-Badge'), 'session': self.headers.get('X-Agent-Session')}))
24
+ def send(code, obj):
25
+ self.send_response(code); self.send_header('Content-Type', 'application/json'); self.end_headers()
26
+ self.wfile.write(json.dumps(obj).encode())
27
+ if (data or {}).get('name') == 'nologin':
28
+ return send(409, {'success': False, 'error': 'not_logged_in', 'message': 'not signed in', 'hint': 'crewly cloud login'})
29
+ if self.path == '/api/apps/publish' and data.get('publicRequest') and data.get('name') == 'Crewly Login':
30
+ return send(400, {'success': False, 'error': 'validation', 'message': 'A public app\'s name may not contain "crewly".'})
31
+ if self.path == '/api/apps/publish':
32
+ out = {'appId': '28au74d9cj', 'name': data.get('name', 'x'), 'url': 'https://apps.crewlyai.com/28au74d9cj',
33
+ 'version': 3, 'created': False, 'notified': bool(data.get('notify'))}
34
+ if data.get('notify'):
35
+ out.update({'card': 'signed', 'cardPlace': 'owner-dm', 'linkId': 'lnk_1', 'linkExpiresAt': 'e', 'signedUrl': 'https://apps.crewlyai.com/28au74d9cj?k=LEAK'})
36
+ if data.get('publicRequest'):
37
+ if data['publicRequest'].get('note') == 'fail':
38
+ out.update({'publicRequested': False, 'publicError': 'old cloud'})
39
+ else:
40
+ out.update({'publicRequested': True, 'notified': True, 'card': 'signed'})
41
+ if data.get('name') == 'WasPublic':
42
+ out.update({'publicPaused': True, 'publicPausedMessage': 'owner must re-approve'})
43
+ return send(200, {'success': True, 'data': out})
44
+ if self.path == '/api/apps/28au74d9cj/share':
45
+ return send(200, {'success': True, 'data': {'appId': '28au74d9cj', 'name': 'G', 'url': 'https://apps.crewlyai.com/28au74d9cj?k=LEAK', 'visibility': 'private',
46
+ 'publicRequestPending': False, 'notified': True, 'card': 'signed', 'cardPlace': 'owner-dm', 'linkId': 'lnk_2', 'linkExpiresAt': 'e'}})
47
+ if self.path == '/api/apps/28au74d9cj/links' and method == 'GET':
48
+ return send(200, {'success': True, 'data': [{'linkId': 'lnk_1', 'active': True, 'uses': 2, 'createdAt': 'c', 'expiresAt': 'e', 'lastUsedAt': None, 'revokedAt': None, 'createdBy': 'dev-ella', 'url': 'LEAK'}]})
49
+ if self.path == '/api/apps/28au74d9cj/links' and method == 'DELETE':
50
+ return send(200, {'success': True, 'data': {'revoked': 3}})
51
+ if self.path == '/api/apps/28au74d9cj/links/lnk_1':
52
+ return send(200, {'success': True, 'data': {'revoked': True}})
53
+ if self.path == '/api/apps/28au74d9cj/links/nope':
54
+ return send(404, {'success': False, 'error': 'not_found', 'message': 'No such link.'})
55
+ if self.path == '/api/apps/28au74d9cj/visibility-request' and method == 'POST':
56
+ return send(200, {'success': True, 'data': {'appId': '28au74d9cj', 'visibility': 'private', 'publicRequest': dict(data, requestedBy='dev-ella'),
57
+ 'message': 'x', 'notified': True, 'card': 'signed', 'cardPlace': 'owner-dm', 'linkId': 'lnk_3'}})
58
+ if self.path == '/api/apps/28au74d9cj/visibility-request' and method == 'DELETE':
59
+ return send(200, {'success': True, 'data': {'appId': '28au74d9cj', 'cancelled': True, 'visibility': 'private'}})
60
+ if self.path == '/api/apps/28au74d9cj/make-private':
61
+ return send(200, {'success': True, 'data': {'appId': '28au74d9cj', 'visibility': 'private'}})
62
+ if self.path.endswith('/rollback'):
63
+ paused = {'publicPaused': True, 'publicPausedMessage': 'owner must re-approve', 'visibility': 'private'} if data['version'] == 9 else {}
64
+ return send(200, {'success': True, 'data': dict({'appId': '28au74d9cj', 'currentVersion': data['version']}, **paused)})
65
+ if self.path.endswith('/versions'):
66
+ return send(200, {'success': True, 'data': [{'version': 2, 'current': True, 'note': None, 'files': 1, 'totalBytes': 9, 'createdAt': 't', 'entry': 'index.html'}]})
67
+ if self.path == '/api/apps':
68
+ return send(200, {'success': True, 'data': [{'appId': 'a', 'name': 'A', 'url': 'u', 'agentSession': 's', 'currentVersion': 1, 'source': '/x'},
69
+ {'appId': 'b', 'name': 'B', 'deleted': True}]})
70
+ send(404, {'success': False, 'error': 'not_found'})
71
+ def do_GET(self): self._reply('GET')
72
+ def do_DELETE(self): self._reply('DELETE')
73
+ def do_POST(self):
74
+ n = int(self.headers.get('content-length', '0')); body = self.rfile.read(n).decode() if n else ''
75
+ self._reply('POST', json.loads(body or '{}'))
76
+ def log_message(self, *a, **k): pass
77
+ HTTPServer(('127.0.0.1', PORT), H).serve_forever()
78
+ PY
79
+ STUB_PID=$!
80
+ disown "$STUB_PID" 2>/dev/null || true
81
+ WORK="$(mktemp -d)"
82
+ trap 'kill "$STUB_PID" >/dev/null 2>&1; rm -rf "$STUB_LOG" "$WORK"' EXIT
83
+ for i in $(seq 1 30); do curl -s -o /dev/null -m 0.2 "http://127.0.0.1:${PORT}/" 2>/dev/null && break; sleep 0.1; done
84
+
85
+ mkdir -p "$WORK/home/.crewly"
86
+ ENVS=(CREWLY_API_URL="http://127.0.0.1:${PORT}" CREWLY_SESSION_NAME=dev-ella CREWLY_AGENT_BADGE=badge1 CREWLY_PROJECT_PATH="$WORK" HOME="$WORK/home" CREWLY_HOME="$WORK/home/.crewly")
87
+ run() { env "${ENVS[@]}" bash "$EXEC" "$@" 2>/dev/null; }
88
+ run_err() { env "${ENVS[@]}" bash "$EXEC" "$@" 2>&1 >/dev/null; }
89
+
90
+ APPDIR="$WORK/groceries"
91
+ mkdir -p "$APPDIR/js" "$APPDIR/.git" "$APPDIR/node_modules/x"
92
+ printf '<h1>hi</h1>' > "$APPDIR/index.html"
93
+ printf 'console.log(1)' > "$APPDIR/js/app.js"
94
+ printf 'SECRET=1' > "$APPDIR/.env"
95
+ printf 'x' > "$APPDIR/.git/config"
96
+ printf 'x' > "$APPDIR/node_modules/x/i.js"
97
+
98
+ OUT=$(run --dir "$APPDIR" --name "Groceries" --note "first" --notify)
99
+ check "publish: output (card fields, never the signed link)" "$OUT" '{"success":true,"appId":"28au74d9cj","name":"Groceries","url":"https://apps.crewlyai.com/28au74d9cj","version":3,"created":false,"notified":true,"card":"signed","cardPlace":"owner-dm","linkId":"lnk_1","linkExpiresAt":"e"}'
100
+ check "publish: files (no dotfiles / node_modules)" "$(jq -c '[.body.files[].path]' "$STUB_LOG")" '["index.html","js/app.js"]'
101
+ check "publish: content is base64" "$(jq -r '.body.files[0].contentBase64' "$STUB_LOG" | base64 --decode 2>/dev/null || jq -r '.body.files[0].contentBase64' "$STUB_LOG" | base64 -D)" '<h1>hi</h1>'
102
+ check "publish: options" "$(jq -c '{name: .body.name, note: .body.note, notify: .body.notify, sourceSet: (.body.source | endswith("groceries"))}' "$STUB_LOG")" '{"name":"Groceries","note":"first","notify":true,"sourceSet":true}'
103
+ check "publish: agent identity headers" "$(jq -c '{badge, session}' "$STUB_LOG")" '{"badge":"badge1","session":"dev-ella"}'
104
+
105
+ printf '<p>t</p>' > "$WORK/timer.html"
106
+ run --html "$WORK/timer.html" --app 28au74d9cj >/dev/null
107
+ check "single file → index.html, explicit app" "$(jq -c '{paths: [.body.files[].path], appId: .body.appId, notify: (.body.notify // false)}' "$STUB_LOG")" '{"paths":["index.html"],"appId":"28au74d9cj","notify":false}'
108
+
109
+ OUT=$(run_err --html "$WORK/timer.txt" || true)
110
+ check "missing file refused" "$(printf '%s' "$OUT" | grep -c 'file not found')" "1"
111
+ mkdir -p "$WORK/noindex"; printf 'x' > "$WORK/noindex/a.html"
112
+ OUT=$(run_err --dir "$WORK/noindex" || true)
113
+ check "dir without entry refused" "$(printf '%s' "$OUT" | grep -c 'index.html is not in')" "1"
114
+
115
+ # The bundle root must be a real path inside the project, never Crewly's home.
116
+ OUTSIDE="$(mktemp -d)"; printf '<p>o</p>' > "$OUTSIDE/index.html"
117
+ OUT=$(run_err --dir "$OUTSIDE" || true)
118
+ check "dir outside the project refused" "$(printf '%s' "$OUT" | grep -c 'outside your project directory')" "1"
119
+ ln -s "$APPDIR" "$WORK/link-app"
120
+ OUT=$(run_err --dir "$WORK/link-app" || true)
121
+ check "symlinked dir refused" "$(printf '%s' "$OUT" | grep -c 'symbolic link')" "1"
122
+ ln -s "$WORK/timer.html" "$WORK/link.html"
123
+ OUT=$(run_err --html "$WORK/link.html" || true)
124
+ check "symlinked html refused" "$(printf '%s' "$OUT" | grep -c 'symbolic link')" "1"
125
+ mkdir -p "$WORK/home/.crewly/cloud"; printf '<p>c</p>' > "$WORK/home/.crewly/cloud/index.html"
126
+ OUT=$(env "${ENVS[@]}" CREWLY_PROJECT_PATH="$WORK/home" bash "$EXEC" --dir "$WORK/home/.crewly/cloud" 2>&1 >/dev/null || true)
127
+ check "Crewly home refused even inside the project" "$(printf '%s' "$OUT" | grep -c "inside Crewly's home")" "1"
128
+ ln -s "$OUTSIDE/index.html" "$APPDIR/escape.html"
129
+ run --dir "$APPDIR" >/dev/null
130
+ check "symlinks inside the bundle are not followed" "$(jq -c '[.body.files[].path]' "$STUB_LOG")" '["index.html","js/app.js"]'
131
+ rm -rf "$OUTSIDE"
132
+
133
+ OUT=$(run --app 28au74d9cj --rollback 2)
134
+ check "rollback: output" "$OUT" '{"success":true,"appId":"28au74d9cj","url":"https://apps.crewlyai.com/28au74d9cj","currentVersion":2}'
135
+ check "rollback: request" "$(jq -c '{path, body}' "$STUB_LOG")" '{"path":"/api/apps/28au74d9cj/rollback","body":{"version":2}}'
136
+
137
+ OUT=$(run --app 28au74d9cj --versions)
138
+ check "versions" "$OUT" '{"success":true,"versions":[{"version":2,"current":true,"note":null,"files":1,"totalBytes":9,"createdAt":"t"}]}'
139
+ OUT=$(run --list)
140
+ check "list hides deleted" "$OUT" '{"success":true,"apps":[{"appId":"a","name":"A","url":"u","agent":"s","currentVersion":1,"source":"/x"}]}'
141
+
142
+ OUT=$(run --dir "$APPDIR" --name nologin; true)
143
+ check "backend error mapped" "$OUT" '{"success":false,"status":409,"reason":"not_logged_in","message":"not signed in","hint":"crewly cloud login"}'
144
+
145
+ # --- P3: signed links, link management, public requests -----------------------
146
+ OUT=$(run --app 28au74d9cj --share --ttl-days 14)
147
+ check "share: output never carries the signed link" "$OUT" '{"success":true,"appId":"28au74d9cj","url":"https://apps.crewlyai.com/28au74d9cj","visibility":"private","publicRequestPending":false,"notified":true,"card":"signed","cardPlace":"owner-dm","linkId":"lnk_2","linkExpiresAt":"e"}'
148
+ check "share: request" "$(jq -c '{method, path, body}' "$STUB_LOG")" '{"method":"POST","path":"/api/apps/28au74d9cj/share","body":{"ttlDays":14}}'
149
+ OUT=$(run_err --app 28au74d9cj --share --ttl-days 31 || true)
150
+ check "share: ttl out of range refused" "$(printf '%s' "$OUT" | grep -c 'ttl-days must be 1-30')" "1"
151
+ OUT=$(run_err --share || true)
152
+ check "share: needs --app" "$(printf '%s' "$OUT" | grep -c 'app <appId> is required')" "1"
153
+
154
+ OUT=$(run --app 28au74d9cj --links)
155
+ check "links: listed without urls" "$OUT" '{"success":true,"links":[{"linkId":"lnk_1","active":true,"uses":2,"createdAt":"c","expiresAt":"e","lastUsedAt":null,"revokedAt":null,"createdBy":"dev-ella"}]}'
156
+ OUT=$(run --app 28au74d9cj --revoke-link lnk_1)
157
+ check "revoke-link" "$OUT" '{"success":true,"linkId":"lnk_1","revoked":true}'
158
+ check "revoke-link: request" "$(jq -c '{method, path}' "$STUB_LOG")" '{"method":"DELETE","path":"/api/apps/28au74d9cj/links/lnk_1"}'
159
+ OUT=$(run --app 28au74d9cj --revoke-link nope; true)
160
+ check "revoke-link: unknown link" "$(printf '%s' "$OUT" | jq -c '{success, reason}')" '{"success":false,"reason":"not_found"}'
161
+ OUT=$(run_err --app 28au74d9cj --revoke-link '../x' || true)
162
+ check "revoke-link: bad id refused" "$(printf '%s' "$OUT" | grep -c 'link id')" "1"
163
+ OUT=$(run --app 28au74d9cj --revoke-links)
164
+ check "revoke-links" "$OUT" '{"success":true,"revoked":3}'
165
+ check "revoke-links: request" "$(jq -c '{method, path}' "$STUB_LOG")" '{"method":"DELETE","path":"/api/apps/28au74d9cj/links"}'
166
+
167
+ OUT=$(run --app 28au74d9cj --public --public-read "items, stats,items" --public-submit votes --public-note "class poll")
168
+ check "public request: output says the owner approves" "$OUT" '{"success":true,"appId":"28au74d9cj","url":"https://apps.crewlyai.com/28au74d9cj","requested":true,"message":"Requested: the owner approves it by opening the app. It stays private until they do; you cannot make it public yourself.","visibility":"private","publicRequest":{"publicRead":["items","stats"],"publicSubmit":["votes"],"note":"class poll"},"notified":true,"card":"signed","cardPlace":"owner-dm","linkId":"lnk_3"}'
169
+ check "public request: body" "$(jq -c '{path, body}' "$STUB_LOG")" '{"path":"/api/apps/28au74d9cj/visibility-request","body":{"publicRead":["items","stats"],"publicSubmit":["votes"],"note":"class poll"}}'
170
+ OUT=$(run_err --app 28au74d9cj --public || true)
171
+ check "public request: needs collections" "$(printf '%s' "$OUT" | grep -c 'public-read and/or --public-submit')" "1"
172
+ OUT=$(run_err --app 28au74d9cj --public-read 'bad.name' || true)
173
+ check "public request: bad collection refused" "$(printf '%s' "$OUT" | grep -c 'is not a collection name')" "1"
174
+ MANY=$(seq -s, 1 21 | sed 's/\([0-9]*\)/c\1/g')
175
+ OUT=$(run_err --app 28au74d9cj --public-submit "$MANY" || true)
176
+ check "public request: at most 20" "$(printf '%s' "$OUT" | grep -c 'at most 20')" "1"
177
+
178
+ run --dir "$APPDIR" --public-read items >/dev/null
179
+ check "publish + public: request in the publish body" "$(jq -c '{publicRequest: .body.publicRequest, notify: (.body.notify // false)}' "$STUB_LOG")" '{"publicRequest":{"publicRead":["items"],"publicSubmit":[]},"notify":false}'
180
+ OUT=$(run --dir "$APPDIR" --public-read items)
181
+ check "publish + public: output" "$(printf '%s' "$OUT" | jq -c '{publicRequested, message, card}')" '{"publicRequested":true,"message":"Requested: the owner approves it by opening the app. It stays private until they do; you cannot make it public yourself.","card":"signed"}'
182
+ OUT=$(run --dir "$APPDIR" --public-read items --public-note fail)
183
+ check "publish + public: soft failure" "$(printf '%s' "$OUT" | jq -c '{success, publicRequested, publicError}')" '{"success":true,"publicRequested":false,"publicError":"old cloud"}'
184
+
185
+ OUT=$(run --dir "$APPDIR" --name WasPublic)
186
+ check "publish of a public app: publicPaused" "$(printf '%s' "$OUT" | jq -c '{success, publicPaused, publicPausedMessage}')" '{"success":true,"publicPaused":true,"publicPausedMessage":"owner must re-approve"}'
187
+ OUT=$(run --app 28au74d9cj --rollback 9)
188
+ check "rollback of a public app: publicPaused" "$OUT" '{"success":true,"appId":"28au74d9cj","url":"https://apps.crewlyai.com/28au74d9cj","currentVersion":9,"publicPaused":true,"message":"owner must re-approve"}'
189
+ OUT=$(run --dir "$APPDIR" --name "Crewly Login" --public-read items || true)
190
+ check "publish + public with a refused name: clear error" "$(printf '%s' "$OUT" | jq -c '{success, status, reason}')" '{"success":false,"status":400,"reason":"validation"}'
191
+ check "publish + public with a refused name: message" "$(printf '%s' "$OUT" | jq -r '.message' | grep -c 'may not contain')" "1"
192
+
193
+ OUT=$(run --app 28au74d9cj --cancel-public)
194
+ check "cancel-public" "$OUT" '{"success":true,"appId":"28au74d9cj","cancelled":true,"visibility":"private"}'
195
+ check "cancel-public: request" "$(jq -c '{method, path}' "$STUB_LOG")" '{"method":"DELETE","path":"/api/apps/28au74d9cj/visibility-request"}'
196
+ OUT=$(run --app 28au74d9cj --private)
197
+ check "private" "$OUT" '{"success":true,"appId":"28au74d9cj","visibility":"private"}'
198
+ check "private: request" "$(jq -c '{method, path}' "$STUB_LOG")" '{"method":"POST","path":"/api/apps/28au74d9cj/make-private"}'
199
+ OUT=$(run_err --app 28au74d9cj --share --links || true)
200
+ check "one action at a time" "$(printf '%s' "$OUT" | grep -c 'at a time')" "1"
201
+ OUT=$(run_err --dir "$APPDIR" --private || true)
202
+ check "--private is not a publish option" "$(printf '%s' "$OUT" | grep -c 'act on an app')" "1"
203
+
204
+ echo "publish-app: $PASS passed, $FAIL failed"
205
+ [ "$FAIL" -eq 0 ]