nowrun 2.0.0__tar.gz → 2.0.2__tar.gz

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 (35) hide show
  1. {nowrun-2.0.0 → nowrun-2.0.2}/PKG-INFO +13 -12
  2. {nowrun-2.0.0 → nowrun-2.0.2}/README.md +12 -11
  3. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/cli.py +33 -18
  4. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/commands/admin.py +117 -68
  5. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/commands/deploy.py +67 -23
  6. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/commands/inspect.py +1 -2
  7. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/config.py +23 -19
  8. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/output.py +6 -0
  9. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/skill/SKILL.md +48 -32
  10. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun.egg-info/PKG-INFO +13 -12
  11. {nowrun-2.0.0 → nowrun-2.0.2}/setup.py +1 -1
  12. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/__init__.py +0 -0
  13. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/__main__.py +0 -0
  14. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/apk.py +0 -0
  15. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/appmeta.py +0 -0
  16. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/client.py +0 -0
  17. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/commands/__init__.py +0 -0
  18. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/commands/app.py +0 -0
  19. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/commands/legacy.py +0 -0
  20. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/commands/login.py +0 -0
  21. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/commands/mcp.py +0 -0
  22. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/commands/mode.py +0 -0
  23. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/commands/setup.py +0 -0
  24. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/commands/skill.py +0 -0
  25. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/commands/update.py +0 -0
  26. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/commands/validate.py +0 -0
  27. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/fields.py +0 -0
  28. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/passthrough.py +0 -0
  29. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun/upload.py +0 -0
  30. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun.egg-info/SOURCES.txt +0 -0
  31. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun.egg-info/dependency_links.txt +0 -0
  32. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun.egg-info/entry_points.txt +0 -0
  33. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun.egg-info/requires.txt +0 -0
  34. {nowrun-2.0.0 → nowrun-2.0.2}/nowrun.egg-info/top_level.txt +0 -0
  35. {nowrun-2.0.0 → nowrun-2.0.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: nowrun
3
- Version: 2.0.0
3
+ Version: 2.0.2
4
4
  Summary: CLI to deploy and manage Android and HTML5 apps on nowrun.
5
5
  Home-page: https://nowrun.io
6
6
  Author: nowrun
@@ -14,12 +14,12 @@ Description-Content-Type: text/markdown
14
14
 
15
15
  # nowrun CLI
16
16
 
17
- Deploy Android and HTML5 apps to a live URL - driven by your AI coding agent.
17
+ Publish Android apps so people can run them inside ChatGPT - driven by your AI coding agent.
18
18
 
19
- `nowrun` publishes Android and HTML5 apps to [nowrun](https://nowrun.io) and streams each at a
20
- URL anyone can open in a browser, no install. You don't run deploy commands by hand: you set
21
- the CLI up once, then tell your agent to ship. Android works with any engine that builds an APK
22
- (native, Unity, Godot, Unreal); HTML5 games deploy as a zipped bundle.
19
+ `nowrun` publishes Android apps to [nowrun](https://nowrun.io) so people can run them inside
20
+ ChatGPT (and other AI assistants that connect to nowrun) - streamed into the conversation, no
21
+ install and no app store. You don't run deploy commands by hand: you set the CLI up once, then
22
+ tell your agent to ship. Works with any engine that builds an APK (native, Unity, Godot, Unreal).
23
23
 
24
24
  ## Quickstart
25
25
 
@@ -38,10 +38,10 @@ copy or paste. (`nowrun update` refreshes it later; `nowrun logout` signs out.)
38
38
 
39
39
  **3. Tell your agent.** Open your AI coding tool in your project and give it this prompt:
40
40
 
41
- > Deploy my app to nowrun and give me the live public URL using the nowrun CLI. If you need the steps, read https://nowrun.io/docs/skill.md
41
+ > Deploy my app to nowrun so people can run it in ChatGPT using the nowrun CLI. If you need the steps, read https://nowrun.io/docs/skill.md
42
42
 
43
- Your agent creates (or reuses) the app, uploads the build, deploys it, and hands back the live
44
- URL. That's the whole flow.
43
+ Your agent creates (or reuses) the app, uploads the build, and deploys it - and once it's live,
44
+ people can run it inside ChatGPT. That's the whole flow.
45
45
 
46
46
  ## What your agent runs for you
47
47
 
@@ -56,13 +56,14 @@ They're here for reference (and the occasional manual run):
56
56
  | `nowrun app list` / `nowrun app show` | list your apps / show one, with its visibility and readiness |
57
57
  | `nowrun inspect app.apk` | read a build's package + version, no Android SDK needed |
58
58
  | `nowrun validate` | check what's missing before deploying |
59
- | `nowrun deploy -f app.apk` | deploy a build and get its live link (version read from the APK; `--wait` blocks until live) |
59
+ | `nowrun deploy -f app.apk` | deploy a build so it's runnable in ChatGPT (version read from the APK; `--wait` blocks until live) |
60
60
  | `nowrun status -s <submission_id>` | poll until it's live |
61
61
  | `nowrun help --json` | full command list + workflow (what the agent reads) |
62
62
 
63
63
  `deploy` reads the build's package/version and **refuses to deploy a build whose package
64
- doesn't match the app**, so you never publish to the wrong place. Add `--json` (in any
65
- position) to any command for machine-readable output.
64
+ doesn't match the app**, so you never publish to the wrong place. It also **refuses a version
65
+ code that isn't higher than the one currently live** (like the Play Store), so you can't ship a
66
+ downgrade. Add `--json` (in any position) to any command for machine-readable output.
66
67
 
67
68
  New apps are private; a nowrun admin decides which deployed apps are listed in ChatGPT.
68
69
  You stay signed in: the CLI refreshes its session automatically (for 30 days after last use).
@@ -1,11 +1,11 @@
1
1
  # nowrun CLI
2
2
 
3
- Deploy Android and HTML5 apps to a live URL - driven by your AI coding agent.
3
+ Publish Android apps so people can run them inside ChatGPT - driven by your AI coding agent.
4
4
 
5
- `nowrun` publishes Android and HTML5 apps to [nowrun](https://nowrun.io) and streams each at a
6
- URL anyone can open in a browser, no install. You don't run deploy commands by hand: you set
7
- the CLI up once, then tell your agent to ship. Android works with any engine that builds an APK
8
- (native, Unity, Godot, Unreal); HTML5 games deploy as a zipped bundle.
5
+ `nowrun` publishes Android apps to [nowrun](https://nowrun.io) so people can run them inside
6
+ ChatGPT (and other AI assistants that connect to nowrun) - streamed into the conversation, no
7
+ install and no app store. You don't run deploy commands by hand: you set the CLI up once, then
8
+ tell your agent to ship. Works with any engine that builds an APK (native, Unity, Godot, Unreal).
9
9
 
10
10
  ## Quickstart
11
11
 
@@ -24,10 +24,10 @@ copy or paste. (`nowrun update` refreshes it later; `nowrun logout` signs out.)
24
24
 
25
25
  **3. Tell your agent.** Open your AI coding tool in your project and give it this prompt:
26
26
 
27
- > Deploy my app to nowrun and give me the live public URL using the nowrun CLI. If you need the steps, read https://nowrun.io/docs/skill.md
27
+ > Deploy my app to nowrun so people can run it in ChatGPT using the nowrun CLI. If you need the steps, read https://nowrun.io/docs/skill.md
28
28
 
29
- Your agent creates (or reuses) the app, uploads the build, deploys it, and hands back the live
30
- URL. That's the whole flow.
29
+ Your agent creates (or reuses) the app, uploads the build, and deploys it - and once it's live,
30
+ people can run it inside ChatGPT. That's the whole flow.
31
31
 
32
32
  ## What your agent runs for you
33
33
 
@@ -42,13 +42,14 @@ They're here for reference (and the occasional manual run):
42
42
  | `nowrun app list` / `nowrun app show` | list your apps / show one, with its visibility and readiness |
43
43
  | `nowrun inspect app.apk` | read a build's package + version, no Android SDK needed |
44
44
  | `nowrun validate` | check what's missing before deploying |
45
- | `nowrun deploy -f app.apk` | deploy a build and get its live link (version read from the APK; `--wait` blocks until live) |
45
+ | `nowrun deploy -f app.apk` | deploy a build so it's runnable in ChatGPT (version read from the APK; `--wait` blocks until live) |
46
46
  | `nowrun status -s <submission_id>` | poll until it's live |
47
47
  | `nowrun help --json` | full command list + workflow (what the agent reads) |
48
48
 
49
49
  `deploy` reads the build's package/version and **refuses to deploy a build whose package
50
- doesn't match the app**, so you never publish to the wrong place. Add `--json` (in any
51
- position) to any command for machine-readable output.
50
+ doesn't match the app**, so you never publish to the wrong place. It also **refuses a version
51
+ code that isn't higher than the one currently live** (like the Play Store), so you can't ship a
52
+ downgrade. Add `--json` (in any position) to any command for machine-readable output.
52
53
 
53
54
  New apps are private; a nowrun admin decides which deployed apps are listed in ChatGPT.
54
55
  You stay signed in: the CLI refreshes its session automatically (for 30 days after last use).
@@ -57,7 +57,7 @@ WORKFLOW = [
57
57
  "app create --title <t> ... # create an app with all its metadata (or 'app list' to reuse one)",
58
58
  "use <app_id> # bind this workspace to an app (optional)",
59
59
  "validate # check what's missing before deploying",
60
- "deploy -f app.apk ... # upload + deploy a build and get the live link",
60
+ "deploy -f app.apk ... # upload + deploy a build (runnable in ChatGPT once live)",
61
61
  "status # poll until it's live",
62
62
  "mode [admin|personal] # nowrun admins: show/switch mode (admin = nowrun-managed apps, audited)",
63
63
  "admin apps list ... # nowrun admins (admin mode): review apps, set-public/disable/..., users, audit",
@@ -112,8 +112,9 @@ def build_manifest(parser):
112
112
  tree = _describe(parser, 'nowrun', 'nowrun publishing CLI')
113
113
  return {
114
114
  "tool": "nowrun",
115
- "description": "Deploy Android and HTML5 apps to nowrun. Sign in once with `nowrun login`. "
116
- "Every command supports --json for machine-readable output.",
115
+ "description": "Deploy Android apps to nowrun so people can run them inside ChatGPT. "
116
+ "Sign in once with `nowrun login`. Every command supports --json for "
117
+ "machine-readable output.",
117
118
  "workflow": WORKFLOW,
118
119
  "commands": tree.get("subcommands", []),
119
120
  "global_options": tree.get("args", []),
@@ -248,7 +249,7 @@ def build_parser():
248
249
  'flags override it. In admin mode this creates a nowrun-managed app.')
249
250
  app_create_parser.add_argument('-t', '--title', type=str, help='App title (required)')
250
251
  app_create_parser.add_argument('--type', dest='game_type', choices=['android', 'h5'],
251
- help='App type: android (default) or h5 (HTML5 web game)')
252
+ help='App type (default: android)')
252
253
  app_create_parser.add_argument('-p', '--package', dest='app_pkg_name', type=str,
253
254
  help='Android package name (required for --type android; or use --from-apk)')
254
255
  app_create_parser.add_argument('-f', '--from-apk', dest='from_apk', type=str,
@@ -279,24 +280,29 @@ def build_parser():
279
280
  app_delete_parser.add_argument('-y', '--yes', action='store_true', help='Skip the confirmation prompt (for scripts/agents)')
280
281
 
281
282
  # deploy
282
- deploy_parser = subparsers.add_parser('deploy', help='Deploy a build and get its live link', formatter_class=fmt)
283
+ deploy_parser = subparsers.add_parser('deploy', help='Deploy a build (runnable in ChatGPT once live)', formatter_class=fmt)
283
284
  deploy_parser.add_argument('-a', '--app-id', '--app_id', dest='app_id', type=str, help="The app's app_id from `app create` / `app list` (default: workspace app)", required=False)
284
285
  deploy_parser.add_argument('-f', '--file_path', type=str,
285
- help='Path to the build: an .apk/.zip for Android, or a .zip web bundle for HTML5',
286
+ help='Path to the build: an .apk or .zip for Android',
286
287
  required=True)
287
288
  deploy_parser.add_argument('-av', '--apk_version', type=str,
288
289
  help='Version name (Android; auto-read from the .apk if omitted)', required=False)
289
290
  deploy_parser.add_argument('-vc', '--version_code', type=int,
290
291
  help='Version code (Android; auto-read from the .apk if omitted)', required=False)
291
292
  deploy_parser.add_argument('--wait', action='store_true',
292
- help='Block until the deployment is live (or fails) and print the live URL')
293
+ help='Block until the deployment is live (or fails)')
293
294
  deploy_parser.add_argument('--timeout', type=int, default=900,
294
295
  help='With --wait, give up after this many seconds (default: 900)')
295
296
 
296
297
  # status
297
- status_parser = subparsers.add_parser('status', help='Check deployment/job status', formatter_class=fmt)
298
+ status_parser = subparsers.add_parser('status', help='Show what is live and in progress, or one submission with -s', formatter_class=fmt)
298
299
  status_parser.add_argument('-a', '--app-id', '--app_id', dest='app_id', type=str, help="The app's app_id from `app create` / `app list` (default: workspace app)", required=False)
299
- status_parser.add_argument('-s', '--submission_id', type=str, help='Submission ID (from deploy)', required=True)
300
+ status_parser.add_argument('-s', '--submission_id', type=str, help='A specific submission (from deploy); omit for the deployment overview', required=False)
301
+
302
+ # cancel
303
+ cancel_parser = subparsers.add_parser('cancel', help='Cancel the deployment in progress for an app', formatter_class=fmt)
304
+ cancel_parser.add_argument('-a', '--app-id', '--app_id', dest='app_id', type=str, help="The app's app_id from `app create` / `app list` (default: workspace app)", required=False)
305
+ cancel_parser.add_argument('-s', '--submission_id', type=str, help='A specific in-progress submission to cancel (default: the app\'s current one)', required=False)
300
306
 
301
307
  # validate (publish-readiness)
302
308
  validate_parser = subparsers.add_parser('validate', help='Check publish-readiness of an app', formatter_class=fmt)
@@ -373,7 +379,7 @@ def _admin_parsers(subparsers):
373
379
  'set-private': 'Remove apps from the catalog listing',
374
380
  'disable': 'Disable apps: out of the catalog, read-only, no deploys (asks to confirm)',
375
381
  'enable': 'Re-enable disabled apps',
376
- 'unpublish': 'Take the live deployment down (private, live URL cleared; asks to confirm)',
382
+ 'unpublish': 'Take the live deployment down (private; removed from ChatGPT; asks to confirm)',
377
383
  'remove': 'Remove any apps: out of every list and the catalog, package freed, record kept (asks to confirm)',
378
384
  'delete': 'PERMANENTLY erase nowrun-managed apps and their builds - cannot be undone (asks to confirm)',
379
385
  }
@@ -384,14 +390,16 @@ def _admin_parsers(subparsers):
384
390
  p.add_argument('-y', '--yes', action='store_true', help='Skip the confirmation prompt')
385
391
 
386
392
  fetch = apps_sub.add_parser(
387
- 'fetch-apk', help='Download an APK from a link into the deploy bucket (does not deploy)',
393
+ 'fetch-apk', help='Download a build from a link to this machine and report it (does not deploy)',
388
394
  formatter_class=fmt,
389
- description="Download an APK from a link server-side and report what it is (package, "
390
- "version, size, SHA-256). The build is an untrusted third-party one: it is "
391
- "staged, not deployed, until you review it and pass --deploy.")
395
+ description="Download an APK/ZIP from a link onto this machine, read its metadata "
396
+ "locally (package, version, size, SHA-256), and report it. The download and, "
397
+ "on --deploy, the upload run from here, not through nowrun's servers. The "
398
+ "build is an untrusted third-party one: it is not deployed until you review "
399
+ "it and pass --deploy, which deploys it via the normal upload flow.")
392
400
  fetch.add_argument('-a', '--app-id', '--app_id', dest='app_id', type=str,
393
401
  help="The app to provision (default: workspace app)", required=False)
394
- fetch.add_argument('--url', type=str, required=True, help='Direct http(s) link to the .apk')
402
+ fetch.add_argument('--url', type=str, required=True, help='Direct http(s) link to the .apk or .zip')
395
403
  fetch.add_argument('--expected-package', dest='expected_package', type=str,
396
404
  help='Fail unless the fetched build has this package name')
397
405
  fetch.add_argument('--deploy', action='store_true',
@@ -518,8 +526,8 @@ def main():
518
526
  "publisher tokens are no longer used - ignoring -t/--token and signing in with "
519
527
  "`nowrun login` (browser). Update this command to `nowrun login`.")
520
528
  # login is strict about the target environment: with --host, use it; WITHOUT --host,
521
- # always reset to prod - never inherit a host left over from an earlier `--host qa1`
522
- # (which would silently sign you into the wrong environment).
529
+ # always reset to prod - never inherit a host left over from an earlier
530
+ # `--host https://qa.nowrun.io` (which would silently sign you into the wrong environment).
523
531
  if getattr(args, 'host', None):
524
532
  config.set_host(config.resolve_host(args.host))
525
533
  else:
@@ -598,7 +606,14 @@ def main():
598
606
  elif args.command == 'status':
599
607
  app_id = resolve_app_id(args)
600
608
  if app_id:
601
- deploy_cmd.status(app_id, args.submission_id)
609
+ if args.submission_id:
610
+ deploy_cmd.status(app_id, args.submission_id)
611
+ else:
612
+ deploy_cmd.deployment_overview(app_id)
613
+ elif args.command == 'cancel':
614
+ app_id = resolve_app_id(args)
615
+ if app_id:
616
+ deploy_cmd.cancel(app_id, args.submission_id)
602
617
  elif args.command == 'validate':
603
618
  app_id = resolve_app_id(args)
604
619
  if app_id:
@@ -6,8 +6,15 @@ suspend|reactivate; audit. All under /v2/admin/*, which the server refuses outsi
6
6
  is given before any request. Every change is audited server-side, one entry per item.
7
7
  """
8
8
  import datetime
9
+ import hashlib
10
+ import os
9
11
  import sys
12
+ import tempfile
13
+ import urllib.parse
10
14
 
15
+ import requests
16
+
17
+ from nowrun import apk
11
18
  from nowrun import config
12
19
  from nowrun import fields as fieldmap
13
20
  from nowrun import output
@@ -60,10 +67,6 @@ def require_admin_mode():
60
67
  return False
61
68
 
62
69
 
63
- def _error_code(body):
64
- return (body.get('error_code') or body.get('code')) if isinstance(body, dict) else None
65
-
66
-
67
70
  def _bool_param(value):
68
71
  return None if value is None else ('true' if value else 'false')
69
72
 
@@ -257,62 +260,136 @@ def apps_action(command, app_ids, reason=None, assume_yes=False):
257
260
  _bulk('/v2/admin/apps/bulk', 'app_ids', app_ids, action, reason, 'app')
258
261
 
259
262
 
260
- # --- apk sourcing (provision an app from a link) --------------------------------------
263
+ # --- apk sourcing (download a build from a link and deploy it from this machine) -------
264
+
265
+ # Streamed and capped so a bad link cannot fill the disk; matches the size the deploy
266
+ # pipeline accepts.
267
+ _MAX_APK_BYTES = 400 * 1024 * 1024
268
+ _DOWNLOAD_TIMEOUT = 120
269
+ _TOO_LARGE = "That build is larger than nowrun allows (400 MB)."
270
+
261
271
 
262
272
  def fetch_apk(app_id, url, expected_package=None, deploy=False, assume_yes=False,
263
273
  wait=False, timeout=900):
264
- """Download an APK from a link into the deploy bucket (server-side) and report what it is.
265
- With deploy=True, deploy it after confirming; otherwise stage it and tell the user to
266
- review and deploy."""
274
+ """Download an APK/ZIP from a link ON THIS MACHINE, read its metadata locally, and - with
275
+ --deploy - deploy it through the normal upload flow (the same path as `nowrun deploy -f`).
276
+
277
+ The build is fetched and uploaded from here, not routed through nowrun's servers: a local
278
+ machine is faster for the download + upload, and the metadata is read locally rather than by
279
+ the backend."""
267
280
  if not require_admin_mode():
268
281
  return
269
- body = {"app_id": app_id, "url": url}
270
- if expected_package:
271
- body["expected_package"] = expected_package
272
- ok, resp, status = StudioClient().request('POST', '/v2/admin/apps/fetch-apk', json_body=body)
273
- if not ok:
274
- _fetch_apk_error(resp, status)
275
- return
276
- data = resp.get('data') or {}
277
- if deploy and not data.get('package_matches', True):
278
- # A build whose package doesn't match would deploy to the wrong app.
279
- emit_error(f"The fetched build's package '{data.get('package')}' does not match this app.",
280
- code='PACKAGE_MISMATCH',
281
- hint="Fetch the right build, or omit --deploy to stage and review it first.")
282
- return
283
- if not deploy:
284
- emit_success({**data, "staged": True, "deployed": False}, human=_print_fetched)
282
+ if urllib.parse.urlparse(url).scheme not in ("http", "https"):
283
+ emit_error("The build link must be an http(s) URL.", code="INVALID_URL",
284
+ hint="Pass a direct link to an .apk or .zip.")
285
285
  return
286
286
 
287
- if not _confirm_fetch_deploy(app_id, data, assume_yes):
288
- return
289
- from nowrun.commands import deploy as deploy_cmd
290
287
  if not output.is_json():
291
- _print_fetched(data)
292
- deploy_cmd.deploy_staged(app_id, data.get('upload_url'), data.get('upload_type'),
293
- version=data.get('version_name'), version_code=data.get('version_code'),
294
- wait=wait, timeout=timeout)
288
+ logger.info(color("Downloading the build to this machine…", 'dim'))
289
+ path, err = _download_build(url)
290
+ if err:
291
+ emit_error(err[0], code=err[1], hint=err[2])
292
+ return
293
+ try:
294
+ meta = apk.inspect(path) # local read: package, version_name, version_code
295
+ package = meta.get("package")
296
+ if expected_package and package and package != expected_package:
297
+ emit_error(f"The build is '{package}', not '{expected_package}'.",
298
+ code="PACKAGE_MISMATCH",
299
+ hint="Fetch the build whose package matches, or drop --expected-package.")
300
+ return
301
+ data = {"package": package, "version_name": meta.get("version_name"),
302
+ "version_code": meta.get("version_code"), "size": os.path.getsize(path),
303
+ "sha256": _sha256_file(path), "deployed": bool(deploy)}
304
+ if not deploy:
305
+ emit_success(data, human=_print_fetched)
306
+ return
307
+ if not _confirm_fetch_deploy(app_id, data, assume_yes):
308
+ return
309
+ from nowrun.commands import deploy as deploy_cmd
310
+ if not output.is_json():
311
+ _print_fetched(data)
312
+ # Deploy via the normal local upload path (multipart upload + trigger), exactly like
313
+ # `nowrun deploy -f`: it re-reads the package/version from the file and refuses a build
314
+ # whose package does not match the bound app.
315
+ deploy_cmd.deploy(app_id, path, meta.get("version_name"), meta.get("version_code"),
316
+ wait=wait, timeout=timeout)
317
+ finally:
318
+ _safe_remove(path)
319
+
320
+
321
+ def _download_build(url):
322
+ """Stream a build to a temp file on this machine. Returns (path, None) on success, or
323
+ (None, (message, code, hint)) on failure."""
324
+ # Keep the extension so apk.inspect() and deploy() route .zip vs .apk correctly.
325
+ suffix = ".zip" if urllib.parse.urlparse(url).path.lower().endswith(".zip") else ".apk"
326
+ tmp = tempfile.NamedTemporaryFile(prefix="nowrun-fetch-", suffix=suffix, delete=False)
327
+ path = tmp.name
328
+ total = 0
329
+ try:
330
+ with requests.get(url, stream=True, timeout=_DOWNLOAD_TIMEOUT,
331
+ allow_redirects=True) as resp:
332
+ if resp.status_code != 200:
333
+ tmp.close(); _safe_remove(path)
334
+ return None, (f"The link returned HTTP {resp.status_code}.", "FETCH_FAILED",
335
+ "Check the link and retry.")
336
+ declared = int(resp.headers.get("content-length") or 0)
337
+ if declared and declared > _MAX_APK_BYTES:
338
+ tmp.close(); _safe_remove(path)
339
+ return None, (_TOO_LARGE, "APK_TOO_LARGE", None)
340
+ for chunk in resp.iter_content(chunk_size=256 * 1024):
341
+ if not chunk:
342
+ continue
343
+ total += len(chunk)
344
+ if total > _MAX_APK_BYTES:
345
+ tmp.close(); _safe_remove(path)
346
+ return None, (_TOO_LARGE, "APK_TOO_LARGE", None)
347
+ tmp.write(chunk)
348
+ tmp.close()
349
+ return path, None
350
+ except requests.Timeout:
351
+ tmp.close(); _safe_remove(path)
352
+ return None, ("The download timed out.", "FETCH_TIMEOUT", "Try a faster or direct link.")
353
+ except requests.RequestException as exc:
354
+ tmp.close(); _safe_remove(path)
355
+ return None, (f"Could not download the build: {exc}", "FETCH_FAILED",
356
+ "Check the link and retry.")
357
+
358
+
359
+ def _sha256_file(path):
360
+ h = hashlib.sha256()
361
+ with open(path, "rb") as f:
362
+ for block in iter(lambda: f.read(1024 * 1024), b""):
363
+ h.update(block)
364
+ return h.hexdigest()
365
+
366
+
367
+ def _safe_remove(path):
368
+ try:
369
+ os.remove(path)
370
+ except OSError:
371
+ pass
295
372
 
296
373
 
297
374
  def _print_fetched(d):
298
- logger.info(color("Fetched an unreviewed third-party build. Review it before deploying.", 'yellow'))
299
- logger.info(f"Package : {d.get('package', '')}"
300
- + ("" if d.get('package_matches', True) else color(" (does NOT match the app!)", 'red')))
375
+ logger.info(color("Downloaded an unreviewed third-party build to this machine. "
376
+ "Review it before deploying.", 'yellow'))
377
+ logger.info(f"Package : {d.get('package', '')}")
301
378
  logger.info(f"Version : {d.get('version_name', '')} (code {d.get('version_code', '')})")
302
379
  size = d.get('size')
303
380
  if size:
304
381
  logger.info(f"Size : {round(int(size) / (1024 * 1024), 2)} MB")
305
382
  logger.info(f"SHA-256 : {d.get('sha256', '')}")
306
- if not d.get('deployed', True) and d.get('staged'):
307
- logger.info(color("Staged, not deployed. Deploy it with: "
308
- "nowrun admin apps fetch-apk -a <app_id> --url <same url> --deploy", 'dim'))
383
+ if not d.get('deployed', True):
384
+ logger.info(color("Not deployed. Deploy it with: nowrun admin apps fetch-apk "
385
+ "-a <app_id> --url <same url> --deploy", 'dim'))
309
386
 
310
387
 
311
388
  def _confirm_fetch_deploy(app_id, data, assume_yes):
312
389
  if assume_yes:
313
390
  return True
314
391
  if sys.stdin.isatty() and not output.is_json():
315
- logger.info(color(f"[ADMIN MODE] Deploy the fetched build '{data.get('package')}' "
392
+ logger.info(color(f"[ADMIN MODE] Deploy the downloaded build '{data.get('package')}' "
316
393
  f"v{data.get('version_name')} to {app_id}? It is an unreviewed "
317
394
  "third-party build. This is audited.", 'yellow'))
318
395
  try:
@@ -321,42 +398,14 @@ def _confirm_fetch_deploy(app_id, data, assume_yes):
321
398
  resp = ''
322
399
  if resp.strip().lower() == 'yes':
323
400
  return True
324
- emit_error("Aborted - the build is staged but not deployed.", code='ABORTED')
401
+ emit_error("Aborted - the build was downloaded but not deployed.", code='ABORTED')
325
402
  return False
326
403
  emit_error(f"Refusing to deploy an unreviewed build to {app_id} without confirmation.",
327
404
  code='CONFIRMATION_REQUIRED',
328
- hint="Re-run with --yes to confirm, or omit --deploy to stage and review first.")
405
+ hint="Re-run with --yes to confirm, or omit --deploy to download and review first.")
329
406
  return False
330
407
 
331
408
 
332
- _FETCH_APK_GUIDANCE = {
333
- 'NOT_AN_APK': "The link did not point to a valid Android APK. Use a direct link to an .apk file.",
334
- 'INVALID_URL': "The URL is not valid. Pass a direct http(s) link to the build.",
335
- 'FETCH_FAILED': "nowrun couldn't download the build from that link. Check the link and retry.",
336
- 'FETCH_TIMEOUT': "The download timed out. Try a faster mirror or a direct link.",
337
- 'APK_TOO_LARGE': "The build is larger than nowrun allows. Use a smaller build.",
338
- }
339
-
340
-
341
- def _fetch_apk_error(body, status):
342
- code = _error_code(body)
343
- if code == 'PACKAGE_MISMATCH':
344
- fetched = ((body.get('details') or {}).get('fetched_package')
345
- if isinstance(body, dict) else None)
346
- got = f" (got '{fetched}')" if fetched else ""
347
- emit_error(f"The fetched build's package doesn't match --expected-package{got}.",
348
- http_status=status, code=code,
349
- hint="Fetch the build whose package matches, or drop --expected-package to "
350
- "stage it anyway and review.")
351
- return
352
- hint = _FETCH_APK_GUIDANCE.get(code)
353
- if hint:
354
- message = body.get('message') if isinstance(body, dict) else None
355
- emit_error(message or hint, http_status=status, code=code, hint=hint)
356
- return
357
- api_error(body, status, fallback='Failed to fetch the APK')
358
-
359
-
360
409
  # --- users ----------------------------------------------------------------------------
361
410
 
362
411
  def users_list(email=None, status=None, created_from=None, created_to=None, limit=None,
@@ -146,29 +146,6 @@ def trigger_deploy(app_id, upload_url, upload_type, version=None, version_code=N
146
146
  })
147
147
 
148
148
 
149
- def deploy_staged(app_id, upload_url, upload_type, version=None, version_code=None, wait=False,
150
- timeout=900):
151
- """Deploy a build that was already staged into the bucket (e.g. by fetch-apk), then report
152
- the submission the same way `deploy` does. Emits its own error on failure."""
153
- ok, body, status = trigger_deploy(app_id, upload_url, upload_type, version, version_code)
154
- if not ok:
155
- api_error(body, status, fallback='Deployment failed')
156
- return
157
- submission = body.get('data', {}) or {}
158
- submission_id = submission.get('id')
159
- if wait and submission_id:
160
- _wait_until_done(app_id, submission_id, timeout, [])
161
- return
162
- result = {"submission_id": submission_id,
163
- "submission_state": submission.get('submission_state'),
164
- "job_status": submission.get('job_status')}
165
-
166
- def _triggered(d):
167
- logger.info(f"Deploy started for {scope_label()} {app_id} (submission {d['submission_id']}).")
168
- logger.info(color(f" Check back: nowrun status -a {app_id} -s {d['submission_id']}", 'dim'))
169
- emit_success(result, human=_triggered)
170
-
171
-
172
149
  def _fetch_status(app_id, submission_id):
173
150
  return StudioClient().request(
174
151
  'GET', '/v2/console/app/jenkins_job_status',
@@ -242,6 +219,73 @@ def _wait_until_done(app_id, submission_id, timeout, notes):
242
219
  f"nowrun status -a {app_id} -s {submission_id}")
243
220
 
244
221
 
222
+ def cancel(app_id, submission_id=None):
223
+ """Cancel the deployment in progress for an app so another can be started. Optional
224
+ submission_id targets a specific build; otherwise the app's current in-progress one."""
225
+ params = {'app_id': app_id}
226
+ if submission_id:
227
+ params['submission_id'] = submission_id
228
+ ok, body, http_status = StudioClient().request(
229
+ 'POST', '/v2/console/app/deploy/cancel', params=params)
230
+ if not ok:
231
+ api_error(body, http_status, fallback='Failed to cancel the deployment')
232
+ return
233
+ data = body.get('data', {}) or {}
234
+ result = {"submission_id": data.get('id'), "job_status": data.get('job_status')}
235
+
236
+ def _done(d):
237
+ logger.info(color(f"✓ Cancelled the deployment for {scope_label()} {app_id}.", 'yellow'))
238
+ logger.info(color(" Start a new one when ready: nowrun deploy -f <build>", 'dim'))
239
+ emit_success(result, human=_done)
240
+
241
+
242
+ def deployment_overview(app_id):
243
+ """Show what is live and what is building for an app: the live version, any in-progress
244
+ build, and recent deployment history - the same view as the dashboard's deploy panel."""
245
+ ok, body, http_status = StudioClient().request(
246
+ 'GET', '/v2/console/app/deploy/status', params={'app_id': app_id})
247
+ if not ok:
248
+ api_error(body, http_status, fallback='Failed to fetch deployment status')
249
+ return
250
+ data = body.get('data', {}) or {}
251
+
252
+ def _print(d):
253
+ live = d.get('live') or {}
254
+ active = d.get('active')
255
+ recent = d.get('recent') or []
256
+ # Live
257
+ if live.get('url'):
258
+ vc = live.get('version_code')
259
+ head = f"● Live: v{vc}" if vc is not None else "● Live"
260
+ logger.info(color(head, 'green'))
261
+ logger.info(f" {live.get('url')}")
262
+ if live.get('published_at'):
263
+ logger.info(color(f" since {live.get('published_at')}", 'dim'))
264
+ else:
265
+ logger.info(color("● Live: not deployed yet", 'dim'))
266
+ # In progress
267
+ if active:
268
+ vc = active.get('app_version_code')
269
+ ver = f"v{vc}" if vc is not None else (active.get('app_version') or '?')
270
+ logger.info(color(f"⠿ In progress: {ver} ({active.get('job_status')})", 'cyan'))
271
+ logger.info(color(
272
+ f" submission {active.get('submission_id')}"
273
+ + (f" · started {active.get('triggered_at')}" if active.get('triggered_at') else ""),
274
+ 'dim'))
275
+ logger.info(color(f" Cancel: nowrun cancel -a {app_id}", 'dim'))
276
+ # Recent history
277
+ if recent:
278
+ glyphs = {'success': '✓', 'failed': '✗', 'cancelled': '∅', 'progress': '⠿'}
279
+ logger.info("Recent:")
280
+ for s in recent[:5]:
281
+ vc = s.get('app_version_code')
282
+ ver = f"v{vc}" if vc is not None else (s.get('app_version') or '-')
283
+ glyph = glyphs.get(s.get('job_status'), '·')
284
+ created = s.get('created_at') or ''
285
+ logger.info(f" {glyph} {ver:<8} {str(s.get('job_status') or ''):<9} {created}")
286
+ emit_success(data, human=_print)
287
+
288
+
245
289
  def status(app_id, submission_id):
246
290
  """Fetch the test-link / cloud build job status for a submission."""
247
291
  ok, body, http_status = _fetch_status(app_id, submission_id)
@@ -18,8 +18,7 @@ def inspect(path):
18
18
  if not meta or not meta.get("package"):
19
19
  emit_error(
20
20
  "Could not read Android metadata from this file.",
21
- hint="Pass an .apk, or an Android .zip that contains one. HTML5 .zip web "
22
- "bundles have no Android manifest, so there's nothing to read.")
21
+ hint="Pass an .apk, or an Android .zip that contains one.")
23
22
  return
24
23
  emit_success(meta, human=_print_inspect)
25
24
 
@@ -29,27 +29,17 @@ WORKSPACE_FILE = 'nowrun.json' # per-project app binding (stays in the cwd)
29
29
 
30
30
  # nowrun-backend topology: the API is served at DOMAIN/api, the dashboard at DOMAIN/console.
31
31
  PROD_HOST = 'https://nowrun.io/api'
32
- QA_HOST = 'https://qa.nowrun.io/api'
33
- DEV_HOST = 'https://dev.nowrun.io/api'
34
32
  LOCAL_HOST = 'http://localhost:3000/api'
35
33
 
36
34
  # Default to prod. Point at another environment with the internal `--host` flag
37
- # (`nowrun login --host qa`), which accepts a shorthand below or a full URL.
35
+ # (`nowrun login --host https://qa.nowrun.io`), which takes a full URL or a bare host -
36
+ # there are no named shorthands. See resolve_host.
38
37
  DEFAULT_HOST = PROD_HOST
39
38
 
40
- # Shorthand -> API host, and API host -> console (dashboard) URL where `nowrun login`
41
- # opens the browser. prod -> nowrun.io, qa / qa1 -> qa.nowrun.io, dev -> dev.nowrun.io.
42
- _HOST_ALIASES = {
43
- 'prod': PROD_HOST,
44
- 'qa': QA_HOST,
45
- 'qa1': QA_HOST,
46
- 'dev': DEV_HOST,
47
- 'local': LOCAL_HOST,
48
- }
39
+ # API host -> console (dashboard) URL where `nowrun login` opens the browser. Only the
40
+ # exceptions live here: local serves its dashboard on a different port. Every other
41
+ # host's console is DOMAIN/console, derived in console_url().
49
42
  _CONSOLE_URLS = {
50
- PROD_HOST: 'https://nowrun.io/console',
51
- QA_HOST: 'https://qa.nowrun.io/console',
52
- DEV_HOST: 'https://dev.nowrun.io/console',
53
43
  LOCAL_HOST: 'http://localhost:3002',
54
44
  }
55
45
 
@@ -86,12 +76,26 @@ def ui_host():
86
76
 
87
77
 
88
78
  def resolve_host(value):
89
- """Map a --host value to a full API host URL. Accepts a full URL, or a shorthand
90
- (prod / qa / qa1 / dev / local). Returns None for an empty value."""
79
+ """Map a --host value to a full API host URL. Takes a full URL
80
+ ("https://qa.nowrun.io") or a bare host ("qa.nowrun.io", "localhost:3000") -
81
+ there are no named shorthands. Returns None for an empty value.
82
+
83
+ The API lives at DOMAIN/api and the client appends API paths (/v2/...) to the
84
+ host verbatim, so the resolved host MUST end in /api - otherwise every call
85
+ hits the wrong path and auth looks broken. So we normalise: add a scheme for a
86
+ bare host (http for localhost, else https), then the /api suffix if missing.
87
+ """
91
88
  if not value:
92
89
  return None
93
- v = value.strip()
94
- return _HOST_ALIASES.get(v.lower(), v)
90
+ v = value.strip().rstrip('/')
91
+ if not v:
92
+ return None
93
+ if not (v.startswith('http://') or v.startswith('https://')):
94
+ scheme = 'http' if v.split(':', 1)[0] in ('localhost', '127.0.0.1') else 'https'
95
+ v = f'{scheme}://{v}'
96
+ if not v.endswith('/api'):
97
+ v = f'{v}/api'
98
+ return v
95
99
 
96
100
 
97
101
  def _cache_obj():
@@ -301,6 +301,12 @@ _CODE_GUIDANCE = {
301
301
  'BULK_PARTIAL_FAILURE': (
302
302
  "Some items in the batch failed.",
303
303
  "See each item's error_code/message in `detail.results`; fix those and retry only the failed ids."),
304
+ 'DEPLOY_IN_PROGRESS': (
305
+ "A deployment is already in progress for this app, so another can't start yet.",
306
+ "Watch it with `nowrun status`; to replace it, cancel first with `nowrun cancel`, then deploy again."),
307
+ 'NO_DEPLOY_IN_PROGRESS': (
308
+ "There's no deployment in progress for this app to cancel.",
309
+ "Check the current state with `nowrun status`."),
304
310
  }
305
311
 
306
312
 
@@ -1,14 +1,14 @@
1
1
  ---
2
2
  name: nowrun-publishing
3
- description: Deploy and manage Android and HTML5 (web) apps on nowrun using the `nowrun` CLI. Use whenever the user wants to create an app, upload a build, deploy it to a live link, check status/readiness, or update its listing metadata.
3
+ description: Deploy and manage Android apps on nowrun using the `nowrun` CLI. Use whenever the user wants to create an app, upload a build, deploy it so people can run it in ChatGPT, check status/readiness, or update its listing metadata.
4
4
  ---
5
5
 
6
6
  # Publishing to nowrun with the `nowrun` CLI
7
7
 
8
- The `nowrun` CLI deploys **Android** apps (compiled `.apk`/`.zip`) and **HTML5** web
9
- games (a `.zip` web bundle) to nowrun. The human signs in once with `nowrun login`
10
- (browser); you operate the CLI, and the human handles account setup and app configuration
11
- in the dashboard.
8
+ The `nowrun` CLI deploys **Android** apps (a compiled `.apk`, or an `.apk` inside a `.zip`)
9
+ to nowrun, so people can run them inside ChatGPT (and other AI assistants that connect to
10
+ nowrun). The human signs in once with `nowrun login` (browser); you operate the CLI, and the
11
+ human handles account setup and app configuration in the dashboard.
12
12
 
13
13
  ## First: discover the tool (don't guess commands)
14
14
 
@@ -85,7 +85,9 @@ Follow this sequence end to end; each step is expanded in the sections below.
85
85
  the flag that sets it). For Android, also verify the binary (package matches,
86
86
  release-signed) - see "Android builds" below.
87
87
  5. **Deploy** - `nowrun deploy -f <build> [-av <name> -vc <code>]`, then poll
88
- `nowrun status -s <submission_id>` until it returns the live URL. Share the URL.
88
+ `nowrun status -s <submission_id>` until it goes live (or pass `--wait`). Once live, the
89
+ app is runnable in ChatGPT. `nowrun status` (no `-s`) shows what's live and what's building;
90
+ `nowrun cancel` stops a build in progress so you can start another.
89
91
  6. **Delete (end of life)** - `nowrun app delete -a <app_id> --yes` permanently removes the
90
92
  app and frees a plan app slot. Destructive and irreversible: only on an explicit user
91
93
  request, and confirm the exact `app_id` first.
@@ -104,12 +106,9 @@ nowrun app list --json # otherwise find an existing app whose app_pkg_name m
104
106
  `nowrun use <app_id>` (writes nowrun.json).
105
107
  - **No match:** create it with all its metadata (see "App metadata" below); this
106
108
  **auto-binds** (writes nowrun.json):
107
- - **Android:** `nowrun app create --title "My App" -p com.foo.bar ...` (or
108
- `--from-apk app.apk` to read the package name - and, without `--icon`, the launcher
109
- icon - straight from the build instead of `-p`).
110
- - **HTML5:** `nowrun app create --title "My Game" --type h5 ...` - no package; it is
111
- synthesized. (HTML5 apps won't appear in `app list` by package, so reuse the `app_id`
112
- from nowrun.json / `app list` rather than matching a package name.)
109
+ `nowrun app create --title "My App" -p com.foo.bar ...` (or `--from-apk app.apk` to read
110
+ the package name - and, without `--icon`, the launcher icon - straight from the build
111
+ instead of `-p`).
113
112
 
114
113
  After this, `nowrun.json` holds the app_id and `deploy`/`status`/`validate` default to it.
115
114
  Never run `app create` for a package that already has an app.
@@ -176,25 +175,33 @@ human; only a nowrun admin can re-enable it.
176
175
  out of an Android build. `validate` flags a missing icon; set one before deploying so the
177
176
  live app looks complete. (`--from-apk` is best-effort - if the build has no resolvable
178
177
  raster icon it tells you, and you provide an image instead.)
179
- - **Deploy:**
180
- - **Android:** `nowrun deploy -f app.apk` - the version name and code are read from the
181
- APK automatically. Pass `-av <name> -vc <code>` only to override; if they disagree with
182
- the APK the CLI warns you. First verify the binary (see "Android builds" below).
183
- - **HTML5:** `nowrun deploy -f game.zip` (a web bundle; no version name/code - HTML5 has none).
184
-
185
- Uploads the build and deploys it, then poll `nowrun status -s <submission_id>` until it
186
- returns a live URL. Share the URL. Add `--wait` to have `deploy` block and return the live
187
- URL itself (no polling loop needed).
178
+ - **Deploy:** `nowrun deploy -f app.apk` - the version name and code are read from the APK
179
+ automatically. Pass `-av <name> -vc <code>` only to override; if they disagree with the APK
180
+ the CLI warns you. First verify the binary (see "Android builds" below). The version code must
181
+ be higher than the currently live one (like the Play Store) or the deploy is rejected.
182
+
183
+ Uploads the build and deploys it, then poll `nowrun status -s <submission_id>` until it goes
184
+ live; once live, the app is runnable in ChatGPT. Add `--wait` to have `deploy` block until it
185
+ settles (no polling loop needed).
188
186
  If deploy fails with error code `CARD_NOT_VERIFIED` (HTTP 402), the human hasn't
189
187
  verified a payment card yet - tell them to add one in the nowrun dashboard (the error
190
188
  message includes the link), then retry. This is required only once, before the first deploy.
189
+ - **See what's deployed / building:** `nowrun status` (no `-s`) shows the current live
190
+ version and URL, any build in progress, and recent deployment history for the app - the same
191
+ view as the dashboard's deploy panel. Use `nowrun status -s <submission_id>` to check one
192
+ specific submission.
193
+ - **Cancel a deployment:** `nowrun cancel` stops the build in progress for the app so you can
194
+ start a new one (there's only one deployment per app at a time). Only one build can run at a
195
+ time: if a deploy fails with `DEPLOY_IN_PROGRESS` (HTTP 429), either wait/poll `nowrun status`
196
+ or `nowrun cancel` first, then deploy again. `NO_DEPLOY_IN_PROGRESS` means nothing was running
197
+ to cancel. A cancelled build won't go live even if its cloud job later finishes.
191
198
 
192
199
  ## Android builds: verify the binary before you deploy
193
200
 
194
201
  `nowrun validate` covers the app's title and icon, NOT the binary - so before an **Android**
195
202
  deploy (`.apk` or `.zip`), check the actual file yourself. These checks use the Android SDK
196
203
  build-tools (`aapt`/`aapt2`, `apksigner`); if they aren't installed, say so instead of
197
- silently skipping. (HTML5 `.zip` web bundles are exempt - they have no package name or signing.)
204
+ silently skipping.
198
205
 
199
206
  1. **Package name matches the bound app.** The APK's package must equal the app's package
200
207
  name (`app_pkg_name` from `nowrun app list`, i.e. what you bound with `nowrun use`). A
@@ -244,16 +251,20 @@ Common cases:
244
251
  - **`MODE_REQUIRED` / `ADMIN_MODE_REQUIRED` / `NOT_ADMIN`:** see "Personal vs admin mode".
245
252
  - **`NETWORK_ERROR`:** nowrun was unreachable; retry. The session is still valid.
246
253
  - **Not signed in (401, `SESSION_EXPIRED`):** the session is gone. Ask the human to run `nowrun login`.
247
- - **In progress (429):** a deploy is already running. Poll `nowrun status`; don't retry the deploy.
254
+ - **`DEPLOY_IN_PROGRESS` (429):** a deploy is already running for this app. Poll `nowrun status`,
255
+ or `nowrun cancel` to stop it, then deploy again - don't just retry.
256
+ - **`NO_DEPLOY_IN_PROGRESS` (404, on cancel):** nothing was building, so there was nothing to cancel.
248
257
 
249
258
  ## Rules & gotchas
250
259
 
251
260
  - **Deploy is async.** After `deploy`, poll `status`; `job_status: progress` means the
252
- cloud build is running; `success` yields the live URL.
253
- - **`submitted` means "in review," not failed.** A 429 with "in progress" means a
254
- deployment is already running, poll, don't retry.
255
- - **There is one deployment per app.** `nowrun deploy` publishes it to a single live
256
- public link - there are no separate tracks or environments to choose or promote to.
261
+ cloud build is running; `success` means it's live and runnable in ChatGPT.
262
+ - **`submitted` means "in review," not failed.** A 429 (`DEPLOY_IN_PROGRESS`) means a
263
+ deployment is already running: poll `nowrun status`, or `nowrun cancel` and redeploy - don't retry.
264
+ - **There is one deployment per app, one build at a time.** `nowrun deploy` publishes to a
265
+ single live public link - no separate tracks or environments to choose or promote to - and a
266
+ new build can't start (or upload) while one is in progress. `nowrun status` shows what's live
267
+ and what's building; `nowrun cancel` clears an in-progress build so you can deploy again.
257
268
  - **`app delete` is permanent.** Only run it on an explicit user request, confirm the exact
258
269
  `app_id`, and pass `--yes` when running non-interactively (it otherwise asks to confirm).
259
270
  - **Admin tools are for nowrun admins only**, in admin mode, and only when the human asks:
@@ -267,8 +278,13 @@ Common cases:
267
278
  exact ids (for `delete`, that they understand it can't be undone).
268
279
  - **Provisioning an app from a build link (nowrun admins, admin mode):**
269
280
  `nowrun admin apps fetch-apk -a <app_id> --url <link> [--expected-package <pkg>]` downloads
270
- the APK server-side and reports its package, version, size and SHA-256 - it does NOT deploy.
271
- These are untrusted third-party builds: review the reported package/version, then add
272
- `--deploy` (which confirms first unless `--yes`) to deploy the staged build. `PACKAGE_MISMATCH`
273
- means the build's package isn't the one you expected - fetch the right build.
281
+ the build **onto this machine**, reads its package/version/size/SHA-256 **locally**, and
282
+ reports them - it does NOT deploy. (The download and, on deploy, the upload happen from here,
283
+ not through nowrun's servers - a local machine is faster and metadata is read locally.) The
284
+ link may be a `.apk` or a wrapper `.zip` that contains the APK (e.g. a now.gg apps-content
285
+ package); the version is read from the base APK either way. These are untrusted third-party
286
+ builds: review the reported package/version, then add `--deploy` (which confirms first unless
287
+ `--yes`) to deploy it through the normal upload flow - the same path as `nowrun deploy -f`,
288
+ which also refuses a build whose package doesn't match the bound app. `PACKAGE_MISMATCH` means
289
+ the build's package isn't the one you passed to `--expected-package` - fetch the right build.
274
290
  - For any command or argument you're unsure about, **read `nowrun <cmd> -h`**, never invent flags.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: nowrun
3
- Version: 2.0.0
3
+ Version: 2.0.2
4
4
  Summary: CLI to deploy and manage Android and HTML5 apps on nowrun.
5
5
  Home-page: https://nowrun.io
6
6
  Author: nowrun
@@ -14,12 +14,12 @@ Description-Content-Type: text/markdown
14
14
 
15
15
  # nowrun CLI
16
16
 
17
- Deploy Android and HTML5 apps to a live URL - driven by your AI coding agent.
17
+ Publish Android apps so people can run them inside ChatGPT - driven by your AI coding agent.
18
18
 
19
- `nowrun` publishes Android and HTML5 apps to [nowrun](https://nowrun.io) and streams each at a
20
- URL anyone can open in a browser, no install. You don't run deploy commands by hand: you set
21
- the CLI up once, then tell your agent to ship. Android works with any engine that builds an APK
22
- (native, Unity, Godot, Unreal); HTML5 games deploy as a zipped bundle.
19
+ `nowrun` publishes Android apps to [nowrun](https://nowrun.io) so people can run them inside
20
+ ChatGPT (and other AI assistants that connect to nowrun) - streamed into the conversation, no
21
+ install and no app store. You don't run deploy commands by hand: you set the CLI up once, then
22
+ tell your agent to ship. Works with any engine that builds an APK (native, Unity, Godot, Unreal).
23
23
 
24
24
  ## Quickstart
25
25
 
@@ -38,10 +38,10 @@ copy or paste. (`nowrun update` refreshes it later; `nowrun logout` signs out.)
38
38
 
39
39
  **3. Tell your agent.** Open your AI coding tool in your project and give it this prompt:
40
40
 
41
- > Deploy my app to nowrun and give me the live public URL using the nowrun CLI. If you need the steps, read https://nowrun.io/docs/skill.md
41
+ > Deploy my app to nowrun so people can run it in ChatGPT using the nowrun CLI. If you need the steps, read https://nowrun.io/docs/skill.md
42
42
 
43
- Your agent creates (or reuses) the app, uploads the build, deploys it, and hands back the live
44
- URL. That's the whole flow.
43
+ Your agent creates (or reuses) the app, uploads the build, and deploys it - and once it's live,
44
+ people can run it inside ChatGPT. That's the whole flow.
45
45
 
46
46
  ## What your agent runs for you
47
47
 
@@ -56,13 +56,14 @@ They're here for reference (and the occasional manual run):
56
56
  | `nowrun app list` / `nowrun app show` | list your apps / show one, with its visibility and readiness |
57
57
  | `nowrun inspect app.apk` | read a build's package + version, no Android SDK needed |
58
58
  | `nowrun validate` | check what's missing before deploying |
59
- | `nowrun deploy -f app.apk` | deploy a build and get its live link (version read from the APK; `--wait` blocks until live) |
59
+ | `nowrun deploy -f app.apk` | deploy a build so it's runnable in ChatGPT (version read from the APK; `--wait` blocks until live) |
60
60
  | `nowrun status -s <submission_id>` | poll until it's live |
61
61
  | `nowrun help --json` | full command list + workflow (what the agent reads) |
62
62
 
63
63
  `deploy` reads the build's package/version and **refuses to deploy a build whose package
64
- doesn't match the app**, so you never publish to the wrong place. Add `--json` (in any
65
- position) to any command for machine-readable output.
64
+ doesn't match the app**, so you never publish to the wrong place. It also **refuses a version
65
+ code that isn't higher than the one currently live** (like the Play Store), so you can't ship a
66
+ downgrade. Add `--json` (in any position) to any command for machine-readable output.
66
67
 
67
68
  New apps are private; a nowrun admin decides which deployed apps are listed in ChatGPT.
68
69
  You stay signed in: the CLI refreshes its session automatically (for 30 days after last use).
@@ -5,7 +5,7 @@ long_description = (this_directory / "README.md").read_text(encoding="utf-8")
5
5
 
6
6
  setup(
7
7
  name='nowrun',
8
- version='v2.0.0',
8
+ version='v2.0.2',
9
9
  description='CLI to deploy and manage Android and HTML5 apps on nowrun.',
10
10
  long_description=long_description,
11
11
  long_description_content_type='text/markdown',
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes